fix: make codex queue storage pg authoritative

This commit is contained in:
Codex
2026-05-11 14:54:32 +00:00
parent 688376abc4
commit ae462ed9ef
21 changed files with 1397 additions and 578 deletions
+1 -1
View File
@@ -115,7 +115,7 @@ services:
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_DATA_DIR: "/var/lib/unidesk/codex-queue"
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"
+4
View File
@@ -26,6 +26,10 @@
- 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
- Frontend Time Zone Policy
- All UniDesk frontend timestamps, dates, clocks, update times, heartbeat times, Trace times, Gantt axis labels, export date stamps, and `datetime-local` values must render as Beijing time.
- Beijing time means IANA timezone `Asia/Shanghai` / UTC+8, regardless of the browser timezone, host system timezone, container timezone, or server-side `project.timezone` value.
- Frontend code must use the shared formatter and input conversion helpers in `src/components/frontend/src/time.ts`; raw ISO/UTC timestamps may appear only inside explicitly opened raw JSON views.
- PostgreSQL Database
- Deployed as a Docker container with a 10 GB named volume
- Stores all task metadata, node heartbeats, resource labels, and business state
+1 -1
View File
@@ -9,7 +9,7 @@
- `frontend` 是唯一公开 Web 控制台,提供登录、从 TSX 转译出的 React 应用资产和到 backend-core 的同源代理。
- `provider-gateway` 是当前主 server 的本机计算节点代理,通过 WebSocket 主动连到 provider ingress,挂载 `/var/run/docker.sock` 作为自动任务执行主路径,使用 `pid: "host"` 读取节点级进程资源,并周期性上报系统资源指标、进程占用与 Docker daemon 状态;维护用 Host SSH / WSL SSH 私钥目录只读挂载到 `/run/host-ssh`,不得作为自动任务调度主路径。
- `todo-note` 是主 server 承载的 Todo Note 纯后端用户服务,容器名 `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、打断和重试。
- `codex-queue` 是主 server 承载的 Codex app-server 队列用户服务,容器名 `codex-queue-backend`,仅在 Compose 内网暴露 `4222/tcp` 给本机 provider-gateway 私有代理访问,任务、queue、未读状态、控制状态和通知 outbox 一律写入主 PostgreSQL,不保留本地状态文件 fallback,浏览器只能通过 UniDesk frontend 同源代理查看运行输出、追加 prompt、打断和重试。
- `project-manager` 是主 server 承载的项目管理用户服务,容器名 `project-manager-backend`,仅在 Compose 内网暴露 `4233/tcp`,项目清单写入主 PostgreSQL,浏览器只能通过 UniDesk frontend 同源代理执行增删改查、Excel 导入和 Excel 导出。
## Public Exposure Boundary
+1 -1
View File
@@ -40,7 +40,7 @@ Typical targeted commands:
- 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, 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://<publicHost>:<frontendPort>/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: 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://<publicHost>:<frontendPort>/app/pipeline/` 能直接落到 Pipeline 页面,随后切到 `资源节点 / Docker 状态` 时地址栏更新为 `/nodes/docker/`,并且浏览器 history 返回链路仍能回到 `/app/pipeline/`;还必须直开 `/app/codex-queue/` 验证页面存在 `app-shell`、左侧主模块边栏、顶部状态栏、顶部子标签和 `codex-queue-page`,防止用户服务 deep link 退化成缺 shell 的 standalone 页面;同时 `态势总览` 这类非用户服务页面应落在自己的模块前缀下,例如 `/ops/status/`Playwright 必须覆盖默认可见时间按北京时间显示,至少包括顶部 `北京时间` 时钟、任务历史/网关版本更新时间和用户服务刷新时间,不得随浏览器本地时区漂移。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.
+7 -1
View File
@@ -8,6 +8,10 @@ frontend 应用源码必须使用 TypeScript + React,禁止在 `src/components
`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` 只做导入和路由分发。
## Time Zone Contract
frontend 所有默认可见的时间、日期、时钟、更新时间、心跳时间、Trace 时间、Gantt 时间轴刻度、导出文件日期和 `datetime-local` 输入值都必须按北京时间显示,即使用 IANA 时区 `Asia/Shanghai` / UTC+8。禁止依赖浏览器本地时区、服务器系统时区或裸 `Date.toLocaleString()` 默认值;新增页面必须复用 `src/components/frontend/src/time.ts` 的统一格式化和输入转换函数。原始 JSON 中的 ISO 时间戳只能在用户显式点击 `查看原始JSON` 后作为原始数据出现,默认结构化控件不得把 UTC/本地时区混入北京时间显示。
## Layout
左侧边栏只切换主模块:运行总览、资源节点、任务调度、用户服务、系统配置。顶部标签只切换当前主模块内的子功能;例如资源节点下的节点清单、资源标签、心跳状态只属于资源节点,用户服务下的服务目录、Todo Note、FindJob、Pipeline、MET Nonlinear、Codex Queue 只属于用户服务,和运行总览、任务调度、系统配置没有重复或共享语义。桌面端左侧边栏必须支持收起,只保留模块 code 和展开按钮,以便最大化主面板空间;移动端左侧边栏会转为顶部横向主模块条,但高度必须在不同主模块之间保持一致,并保持窄条、单行、不换行;主内容区无论内容多少都必须从顶部向下排列,空状态也不得上下居中制造大块留白。
@@ -65,7 +69,9 @@ frontend shell 必须把左侧主模块与顶部子标签编译为统一的 URL
- `Todo Note` 子标签必须把主 server `todo-note-backend` 后端渲染为 UniDesk React 控件,包括迁移清单、树形任务、筛选、提醒、拖放/移动、撤销/重做、字号控制和显式原始 JSON 按钮。
- `FindJob` 子标签必须把 D601 findjob 后端渲染为 UniDesk React 控件,包括岗位指标、岗位预览、草稿报告和显式原始 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 callCodex 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 <taskId>` 的提示;连续执行同一 prompt 应通过入队份数一次性生成多条任务,避免快速连点造成操作员误判。
- `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 callCodex 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 含引用注入时,引用内容必须默认折叠,并只在 Trace 的初始消息中提供可展开的“最终传入 Codex 的真实完整 prompt”,不得再渲染独立 Prompt 全量卡片;多轮引用注入必须按上游/最早上下文在前、直接引用在后的顺序排列,每一轮必须有明确 `Reference Round N/M` 分割线和时间范围,不能用固定 6 轮截断引用链;点击队列引用按钮必须自动把该任务 ID 写入提交表单的引用输入框,引用任务 ID 创建新任务时必须自动注入 `bun scripts/cli.ts codex task <taskId>` 的提示;连续执行同一 prompt 应通过入队份数一次性生成多条任务,避免快速连点造成操作员误判。
- `Codex Queue` 前端改进必须在同一任务内重建并上线公网 frontend,不能只修改源码或本地 bundle;重建 frontend 是无状态 WebUI 替换,不会导致 Codex Queue 长期任务失败。已结束未读任务只能在 task card 边角显示类似未读消息的 `codex-unread-badge` 圆点和“标为已读”操作,不得把整张卡片改成红色/琥珀色失败态边框、背景或胶囊标签;状态栏的“结束未读”提示也不得使用失败态红色。
- `Codex Queue` 前端必须把 PostgreSQL-backed backend API 作为 task、queue、readAt/未读状态和 attempt 状态的唯一数据来源;不得用 `localStorage``sessionStorage` 或 IndexedDB 持久化这些业务状态,也不得在后端标记已读失败时伪造本地成功。前端允许保留 React 内存态、请求 in-flight guard 和本轮页面缓存,但刷新页面或切换设备后的状态必须完全由后端 PostgreSQL 数据恢复。
- `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。
+10 -9
View File
@@ -30,7 +30,7 @@ UniDesk 用户服务是挂载到 UniDesk 核心服务上的、面向用户使用
## Main Server User Services
主 server 只承载对统一入口、状态迁移或控制面自动化有明确必要的用户服务。该类服务仍遵守不暴露公网端口、前端统一 React 控件化展示的规则;业务持久状态优先写入主 PostgreSQL,控制队列这类运行态可使用 `.state/` 文件并必须提供 `/logs` 与结构化状态端点
主 server 只承载对统一入口、状态迁移或控制面自动化有明确必要的用户服务。该类服务仍遵守不暴露公网端口、前端统一 React 控件化展示的规则;业务持久状态必须写入主 PostgreSQL`.state/` 只能保存日志归档、缓存或可重建工件,不能作为任务、队列、未读、通知 outbox 等权威状态来源
### Todo Note On Main Server
@@ -72,18 +72,19 @@ Project Manager 在 UniDesk 语境中按纯后端服务管理:不得将 `4233`
- 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` serviceDockerfile 为 `src/components/microservices/codex-queue/Dockerfile`,容器名为 `codex-queue-backend`
- 上线纪律:Codex Queue 相关的前端或后端改进必须在同一任务内正式上线并验证公网 frontend 或 live API,不能只停留在源码、构建产物或“后续再上线”。重建 `frontend` 只替换无状态 WebUI 容器,不会触碰 `codex-queue-backend`、PostgreSQL 队列或运行中 Codex thread,不能以“可能影响长期任务”为由延迟前端上线;`codex-queue-backend` 本身带有 restart-recovery,允许按 `server rebuild codex-queue` 或 Compose 重启/替换,停止、重启或重建后必须从持久化状态恢复运行中和排队任务。
- 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。
- 用户输入持久化:任务初始 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 <taskId>``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 Queue 的第一目标是长期稳定可用;部署修复或运维排障时不得因为担心容器重启会打断任务而拒绝重启、重建或替换 `codex-queue-backend`。容器重启、服务进程重启和镜像替换后,队列、`promptHistory`、running/judging/retry_wait 任务和 active session 元数据必须从 PostgreSQL 恢复,并在已有 `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 `MiniMax-M2.7` `complete|retry|fail` 的判定是权威结果;任何非 LLM 判断,包括字符串匹配、正则、硬编码 safety override,都不得覆盖、降级或提升一次成功的 MiniMax 判定。非 LLM/fallback 判断只允许在 MiniMax 未配置、额度/限流/网络/超时不可用,或 JSON 去噪与 repair 全部耗尽后启用。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。continuation/judge feedback prompt 只应携带本轮缺口、恢复原因、验收要求和有界原始任务摘要,禁止重新注入完整引用上下文、历史 transcript 或长 JSON;服务重启恢复类 feedback 尤其必须保持短 prompt,依赖现有 thread 上文继续。只有 judge 判定 `complete` 后,队列 worker 才把当前任务标为成功并推进下一个 queued/retry_wait 任务。非 LLM/fallback 判定产生的 `retry` 最多累计 `3` 次;达到上限后当前任务必须转为 `failed` 并记录原因,worker 继续推进后续 queued/retry_wait 任务,避免 fallback safety override 或硬编码判断造成无限循环
- Judge 探针:`GET|POST /api/judge/probe` 使用同一套 judge 逻辑跑内置 synthetic execution records,覆盖正常完成、正常结束但只给计划、未上线/未部署的服务或 WebUI 改动、传输中断和用户打断样本,返回 `hits``total``hitRate`、每例 `expected``decision`;该接口不得回显 MiniMax API key。
- 模型选择:默认 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/<id>/transcript``/api/tasks/<id>/output` 必须从 archive 分页重建完整历史,不得因为热状态裁剪而丢失早期 trace热 task JSON 只保留可配置窗口(默认 600 条 output、400 条 event)以保证 `/health``/api/tasks` 和 PostgreSQL flush 不被长任务拖死。WebUI 必须支持多 queue 查看、显式创建 queue、提交时下拉选择 queue,并支持把已创建且非 active 的任务移动到其他 queuequeue 内串行,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` 必须暴露非敏感配置与是否已配置目标
- 状态与日志:默认工作目录为容器内 `/root/unidesk`,该路径映射主 server 的 `~/unidesk`;同时保留 `/workspace` 映射以兼容历史任务。Codex Queue 的任务、queue、`readAt`/未读状态、attempt、judge、`promptHistory`、active session 元数据、控制状态和 ClaudeQQ 通知 outbox 一律以主 PostgreSQL 为权威,分别写入 `unidesk_codex_queue_tasks``unidesk_codex_queue_queues``unidesk_codex_queue_notifications``DATABASE_URL` 是必需配置,服务不得在 PostgreSQL 缺失或不可用时进入文件存储模式。`.state/codex-queue/state.json` 不再作为任务或 queue 状态存储,不得重新引入本地 JSON fallback服务启动必须以 PostgreSQL 为唯一来源恢复队列,并把 running/judging 任务恢复为 retry_wait。WebUI 不得用 browser `localStorage``sessionStorage` 或 IndexedDB 持久化 task/queue/readAt/unread 等业务状态;浏览器只能保留临时 UI 内存缓存,刷新后必须重新从后端读取 PostgreSQL 权威数据。Codex CLI-like output/Trace 的完整记录可以使用 append-only 文件作为日志型归档,但任务状态、未读状态和列表摘要不得依赖这些文件作为权威来源;`/api/tasks/<id>/transcript``/api/tasks/<id>/output` 必须分页重建完整历史,不得因为热状态裁剪而丢失早期 trace热 task JSON 只保留可配置窗口(默认 600 条 output、400 条 event)以保证 `/health``/api/tasks` 和 PostgreSQL flush 不被长任务拖死。WebUI 必须支持多 queue 查看、显式创建 queue、提交时下拉选择 queue,并支持把已创建且非 active 的任务移动到其他 queuequeue 内串行,queue 间并行。Codex Queue 镜像必须内置 Playwright Chromium 浏览器与系统依赖,保证队列任务能直接执行公网 frontend Playwright 回归,不得只在宿主机临时安装。日志写入 UniDesk `logs/{YYYYMMDD}/..._codex-queue.jsonl``/logs` 端点返回最近结构化日志。`/health``queue.storage.primary` 必须恒为 `postgres`,并通过 `queue.storage.postgresReady``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` 配置。任务终态和队列空闲通知必须先写入 PostgreSQL outbox 表 `unidesk_codex_queue_notifications` 再异步发送;不得使用 `.state/codex-queue/claudeqq-notifications.json``CODEX_QUEUE_NOTIFY_CLAUDEQQ_OUTBOX_PATH` 或任何本地 JSON 作为通知权威存储。发送失败、NapCat 离线、代理 502 或容器重启时不能丢通知,必须按 `CODEX_QUEUE_NOTIFY_CLAUDEQQ_RETRY_INTERVAL_MS` 指数退避重试并跨进程/容器重启保留。`/health``queue.notifications.claudeqq` 必须暴露非敏感配置、目标配置状态和 PostgreSQL outbox 统计;`GET /api/notifications/claudeqq` 返回 outbox 明细,`POST /api/notifications/claudeqq/drain` 手动触发发送,`POST /api/notifications/claudeqq/backfill` 可按 `since` 补入某次故障窗口内已终态任务,确保 QQ/NapCat 超时或离线不会让任务完成通知永久丢失
- 代理路径:只允许 `/health``/logs``/api/` 前缀;允许方法为 `GET``HEAD``POST``DELETE`。Codex Queue 只在 Compose 内网暴露 `4222/tcp`,不得映射或开放到公网。
- 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 callCodex Queue 与 Pipeline/OpenCode messages 必须共用 `src/components/frontend/src/trace.tsx` 的 Trace 公共组件、统一 Trace item 接口和 codex/opencode port 适配层;连续 read/edit/run 工具调用只是在 Trace 内折叠为可展开工具调用组,汇总格式至少包含 `xx read, xx edit, xx run`,并展示读取文件、编辑文件、运行命令和耗时摘要;最近 3 个工具调用保持展开,工具调用内容不得自动换行且必须在工具调用块内部横向滚动,工具调用组展开后不得再增加额外左侧缩进;message 与 prompt 必须自动换行,普通 message 不显示左侧项目符号缩进且永不折叠;点击队列卡片引用按钮必须自动把该任务 ID 写入提交表单的引用任务 ID 输入框;引用任务 ID 创建新任务时必须自动注入 `bun scripts/cli.ts codex task <taskId>` 的提示,让 Codex 读取初始 prompt、最后消息和工具摘要后继续;连续执行同一 prompt 应使用 `入队份数` 一次性生成多条队列任务,而不是依赖快速连点按钮;原始任务 JSON 只能通过显式 `查看原始JSON` 打开。
@@ -175,7 +176,7 @@ ClaudeQQ 在 UniDesk 语境中按消息网关后端服务管理:不得直接
- `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/<name>' --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 后端、PostgreSQL 优先持久化、文件 fallback 快照和本机 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 强制持久化和本机 provider-gateway 私有代理链路;写入、追加 prompt、打断和 readAt/未读状态都必须由 backend 写入 PostgreSQLfrontend 不得用本地存储伪造成功状态
- `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 用户服务页面。
@@ -200,7 +201,7 @@ ClaudeQQ 在 UniDesk 语境中按消息网关后端服务管理:不得直接
- 运行 `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` 后端,并且 `/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,而不是只运行第一条。
- 运行 `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``queue.storage.postgresReady=true`,不得出现 file fallback`queue.notifications.claudeqq.outbox.storage=postgres` 且暴露 pending/failed/sent 统计。再通过公网 frontend 提交一个 `gpt-5.5` 小任务,确认队列串行推进、输出实时更新、结束后有 judge 判定,且运行中可追加 prompt 或打断。Codex Queue 的重启恢复必须作为验收项:运行中任务存在时重启或重建 `codex-queue-backend` 后,任务必须从 PostgreSQL 恢复到可继续执行状态,不能丢失 active task、`promptHistory`后续 queued 任务、readAt/未读状态或已入 outbox 的 ClaudeQQ 通知;ClaudeQQ/NapCat 离线期间结束的任务必须能在 `/api/notifications/claudeqq` 中看到 pending/failed,并在登录恢复后通过 `POST /api/notifications/claudeqq/drain` 发送。批量验收必须通过公网 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。
+6 -15
View File
@@ -1524,16 +1524,11 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2
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;
codexInitialPromptFullMetrics.legacyPromptPanelMissing = await page.evaluate(() =>
document.querySelector(".codex-prompt-panel") === null
&& document.querySelector('[data-testid="codex-task-prompt-detail"]') === null
&& document.querySelector('[data-testid="codex-final-prompt-full"]') === null,
);
}
}
if (wants("frontend:codex-queue-trace-full-load")) {
@@ -2101,11 +2096,7 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2
&& 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.legacyPromptPanelMissing === true
),
{ codexInitialPromptFullMetrics });
addSelectedCheck(checks, options, "frontend:codex-queue-trace-full-load",
File diff suppressed because one or more lines are too long
+1 -56
View File
@@ -1767,8 +1767,7 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
min-height: 0;
max-height: calc(100vh - 318px);
}
.codex-task-pagination,
.codex-lazy-detail-callout {
.codex-task-pagination {
display: flex;
flex-wrap: wrap;
align-items: center;
@@ -1782,17 +1781,6 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
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);
}
@@ -1862,17 +1850,6 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
}
.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);
@@ -2561,30 +2538,6 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
.codex-detail-grid {
grid-template-columns: minmax(320px, 1fr) minmax(320px, 1fr);
}
.codex-prompt-panel {
grid-column: 1 / -1;
}
.codex-prompt-detail {
display: grid;
gap: 9px;
min-width: 0;
}
.codex-prompt-meta {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
min-width: 0;
}
.codex-prompt-meta span:not(.status-badge) {
max-width: 100%;
padding: 3px 7px;
border: 1px solid var(--line-soft);
color: var(--muted);
background: rgba(255,255,255,0.025);
font-size: 11px;
overflow-wrap: anywhere;
}
.codex-prompt-full {
max-height: 360px;
margin: 0;
@@ -2629,14 +2582,6 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
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);
+8 -11
View File
@@ -1,4 +1,5 @@
import React from "react";
import { BEIJING_TIME_LABEL, fmtClock, fmtDate } from "./time";
import { createRoot } from "react-dom/client";
import { ClaudeQqPage } from "./claudeqq";
import { CodexQueuePage } from "./codex-queue";
@@ -73,16 +74,6 @@ function taskRawButtonData(task: any): any {
return task?._summaryOnly ? { ...task, _loadRaw: () => loadTaskRawData(task) } : task;
}
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 fmtDuration(seconds: number): string {
if (!Number.isFinite(seconds)) return "--";
@@ -191,6 +182,12 @@ function summarizeValue(value: any): string {
}
function summarizeFieldValue(key: string, value: any): string {
const normalizedKey = key.replace(/[-_\s]/g, "").toLowerCase();
const looksLikeTimeKey = normalizedKey === "ts" || normalizedKey.endsWith("at") || normalizedKey.endsWith("timestamp") || normalizedKey.endsWith("heartbeat");
if ((typeof value === "string" || typeof value === "number") && looksLikeTimeKey) {
const formatted = fmtDate(value);
if (formatted !== "--") return formatted;
}
if (key === "bodyText" && typeof value === "string") {
const kind = /^\s*[{[]/.test(value) ? "JSON" : "HTTP";
return `${kind} body ${value.length} chars`;
@@ -504,7 +501,7 @@ function TopBar({ connection, lastRefresh, onRefresh, onLogout, session, clock,
{ 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: "clock", label: BEIJING_TIME_LABEL, value: fmtClock(clock) },
{ key: "user", label: "用户", value: session?.user?.username || "--", tone: "user" },
];
return h("header", { className: "topbar" },
+1 -10
View File
@@ -1,4 +1,5 @@
import React from "react";
import { fmtClock, fmtDate } from "./time";
import { errorMessage, requestJson as requestUniDeskJson } from "./unidesk-error";
import { UniDeskErrorBanner } from "./unidesk-error-banner";
@@ -9,16 +10,6 @@ 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);
+202 -159
View File
@@ -1,4 +1,5 @@
import React from "react";
import { fmtClock, fmtDate } from "./time";
import { TraceView, codexTracePort } from "./trace";
import { errorMessage, requestJson as requestUniDeskJson } from "./unidesk-error";
import { UniDeskErrorBanner } from "./unidesk-error-banner";
@@ -11,7 +12,6 @@ const useState: any = React.useState;
const codexTranscriptChunkLimit = 120;
const codexInitialTaskLimit = 24;
const codexMoreTaskLimit = 48;
const codexLocalReadRetention = 300;
const queueErrorPreviewLength = 1200;
function isDocumentVisible(): boolean {
@@ -22,16 +22,6 @@ function errorText(error: unknown, fallback = "操作失败"): string {
return errorMessage(error, fallback);
}
function fmtDate(value: any): string {
if (!value) return "--";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "--";
return date.toLocaleString("zh-CN", { hour12: false });
}
function fmtClock(value: Date): string {
return value.toLocaleTimeString("zh-CN", { hour12: false });
}
function fmtDuration(ms: any): string {
const value = Number(ms);
@@ -45,6 +35,44 @@ function fmtDuration(ms: any): string {
return `${seconds}s`;
}
function timestampMs(value: any): number | null {
if (value === null || value === undefined || value === "") return null;
const ms = value instanceof Date ? value.getTime() : new Date(value).getTime();
return Number.isFinite(ms) ? ms : null;
}
function fmtRelativeAge(value: any, nowMs = Date.now()): string {
const atMs = timestampMs(value);
if (atMs === null) return "--";
const totalSeconds = Math.max(0, Math.floor((nowMs - atMs) / 1000));
if (totalSeconds < 1) return "刚刚";
const days = Math.floor(totalSeconds / 86400);
const hours = Math.floor((totalSeconds % 86400) / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
if (days > 0) return `${days}${hours > 0 ? `${hours}小时` : ""}`;
if (hours > 0) return `${hours}小时${minutes > 0 ? `${minutes}分钟` : ""}`;
if (minutes > 0) return `${minutes}分钟${seconds}秒前`;
return `${seconds}秒前`;
}
function latestTimestampValue(...values: any[]): string {
let best = "";
let bestMs = -Infinity;
for (const value of values) {
const text = String(value || "");
if (text.length === 0) continue;
const ms = timestampMs(value);
if (ms !== null && ms >= bestMs) {
best = text;
bestMs = ms;
} else if (best.length === 0) {
best = text;
}
}
return best;
}
function fmtPreciseMs(ms: any): string {
const value = Number(ms);
if (!Number.isFinite(value) || value < 0) return "--";
@@ -174,7 +202,6 @@ function activeTaskIds(queue: any): string[] {
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;
@@ -571,6 +598,25 @@ function attemptExecutionSummary(task: any, attempt: any): AnyRecord {
return objectRecord(attempt?.execution) || taskExecutionSummary(task);
}
function attemptExecutionUpdatedAt(task: any, attempt: any, attemptIndex: any, execution: AnyRecord): string {
const summary = taskTraceSummary(task);
const currentAttempt = Number(summary?.currentAttempt || task?.currentAttempt || 0);
const numericAttempt = Number(attemptIndex);
const isCurrentAttempt = Number.isFinite(numericAttempt) && numericAttempt > 0 && numericAttempt === currentAttempt;
const activeTaskUpdatedAt = latestTimestampValue(task?.updatedAt, summary?.updatedAt);
if (isCurrentAttempt && !attempt?.finishedAt && activeTaskUpdatedAt.length > 0) return activeTaskUpdatedAt;
return String(
attempt?.updatedAt
|| attempt?.finishedAt
|| execution.effectiveEndAt
|| (isCurrentAttempt ? activeTaskUpdatedAt : "")
|| activeTaskUpdatedAt
|| task?.finishedAt
|| task?.startedAt
|| "",
);
}
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();
@@ -632,6 +678,104 @@ function taskTraceSteps(task: any, attemptIndex: any = null): any[] {
return Array.isArray(task?._traceSteps) ? task._traceSteps : [];
}
function traceStepSummaryLines(step: any): string[] {
return (Array.isArray(step?.summaryLines) ? step.summaryLines : []).map((line: any) => String(line || ""));
}
function traceStepFileChangeMethod(step: any): string {
const status = String(step?.status || "").trim();
if (status.length > 0) return status;
const text = traceStepSummaryLines(step).join("\n");
return /^(item\/[A-Za-z]+(?:\/[A-Za-z]+)?):/u.exec(text)?.[1] || "";
}
function isFileChangeLifecycleSummaryLine(line: string): boolean {
return /^item\/(?:started|completed): file changes status=/u.test(String(line || "").trim());
}
function fileChangeStatusFromStep(step: any): string {
const lines = traceStepSummaryLines(step);
for (let index = lines.length - 1; index >= 0; index -= 1) {
const status = /file changes status=([A-Za-z0-9_-]+)/u.exec(lines[index] || "")?.[1];
if (status) return status;
}
const method = traceStepFileChangeMethod(step);
if (method === "item/fileChange/outputDelta") return "updated";
if (method === "item/started") return "started";
if (method === "item/completed") return "completed";
return method.replace(/^item\//u, "") || String(step?.status || "changed");
}
function isFileChangeTraceStep(step: any): boolean {
if (String(step?.kind || "") !== "edited") return false;
const title = String(step?.title || "");
const status = String(step?.status || "");
const text = traceStepSummaryLines(step).join("\n");
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(text)) return true;
if (/^Success\. Updated the following files:/mu.test(text)) return true;
if (/^diff --git /mu.test(text)) return true;
return /^([AMDRCU?]{1,2})\s+\S+/mu.test(text);
}
function mergeFileChangeTraceStepGroup(group: any[]): any {
if (group.length <= 1) return group[0];
const outputStep = group.find((step) => traceStepFileChangeMethod(step) === "item/fileChange/outputDelta") || group.find((step) => traceStepSummaryLines(step).some((line) => !isFileChangeLifecycleSummaryLine(line))) || group.at(-1) || group[0];
const rawSeqs = group.flatMap((step) => Array.isArray(step?.rawSeqs) ? step.rawSeqs : [step?.seq]).filter((seq) => seq !== undefined);
const summaryLines = group
.flatMap(traceStepSummaryLines)
.filter((line) => line.trim().length > 0 && !isFileChangeLifecycleSummaryLine(line));
const finalStep = group[group.length - 1] || outputStep;
return {
...outputStep,
at: outputStep?.at || finalStep?.at,
title: String(outputStep?.title || "Edited files"),
status: fileChangeStatusFromStep(finalStep),
summaryLines: summaryLines.length > 0 ? summaryLines : traceStepSummaryLines(outputStep),
rawSeqs,
};
}
function coalesceFileChangeTraceSteps(steps: any[]): any[] {
const rows = Array.isArray(steps) ? steps : [];
const merged: any[] = [];
let group: any[] = [];
const flush = () => {
if (group.length > 0) merged.push(mergeFileChangeTraceStepGroup(group));
group = [];
};
for (const step of rows) {
if (isFileChangeTraceStep(step)) {
if (traceStepFileChangeMethod(step) === "item/started" && group.length > 0) flush();
group.push(step);
if (traceStepFileChangeMethod(step) === "item/completed") flush();
continue;
}
flush();
merged.push(step);
}
flush();
return merged;
}
function executionSummaryWithDisplayedSteps(execution: AnyRecord, steps: any[]): AnyRecord {
if (steps.length === 0) return execution;
const counts = steps.reduce((memo: AnyRecord, step: any) => {
const kind = String(step?.kind || "");
if (kind === "explored") memo.readCount += 1;
else if (kind === "edited") memo.editCount += 1;
else if (kind === "ran") memo.runCount += 1;
return memo;
}, { readCount: 0, editCount: 0, runCount: 0 });
return {
...execution,
...counts,
toolCallCount: counts.readCount + counts.editCount + counts.runCount,
stepCount: steps.length,
};
}
function taskTraceStepsLoaded(task: any, attemptIndex: any = null): boolean {
if (attemptIndex !== null && attemptIndex !== undefined) {
const byAttempt = objectRecord(task?._traceStepsLoadedByAttempt) || {};
@@ -644,6 +788,20 @@ function taskTraceStepDetails(task: any): AnyRecord {
return task?._traceStepDetails && typeof task._traceStepDetails === "object" && !Array.isArray(task._traceStepDetails) ? task._traceStepDetails : {};
}
function attemptSegmentIndex(attempt: any, position: number): number {
const raw = Number(attempt?.index);
return Number.isFinite(raw) ? raw : position + 1;
}
function isSyntheticAttemptSegment(attempt: any, attemptIndex: any): boolean {
return Boolean(attempt?.synthetic) || Number(attemptIndex) <= 0;
}
function attemptDataIndex(attemptIndex: any): string | undefined {
const value = Number(attemptIndex);
return Number.isFinite(value) ? String(value) : undefined;
}
function taskDurationLabel(task: any): string {
const timing = task?.timing && typeof task.timing === "object" ? task.timing : {};
const status = String(task?.status || "");
@@ -685,46 +843,6 @@ function taskIsUnreadTerminal(task: any): boolean {
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) ? trimLocalReadAt(parsed) : {};
} catch {
return {};
}
}
function trimLocalReadAt(readAtByTask: AnyRecord): AnyRecord {
const entries = Object.entries(readAtByTask || {})
.filter(([, value]) => typeof value === "string" && value.length > 0)
.sort((left, right) => {
const leftTime = Date.parse(String(left[1] || ""));
const rightTime = Date.parse(String(right[1] || ""));
return (Number.isFinite(rightTime) ? rightTime : 0) - (Number.isFinite(leftTime) ? leftTime : 0);
})
.slice(0, codexLocalReadRetention);
return Object.fromEntries(entries);
}
function saveLocalReadAt(readAtByTask: AnyRecord): AnyRecord {
const retained = trimLocalReadAt(readAtByTask);
if (typeof window === "undefined") return retained;
try {
window.localStorage.setItem(codexReadAtStorageKey, JSON.stringify(retained));
} catch {
// Best-effort fallback only; backend readAt remains authoritative when deployed.
}
return retained;
}
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;
@@ -1035,18 +1153,21 @@ function ProgressivePromptBlock({ task, loading, onLoadPromptPart, testId = "cod
}
function ProgressiveExecutionSummary({ task, attempt, attemptIndex, loading, onLoadSteps, onLoadStep, testId = "codex-execution-summary" }: AnyRecord) {
const execution = attemptExecutionSummary(task, attempt);
const steps = taskTraceSteps(task, attemptIndex);
const steps = coalesceFileChangeTraceSteps(taskTraceSteps(task, attemptIndex));
const execution = executionSummaryWithDisplayedSteps(attemptExecutionSummary(task, attempt), steps);
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}` : "";
const synthetic = isSyntheticAttemptSegment(attempt, attemptIndex);
const labelSuffix = synthetic ? ` · ${String(attempt?.label || "recovered thread execution")}` : attemptIndex ? ` #${attemptIndex}` : "";
const updatedAt = attemptExecutionUpdatedAt(task, attempt, attemptIndex, execution);
const recentUpdateLabel = `最近更新: ${fmtRelativeAge(updatedAt)}`;
return h("details", {
className: "codex-progressive-card codex-execution-summary",
"data-testid": testId,
"data-attempt-index": attemptIndex ? String(attemptIndex) : undefined,
"data-attempt-index": attemptDataIndex(attemptIndex),
onToggle: (event: any) => {
if (event.currentTarget?.open && !stepsLoaded) onLoadSteps?.(attemptIndex);
},
@@ -1055,7 +1176,8 @@ function ProgressiveExecutionSummary({ task, attempt, attemptIndex, loading, onL
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("code", { title: updatedAt ? `最近更新: ${fmtDate(updatedAt)}` : recentUpdateLabel },
`${fmtDuration(execution.durationMs ?? execution.totalElapsedMs)} / ${toolCount} tools / ${recentUpdateLabel}`),
),
h("div", { className: "codex-execution-digest" },
h("span", null, `read ${Number(execution.readCount || 0)}`),
@@ -1122,7 +1244,7 @@ function ProgressiveFinalResponse({ task, attempt, attemptIndex, testId = "codex
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 },
return h("section", { className: "codex-progressive-card codex-final-response", "data-testid": testId, "data-attempt-index": attemptDataIndex(attemptIndex) },
h("div", { className: "codex-progressive-card-head" },
h("span", { className: "codex-output-channel" }, "Final"),
h("strong", null, `最终 response${labelSuffix}`),
@@ -1135,7 +1257,7 @@ function ProgressiveFinalResponse({ task, attempt, attemptIndex, testId = "codex
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 },
return h("section", { className: "codex-progressive-card codex-progressive-judge", "data-testid": testId, "data-attempt-index": attemptDataIndex(attemptIndex) },
h("div", { className: "codex-progressive-card-head" },
h("span", { className: "codex-output-channel" }, "Judge"),
h("strong", null, `完成判定${labelSuffix}`),
@@ -1165,7 +1287,7 @@ function ProgressiveJudgeFeedbackPrompt({ task, attempt, attemptIndex, loading,
return h("details", {
className: "codex-progressive-card codex-judge-feedback-prompt",
"data-testid": testId,
"data-attempt-index": attemptIndex ? String(attemptIndex) : undefined,
"data-attempt-index": attemptDataIndex(attemptIndex),
onToggle: (event: any) => {
if (event.currentTarget?.open && !detailText) onLoadPromptPart?.("feedback", attemptIndex);
},
@@ -1184,11 +1306,13 @@ function ProgressiveJudgeFeedbackPrompt({ task, attempt, attemptIndex, loading,
}
function ProgressiveAttemptCycle({ task, attempt, position, loading, onLoadPromptPart, onLoadSteps, onLoadStep }: AnyRecord) {
const attemptIndex = Number(attempt?.index || position + 1);
const attemptIndex = attemptSegmentIndex(attempt, position);
const first = position === 0;
const synthetic = isSyntheticAttemptSegment(attempt, attemptIndex);
const title = synthetic ? String(attempt?.label || "Recovered thread execution") : `Attempt ${attemptIndex}`;
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("span", { className: "codex-output-channel" }, title),
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)}`),
@@ -1202,19 +1326,19 @@ function ProgressiveAttemptCycle({ task, attempt, position, loading, onLoadPromp
onLoadStep,
testId: first ? "codex-execution-summary" : `codex-execution-summary-attempt-${attemptIndex}`,
}),
h(ProgressiveFinalResponse, {
synthetic ? null : h(ProgressiveFinalResponse, {
task,
attempt,
attemptIndex,
testId: first ? "codex-final-response" : `codex-final-response-attempt-${attemptIndex}`,
}),
h(ProgressiveJudge, {
synthetic ? null : h(ProgressiveJudge, {
task,
attempt,
attemptIndex,
testId: first ? "codex-progressive-judge" : `codex-progressive-judge-attempt-${attemptIndex}`,
}),
h(ProgressiveJudgeFeedbackPrompt, {
synthetic ? null : h(ProgressiveJudgeFeedbackPrompt, {
task,
attempt,
attemptIndex,
@@ -1242,63 +1366,6 @@ function ProgressiveTrace({ task, loading, onLoadPromptPart, onLoadSteps, onLoad
}
function PromptDetail({ task, loading, onLoadPromptPart }: AnyRecord) {
if (!task) return h(EmptyState, { title: "未选择任务", text: "选择队列或历史 session 后,这里显示完整 prompt、模型和工作目录。" });
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, hasReference
? `task ${visibleLines} lines / total ${Number.isFinite(totalLines) && totalLines > 0 ? totalLines : "--"} lines`
: `${visibleLines} lines / ${visiblePrompt.length} chars`),
),
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"),
);
}
function RawTranscript({ task }: AnyRecord) {
const output = taskOutput(task);
if (!task || output.length === 0) return h(EmptyState, { title: "暂无原始消息", text: "原始 Codex app-server 消息会保留在任务 JSON 中。" });
@@ -1395,7 +1462,6 @@ export function CodexQueuePage({ microservices, onRaw, apiBaseUrl = "/api", init
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,
@@ -1410,7 +1476,7 @@ export function CodexQueuePage({ microservices, onRaw, apiBaseUrl = "/api", init
const [refreshedAt, setRefreshedAt] = useState(initialTasksData ? new Date() : null);
const [loadingMoreTasks, setLoadingMoreTasks] = useState(false);
const tasks = taskRows(tasksData).map((task: any) => applyLocalReadState(task, localReadAt));
const tasks = taskRows(tasksData);
const unreadTerminalTasks = tasks.filter(taskIsUnreadTerminal);
const queuedTasks = tasks.filter((task: any) => !taskIsTerminal(task));
const historyTasks = tasks.filter((task: any) => taskIsTerminal(task) && !taskIsUnreadTerminal(task));
@@ -1429,7 +1495,7 @@ export function CodexQueuePage({ microservices, onRaw, apiBaseUrl = "/api", init
const globalCounts = queueCounts(queue);
const overallQueuedCount = queueWaitingCount(globalCounts);
const overallRunningCount = Math.max(queueRunningCount(globalCounts), globalActiveIds.length);
const overallUnreadTerminalCount = countNumber(queue?.unreadTerminal ?? unreadTerminalTasks.length);
const overallUnreadTerminalCount = countNumber((isAllQueues(selectedQueueId) ? queue?.unreadTerminal : viewQueueRow?.unreadTerminal) ?? unreadTerminalTasks.length);
const selectedQueueName = isAllQueues(selectedQueueId) ? "All queues" : selectedQueueId;
const runtime = service ? microserviceRuntime(service) : {};
const repository = service ? microserviceRepository(service) : {};
@@ -1478,16 +1544,6 @@ export function CodexQueuePage({ microservices, onRaw, apiBaseUrl = "/api", init
}
}
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;
return saveLocalReadAt(next);
});
}
useEffect(() => {
setBatchConfirmed(false);
}, [prompt, repeatCount, referenceTaskId]);
@@ -1867,6 +1923,11 @@ export function CodexQueuePage({ microservices, onRaw, apiBaseUrl = "/api", init
}
setRefreshedAt(new Date());
if (trackLoad) trackedLoadInFlightRef.current = false;
// Overview is intentionally small and may only contain the latest attempt.
// Load the canonical Trace Summary as a follow-up so retry/judge feedback
// cards are shown for historical multi-attempt sessions too.
void ensureTraceSummary(nextId, false, trackLoad ? startedAt : undefined, trackLoad ? queueMs : undefined)
.catch((err) => setError(errorText(err, "加载 Codex Trace Summary 失败")));
return;
}
if (trackLoad) {
@@ -1993,18 +2054,11 @@ export function CodexQueuePage({ microservices, onRaw, apiBaseUrl = "/api", init
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 result = await markTaskReadRequest(apiBaseUrl, taskId);
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} 标为已读`);
setNotice(`已将任务 ${taskId} 标为已读`);
}, "标记 Codex task 已读失败");
setMarkingReadTaskId((value: string) => value === taskId ? "" : value);
}
@@ -2013,27 +2067,19 @@ export function CodexQueuePage({ microservices, onRaw, apiBaseUrl = "/api", init
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 result = await markAllTerminalReadRequest(apiBaseUrl);
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)))
.filter(([, value]) => taskIsUnreadTerminal(value?.task))
.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} 个已结束未读任务标为已读`);
const markedCount = Number(result?.count || readIds.length);
setNotice(`已将 ${markedCount} 个已结束未读任务标为已读`);
}, "全部标为已读失败");
setMarkingAllRead(false);
}
@@ -2446,9 +2492,6 @@ export function CodexQueuePage({ microservices, onRaw, apiBaseUrl = "/api", init
),
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 }),
+1 -10
View File
@@ -1,4 +1,5 @@
import React from "react";
import { fmtClock, fmtDate } from "./time";
import { errorMessage, requestJson } from "./unidesk-error";
import { UniDeskErrorBanner } from "./unidesk-error-banner";
@@ -8,16 +9,6 @@ const h = React.createElement;
const { useEffect } = React;
const useState: any = React.useState;
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();
+1 -10
View File
@@ -1,4 +1,5 @@
import React from "react";
import { fmtClock, fmtDate } from "./time";
import { errorMessage, requestJson } from "./unidesk-error";
import { UniDeskErrorBanner } from "./unidesk-error-banner";
@@ -8,16 +9,6 @@ const h = React.createElement;
const { useEffect } = React;
const useState: any = React.useState;
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 fmtPercent(value: any): string {
const number = Number(value);
+1 -10
View File
@@ -1,4 +1,5 @@
import React from "react";
import { fmtClock, fmtDate } from "./time";
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";
@@ -192,16 +193,6 @@ function PipelineCurveEdge({ id, sourceX, sourceY, targetX, targetY, targetPosit
const pipelineEdgeTypes: any = { pipelineCurve: PipelineCurveEdge };
const pipelineNodeTypes: any = { pipelineNode: PipelineFlowNode };
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 fmtClockValue(value: any): string {
if (!value) return "--";
@@ -1,4 +1,5 @@
import React from "react";
import { beijingDateStamp, fmtClock, fmtDate } from "./time";
import { errorMessage, requestBlob, requestJson } from "./unidesk-error";
import { UniDeskErrorBanner } from "./unidesk-error-banner";
@@ -19,16 +20,6 @@ const EMPTY_FORM = {
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();
@@ -230,7 +221,7 @@ export function ProjectManagerPage({ microservices, onRaw, apiBaseUrl = "/api" }
const href = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = href;
anchor.download = `project-manager-${new Date().toISOString().slice(0, 10)}.xlsx`;
anchor.download = `project-manager-${beijingDateStamp()}.xlsx`;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
+77
View File
@@ -0,0 +1,77 @@
export const BEIJING_TIME_ZONE = "Asia/Shanghai";
export const BEIJING_TIME_LABEL = "北京时间";
const BEIJING_UTC_OFFSET_HOURS = 8;
const DATE_TIME_OPTIONS: Intl.DateTimeFormatOptions = {
timeZone: BEIJING_TIME_ZONE,
hour12: false,
};
const CLOCK_OPTIONS: Intl.DateTimeFormatOptions = {
timeZone: BEIJING_TIME_ZONE,
hour12: false,
};
const INPUT_PARTS_FORMATTER = new Intl.DateTimeFormat("en-CA", {
timeZone: BEIJING_TIME_ZONE,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
});
function coerceDate(value: any): Date | null {
if (value === null || value === undefined || value === "") return null;
const date = value instanceof Date ? value : new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
}
function beijingInputParts(value: any): Record<string, string> | null {
const date = coerceDate(value);
if (!date) return null;
return INPUT_PARTS_FORMATTER.formatToParts(date).reduce((parts: Record<string, string>, part) => {
if (part.type !== "literal") parts[part.type] = part.value;
return parts;
}, {});
}
export function fmtDate(value: any): string {
const date = coerceDate(value);
return date ? date.toLocaleString("zh-CN", DATE_TIME_OPTIONS) : "--";
}
export function fmtClock(value: any): string {
const date = coerceDate(value);
return date ? date.toLocaleTimeString("zh-CN", CLOCK_OPTIONS) : "--";
}
export function fmtDateTimeLocalInput(value: any): string {
const parts = beijingInputParts(value);
if (!parts) return "";
const hour = parts.hour === "24" ? "00" : parts.hour;
return `${parts.year}-${parts.month}-${parts.day}T${hour}:${parts.minute}`;
}
export function beijingDateStamp(value: any = new Date()): string {
const parts = beijingInputParts(value);
if (!parts) return "";
return `${parts.year}-${parts.month}-${parts.day}`;
}
export function localDateTimeInputToBeijingIso(value: string): string | null {
if (!value) return null;
const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?$/.exec(value);
if (!match) return null;
const [, year, month, day, hour, minute, second = "00"] = match;
const utcMs = Date.UTC(
Number(year),
Number(month) - 1,
Number(day),
Number(hour) - BEIJING_UTC_OFFSET_HOURS,
Number(minute),
Number(second),
);
const date = new Date(utcMs);
const normalized = fmtDateTimeLocalInput(date);
return Number.isNaN(date.getTime()) || normalized !== `${year}-${month}-${day}T${hour}:${minute}` ? null : date.toISOString();
}
+2 -26
View File
@@ -1,4 +1,5 @@
import React from "react";
import { fmtClock, fmtDate, fmtDateTimeLocalInput, localDateTimeInputToBeijingIso } from "./time";
import { errorMessage, requestJson } from "./unidesk-error";
import { UniDeskErrorBanner } from "./unidesk-error-banner";
@@ -9,17 +10,6 @@ const h = React.createElement;
const { useEffect } = React;
const useState: any = React.useState;
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");
@@ -102,20 +92,6 @@ function todoVisible(todo: any, filter: string): boolean {
return selfVisible || children.some((child: any) => todoVisible(child, filter));
}
function todoReminderLocal(value: any): string {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000);
return local.toISOString().slice(0, 16);
}
function localReminderIso(value: string): string | null {
if (!value) return null;
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date.toISOString();
}
function todoRegistryRows(registry: any): any[] {
return Array.isArray(registry?.instances) ? registry.instances : [];
}
@@ -430,7 +406,7 @@ function TodoRow(props: AnyRecord): ReactNode {
todo.reminderAt ? h("span", { className: "todo-reminder" }, `提醒 ${fmtDate(todo.reminderAt)}`) : h("span", null, "无提醒"),
),
),
h("input", { className: "todo-reminder-input", type: "datetime-local", value: todoReminderLocal(todo.reminderAt), onChange: (event: any) => applyTodoAction({ type: "setTodoReminder", todoId: todo.id, reminderAt: localReminderIso(event.target.value) }) }),
h("input", { className: "todo-reminder-input", type: "datetime-local", value: fmtDateTimeLocalInput(todo.reminderAt), onChange: (event: any) => applyTodoAction({ type: "setTodoReminder", todoId: todo.id, reminderAt: localDateTimeInputToBeijingIso(event.target.value) }) }),
h("div", { className: "todo-row-actions" },
h("button", { type: "button", className: "ghost-btn", onClick: () => beginEdit(todo) }, "编辑"),
h("button", { type: "button", className: "ghost-btn", onClick: () => addChild(todo.id) }, "子项"),
+1 -7
View File
@@ -1,4 +1,5 @@
import React from "react";
import { fmtDate } from "./time";
type AnyRecord = Record<string, any>;
@@ -73,13 +74,6 @@ export function traceFromPort<Input>(port: TracePort<Input>, input: Input): Trac
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 "--";
+3 -1
View File
@@ -1,3 +1,5 @@
import { BEIJING_TIME_LABEL, fmtDate } from "./time";
type AnyRecord = Record<string, any>;
export type UniDeskFailureField = string | false;
@@ -231,7 +233,7 @@ 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()}`;
return `${fmtDate(date)} ${BEIJING_TIME_LABEL}`;
}
export function describeUniDeskError(error: unknown, fallback = "操作失败"): UniDeskErrorView {
File diff suppressed because it is too large Load Diff