diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bc538a1..7039803 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -81,7 +81,7 @@ jobs: - run: ./tests/deployment-package.sh - uses: actions/upload-artifact@v4 with: - name: farhelm-V0.8.0-linux-x86_64 + name: farhelm-V0.9.0-linux-x86_64 path: | dist/release/farhelm-* dist/release/SHA256SUMS diff --git a/Cargo.lock b/Cargo.lock index f43af68..44cd6b0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -569,7 +569,7 @@ checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" [[package]] name = "farhelm-agent" -version = "0.8.0" +version = "0.9.0" dependencies = [ "anyhow", "base64", @@ -599,14 +599,14 @@ dependencies = [ [[package]] name = "farhelm-core" -version = "0.8.0" +version = "0.9.0" dependencies = [ "serde", ] [[package]] name = "farhelm-hub" -version = "0.8.0" +version = "0.9.0" dependencies = [ "anyhow", "argon2", @@ -642,7 +642,7 @@ dependencies = [ [[package]] name = "farhelm-lifecycle" -version = "0.8.0" +version = "0.9.0" dependencies = [ "anyhow", "tempfile", @@ -650,7 +650,7 @@ dependencies = [ [[package]] name = "farhelm-protocol" -version = "0.8.0" +version = "0.9.0" dependencies = [ "serde", "serde_json", @@ -658,14 +658,14 @@ dependencies = [ [[package]] name = "farhelm-test-support" -version = "0.8.0" +version = "0.9.0" dependencies = [ "farhelm-protocol", ] [[package]] name = "farhelm-updater" -version = "0.8.0" +version = "0.9.0" dependencies = [ "anyhow", "reqwest", diff --git a/Cargo.toml b/Cargo.toml index 9500d9f..41fabae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ members = [ ] [workspace.package] -version = "0.8.0" +version = "0.9.0" edition = "2024" rust-version = "1.98" license = "Apache-2.0" diff --git a/README.en.md b/README.en.md index fd87030..5d65f97 100644 --- a/README.en.md +++ b/README.en.md @@ -4,14 +4,14 @@

A remote control plane for personal research and GPU training environments

See training-host status from your phone and safely extend remote control without exposing inbound ports on training machines.

- V0.8.0 · + V0.9.0 · Deployment guide · 简体中文

> [!IMPORTANT] -> `V0.8.0` lets the Rust Agent manage your installed, authenticated Codex through one persistent App Server and an outbound WSS connection. The workspace uses Ant Design X, a shared memory cache, and virtualized history while preserving global navigation, experiments, schedules, and browser notifications. +> `V0.9.0` completes the project workspace, native Codex model and permission details, light/dark appearance, and custom accent colors. It fixes cached long-history switching, notification/experiment/audit pagination, and schedule synchronization. The Rust Agent, persistent local Codex, outbound WSS, experiment reports, and browser notifications remain in place. ## Quick install @@ -121,16 +121,18 @@ A successful command returns `run_id`, `event_id`, and `stored_locally: true`: t The [Bash example](examples/experiment-report.sh) and [Python example](examples/experiment-report.py) report failures through exit handling and preserve the training exit code, without a Python SDK. A single final call cannot infer a result if never reached, after power loss, or after SIGKILL; use PID watch for that fallback. Success follow-ups expire after 24 hours; failed and unknown reports only notify. Prompt files remain on the Agent. -The notification center provides pagination, type/Agent/result filters, synchronized unread state, and details. Settings include system/light/dark themes, experiment/Codex in-page alert switches, and a browser test notification. Keep FarHelm open to receive completion alerts and click through to details. Initial loading and reconnect history do not trigger a burst of old alerts. This release covers browser in-page notifications; iOS system notifications are planned for a later version. +The notification center provides pagination, type/Agent/result filters, synchronized unread state, and details. Notification, experiment, and audit refreshes retain loaded pages. Filters cover all registered servers, obsolete requests cannot replace current results, and failed reads can be retried. Schedule creation and cancellation reconcile the list immediately, with confirmation before cancelling a specific task. Settings include system/light/dark themes, experiment/Codex in-page alert switches, and a browser test notification. Keep FarHelm open to receive completion alerts and click through to details. Initial loading and reconnect history do not trigger a burst of old alerts. This release covers browser in-page notifications; iOS system notifications are planned for a later version. Browser commands and schedules are acknowledged after Agent persistence. Failed submissions retain the current-page draft and operation identity for retries. On restart, saved terminal receipts reconcile completed work; running tasks without a terminal receipt become orphaned and are not replayed. Upgrade Hub before Agent; the new content relay requires the Agent's V0.7 capability. ## Codex workspace -Desktop global navigation and mobile bottom navigation remain visible in Codex. Project groups collapse, and sessions use formal names or a temporary first-user-message summary. Search covers all imported sessions in the selected scope; offline Agents are clearly marked as incomplete results. Summaries and search results never enter Hub databases, logs, or notification titles. +Desktop global navigation and mobile bottom navigation remain visible in Codex. Sessions collapse by project, with the device name beside each project; same-named projects on different devices remain separate. Sessions use formal names or a temporary first-user-message summary. Search covers all imported sessions in the selected scope; offline Agents are clearly marked as incomplete results. Summaries and search results never enter Hub databases, logs, or notification titles. Assistant replies support Markdown tables, code copying, math, and safe HTTPS links. Execution details in each turn form a collapsed group with visible failures. The composer stays visible, and reading older content does not jump to the bottom on new messages. Large-message continuation is separate from loading earlier conversations. Switching sessions immediately displays cached history and retains each draft; failed refreshes preserve visible content. Inactive body and parsing caches total at most 32 MiB, with at most 20 inactive histories, and are cleared on logout. Steer and interrupt target the visible active turn. +The system uses black and white base colors with one customizable theme color; the logo retains its original brand colors. Settings offer separate light/dark mode and theme color choices (graphite, blue, green, violet, rose, orange, or a custom color) for primary buttons, selected states, links, and focus, applied immediately and saved in the current browser. The rounded composer stays visible; locally served Manrope and Noto Sans SC fonts share a consistent reading column. Submission and expanding execution details use brief animations that respect the system’s reduced-motion preference; cached session switches do not replay the entry animation. + Upgrade Hub before Agent when moving from V0.7.1. SQLite schema remains 7. Older Agents retain HTTP compatibility; upgraded Agents use WSS without also claiming work over HTTP. Existing Python directories remain on installed machines for rollback validation and are never invoked by the new version. ## What is implemented @@ -152,6 +154,8 @@ flowchart LR FarHelm is one monorepo, but Hub and Agent are compiled separately and retain distinct privileges and attack surfaces. Codex communicates only over local stdio. Authentication remains local; Hub relays bodies only in bounded memory for at most 20 seconds. +The Codex composer displays the session model, reasoning effort, and permission details returned by the server. Resuming inherits native Codex settings; new browser sessions use project configuration, with an optional isolated Git worktree. Unconfirmed settings are marked unknown rather than inferred from legacy session modes. Adjust the model and permissions in Codex on the server. Browser approval prompts are not supported yet, so operations requiring user approval are declined. Upgrade Hub before Agent and verify that Agent advertises `codex.session_context`. Explicit inspect/edit creation through older APIs and the CLI remains compatible. + ## Local development You need Rust 1.98, Node.js 24, Corepack, and a locally installed Codex. diff --git a/README.md b/README.md index 41c083e..95b014b 100644 --- a/README.md +++ b/README.md @@ -4,14 +4,14 @@

面向个人科研与 GPU 训练环境的远程控制平面

从手机查看训练服务器状态,并在不开放训练机入站端口的前提下安全扩展远程控制能力。

- V0.8.0 · + V0.9.0 · 部署文档 · English

> [!IMPORTANT] -> `V0.8.0` 由 Rust Agent 直接管理本机已安装、已登录的 Codex,通过常驻 App Server 和主动 WSS 连接传输请求与增量。工作区使用 Ant Design X、统一内存缓存和长历史虚拟化,保留系统导航、实验、调度及页面通知。 +> `V0.9.0` 完善项目工作区、原生 Codex 模型与权限展示、浅深主题和自定义主题色,修复长会话缓存切换、通知/实验/审计分页刷新及定时任务同步。继续使用 Rust Agent、常驻本机 Codex 和出站 WSS,保留实验上报与页面通知。 ## 快速安装 @@ -121,16 +121,18 @@ printf '训练失败,请登录查看详情' | farhelm-agent experiment report [Bash 示例](examples/experiment-report.sh)和 [Python 示例](examples/experiment-report.py)通过退出捕获报告失败,保留训练退出码,不要求安装 Python SDK。脚本未运行到上报、机器断电或 SIGKILL 无法由单次上报推断结果,仍可使用 PID watch 兜底。成功续话有效期 24 小时,失败与 unknown 只通知。 -通知中心支持分页、类型/Agent/结果筛选、未读同步和结果详情。设置页支持系统/浅色/深色主题、实验/Codex 页面提醒开关及页面测试通知。保持 FarHelm 页面打开即可接收完成提醒,点击提醒进入对应详情;初次进入与重连补历史不会批量弹出旧提醒。本版范围为浏览器页面通知,iOS 系统推送留待后续版本。 +通知中心支持分页、类型/Agent/结果筛选、未读同步和结果详情。通知、实验、审计刷新保留已加载页;筛选覆盖全部登记服务器,过时请求不影响当前结果,读取失败可重试。定时任务创建和取消后即时核对列表,取消前确认具体任务。设置页支持系统/浅色/深色主题、实验/Codex 页面提醒开关及页面测试通知。保持 FarHelm 页面打开即可接收完成提醒,点击提醒进入对应详情;初次进入与重连补历史不会批量弹出旧提醒。本版范围为浏览器页面通知,iOS 系统推送留待后续版本。 网页指令和调度在 Agent 保存后才确认提交;失败时当前页面保留草稿并沿用操作身份重试。运行中的任务在 Agent 重启后按已保存的终态收据恢复;无终态收据时标记 orphaned,不自动重放。升级先 Hub 后 Agent,新正文交付要求 Agent 的 V0.7 能力标识。 ## Codex 工作区 -进入 Codex 后保留桌面全局侧栏和手机底部导航。项目分组可折叠,会话名称优先使用正式标题,没有标题时临时读取首条用户消息摘要。搜索覆盖筛选范围内全部已导入会话;离线 Agent 会明确提示结果不完整。摘要与搜索结果不写入 Hub 数据库或日志,也不用于通知标题。 +进入 Codex 后保留桌面全局侧栏和手机底部导航。会话栏以项目为单位折叠,设备名显示在项目旁;不同设备的同名项目分别列出。会话名称优先使用正式标题,没有标题时临时读取首条用户消息摘要。搜索覆盖筛选范围内全部已导入会话;离线 Agent 会明确提示结果不完整。摘要与搜索结果不写入 Hub 数据库或日志,也不用于通知标题。 助手回复支持 Markdown 表格、代码复制、公式及安全 HTTPS 链接。每轮执行过程默认合并折叠,失败可直接辨认。输入区固定可见,阅读旧内容时不会被新消息拉回底部;大消息的“继续加载此消息”与“加载更早对话”分别处理。切换会话立即显示已有缓存,并保留各自草稿;刷新失败不清空已显示内容。非活动正文缓存与解析缓存合计不超过 32 MiB,最多保留 20 个非活动历史,退出登录清空。补充与中断只针对可见的活动轮次。 +系统外观使用黑白基础色和一个可自定义主题色,Logo 保留品牌原色。设置中可独立选择浅深模式及主题色(石墨、蓝、绿、紫、玫瑰、橙或自定义颜色),用于主要按钮、选中状态、链接与焦点,即时生效并保存在当前浏览器。圆润的输入框固定可见;界面使用本地加载的 Manrope 与 Noto Sans SC 字体和统一阅读列。提交与展开过程提供简短动效,遵循系统“减少动态效果”设置;已缓存会话切换不重播进入动画。 + 从 V0.7.1 升级先更新 Hub,再更新 Agent。SQLite schema 保持 7,旧 Agent 的 HTTP 链路保留兼容;新 Agent 使用 WSS,连接期间不重复领取任务。旧 Python 目录保留在安装机供回退验证,新版本不再调用它。 ## 当前实现 @@ -152,6 +154,8 @@ flowchart LR FarHelm 是一个 monorepo,但 Hub 与 Agent 分离编译并保持不同权限与攻击面。Codex 只通过本机 stdio 通信;登录凭据保留在本机,Hub 仅在最长 20 秒的有界内存中中转正文。 +Codex 输入区显示服务器返回的会话模型、推理强度和权限详情。恢复会话沿用原生 Codex 设置;网页新建会话默认使用项目配置,隔离 Git 工作区为可选项。尚未确认的设置明确标为未知,不根据会话旧模式猜测权限。模型和权限在服务器上的 Codex 中调整;网页暂不支持人工审批,需要用户批准的操作会被拒绝。请先升级 Hub,再升级 Agent,确认 Agent 已提供 `codex.session_context` 能力。旧 API/CLI 显式创建的 inspect/edit 模式继续兼容。 + ## 本地开发 需要 Rust 1.98、Node.js 24 和 Corepack;原生联调另需已安装、已登录的 Codex。 diff --git a/VERSION b/VERSION index a3df0a6..ac39a10 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.8.0 +0.9.0 diff --git a/crates/farhelm-core/src/lib.rs b/crates/farhelm-core/src/lib.rs index 21a4c19..7656d0f 100644 --- a/crates/farhelm-core/src/lib.rs +++ b/crates/farhelm-core/src/lib.rs @@ -29,6 +29,6 @@ mod tests { fn build_info_uses_workspace_identity() { let info = BuildInfo::current(); assert_eq!(info.product, "FarHelm"); - assert_eq!(info.version, "0.8.0"); + assert_eq!(info.version, include_str!("../../../VERSION").trim()); } } diff --git a/crates/farhelm-protocol/src/lib.rs b/crates/farhelm-protocol/src/lib.rs index f8ae02b..f1ad759 100644 --- a/crates/farhelm-protocol/src/lib.rs +++ b/crates/farhelm-protocol/src/lib.rs @@ -55,6 +55,7 @@ impl AgentHeartbeat { "codex.session_display".to_owned(), "codex.item_offsets".to_owned(), "codex.native".to_owned(), + "codex.session_context".to_owned(), "agent.live".to_owned(), ], protocol: FARHELM_PROTOCOL.to_owned(), @@ -74,6 +75,8 @@ pub struct AgentHeartbeatAck { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct AgentSummary { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub capabilities: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub codex: Option, pub agent_id: String, @@ -354,11 +357,28 @@ pub struct CodexTranscriptPage { pub session_id: String, pub turns: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] + pub context: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub next_cursor: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub continuation: Option, } +/// Ephemeral configuration only. Never persist a native configuration object or paths. +#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CodexSessionContext { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reasoning_effort: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sandbox: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub approval_policy: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub approvals_reviewer: Option, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct TranscriptContinuation { pub kind: String, @@ -522,6 +542,8 @@ pub struct CreateCodexSessionRequest { pub agent_id: String, pub project_id: String, pub mode: CodexSessionMode, + #[serde(default)] + pub inherit_permissions: bool, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -744,7 +766,7 @@ mod tests { "agent_id": "gpu-a", "hostname": "trainer-a", "agent_version": "0.1.0", - "capabilities": ["codex.ephemeral_submit", "codex.session_display", "codex.item_offsets", "codex.native", "agent.live"] + "capabilities": ["codex.ephemeral_submit", "codex.session_display", "codex.item_offsets", "codex.native", "codex.session_context", "agent.live"] }) ); } @@ -770,6 +792,21 @@ mod tests { assert_eq!(data["data"]["turn_id"], "t"); } + #[test] + fn ephemeral_native_context_is_allowlisted_and_old_requests_remain_compatible() { + let page: CodexTranscriptPage = serde_json::from_value(serde_json::json!({"session_id":"s","turns":[],"context":{"model":"gpt-5.4","sandbox":"workspace-write","cwd":"PRIVATE","writableRoots":["PRIVATE"],"api_key":"PRIVATE"}})).unwrap(); + let value = serde_json::to_value(page).unwrap(); + assert_eq!(value["context"]["model"], "gpt-5.4"); + assert!(!value.to_string().contains("PRIVATE")); + let payload = public_event_payload("codex.session.updated", &value); + assert!(payload.get("context").is_none()); + let legacy: CreateCodexSessionRequest = serde_json::from_value( + serde_json::json!({"agent_id":"a","project_id":"p","mode":"inspect"}), + ) + .unwrap(); + assert!(!legacy.inherit_permissions); + } + #[test] fn command_fixture_is_stable() { let command: AgentCommand = diff --git a/deploy/README.en.md b/deploy/README.en.md index 219f9d2..cb3bc75 100644 --- a/deploy/README.en.md +++ b/deploy/README.en.md @@ -1,4 +1,4 @@ -# FarHelm V0.8.0 deployment and lifecycle +# FarHelm V0.9.0 deployment and lifecycle [简体中文](README.md) · [English](README.en.md) @@ -154,9 +154,9 @@ farhelm-agent uninstall --keep-data ## Migrating from V0.2.0 -Hosts already on `V0.3.0` through `V0.7.1` can run `farhelm-hub update` or `farhelm-agent update` directly. `V0.2.0` must first upgrade to `V0.3.0` to migrate the old layout, then upgrade to V0.8.0. +Hosts already on `V0.3.0` through `V0.8.0` can run `farhelm-hub update` or `farhelm-agent update` directly. `V0.2.0` must first upgrade to `V0.3.0` to migrate the old layout, then upgrade to V0.9.0. -Lowercase legacy `v0.1.0/v0.2.0` releases are outside the formal update sequence. Remove them with their matching old uninstaller before installing V0.8.0. +Lowercase legacy `v0.1.0/v0.2.0` releases are outside the formal update sequence. Remove them with their matching old uninstaller before installing V0.9.0. ## Security notes @@ -181,3 +181,11 @@ Update Hub first, then Agent. The database stays at schema 7; project, session, A binary rollback retains the current database and receipts. Never restore an older queue database: it could repeat completed operations. Running work becomes orphaned after restart and requires inspection instead of automatic replay. V0.8 does not downgrade your Codex executable; an older FarHelm installation can reuse its retained Python directory only after rollback verification. In the browser, verify the permanent system navigation, cached session switching, Markdown, queued sends, interrupts and completion alerts. Conversation caches are memory-only and cleared on logout; at most 20 inactive histories or 24 MiB plus up to 8 MiB of parsed Markdown are retained. Page notifications remain the supported notification experience for this release. + +## V0.8.0 → V0.9.0 + +On the Hub host, run `sudo farhelm-hub update --version V0.9.0` first. Then run `farhelm-agent update --version V0.9.0` as the original user on each Agent host. Check `farhelm-hub status` and `farhelm-agent status`, then refresh the browser. The database stays at schema 7; no re-pairing, project import, or old database restoration is required. + +Check device names beside projects, model/permission details, appearance settings, cached session switching, notification pagination/details, and list synchronization after creating/cancelling schedules. Existing server Codex settings continue to apply. Agents without `codex.session_context` must be upgraded before the browser can create sessions with native permissions. Failed submission retries preserve the original operation identity. + +Rollback replaces only the executable and retains the current database, configuration, and receipts. Never restore an old queue snapshot. V0.8.0 can still read schema 7, but does not provide the V0.9.0 interface or native permission display features. diff --git a/deploy/README.md b/deploy/README.md index 5088009..d3c94e7 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -1,4 +1,4 @@ -# FarHelm V0.8.0 部署与生命周期 +# FarHelm V0.9.0 部署与生命周期 [简体中文](README.md) · [English](README.en.md) @@ -154,9 +154,9 @@ farhelm-agent uninstall --keep-data ## 从 V0.2.0 迁移 -已安装 `V0.3.0` 至 `V0.7.1` 的主机可以直接执行 `farhelm-hub update` 或 `farhelm-agent update`。`V0.2.0` 必须先升级到 `V0.3.0` 完成旧布局迁移,再升级到 V0.8.0。 +已安装 `V0.3.0` 至 `V0.8.0` 的主机可以直接执行 `farhelm-hub update` 或 `farhelm-agent update`。`V0.2.0` 必须先升级到 `V0.3.0` 完成旧布局迁移,再升级到 V0.9.0。 -旧小写 `v0.1.0/v0.2.0` 不属于正式升级序列,仍需先使用对应旧卸载器清理,再安装 V0.8.0。 +旧小写 `v0.1.0/v0.2.0` 不属于正式升级序列,仍需先使用对应旧卸载器清理,再安装 V0.9.0。 ## 安全说明 @@ -181,3 +181,11 @@ V0.7 的页面通知通过现有 SSE 和持久通知中心工作,无需 VAPID 二进制回退始终保留当前数据库和收据。不能恢复旧队列数据库,否则可能重复执行已完成操作。重启中的运行任务标记为 orphaned,需检查结果,不自动重放。V0.8 不会降级用户 Codex;旧 FarHelm 只有在回退验证后才可复用保留的 Python 目录。 在浏览器中检查始终可见的系统导航、会话缓存切换、Markdown、排队发送、中断和完成通知。对话缓存仅在内存中,退出登录时清除;非活动历史最多 20 个或 24 MiB,另保留最多 8 MiB 的 Markdown 解析缓存。本版继续使用页面通知。 + +## V0.8.0 → V0.9.0 + +在 Hub 主机先执行 `sudo farhelm-hub update --version V0.9.0`,再在每台 Agent 主机以原用户执行 `farhelm-agent update --version V0.9.0`。分别运行 `farhelm-hub status` 与 `farhelm-agent status`,然后刷新浏览器。数据库保持 schema 7,不需要重新配对、导入项目或恢复旧数据库。 + +检查项目旁的设备名称、模型/权限详情、主题设置、缓存会话切换、通知分页与详情,以及创建/取消定时任务后的列表同步。服务器原有 Codex 配置继续生效;旧 Agent 缺少 `codex.session_context` 时需升级后才能通过网页新建原生权限会话。失败重试保留原操作身份。 + +回退只替换二进制并保留当前数据库、配置和收据;不要恢复旧队列快照。V0.8.0 仍可读取 schema 7,回退后不提供 V0.9.0 的界面与原生权限展示能力。 diff --git a/farhelm-agent/src/codex/context.rs b/farhelm-agent/src/codex/context.rs new file mode 100644 index 0000000..602c52e --- /dev/null +++ b/farhelm-agent/src/codex/context.rs @@ -0,0 +1,93 @@ +//! Allowlisted native configuration for the authenticated, ephemeral read relay. +use farhelm_protocol::CodexSessionContext; +use serde_json::Value; + +pub fn project(value: &Value, previous: Option<&CodexSessionContext>) -> CodexSessionContext { + let mut context = previous.cloned().unwrap_or_default(); + if let Some(model) = value.get("model") { + context.model = model + .as_str() + .filter(|name| { + !name.is_empty() + && name.len() <= 128 + && name + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b)) + }) + .map(str::to_owned); + } + if let Some(effort) = value.get("reasoningEffort") { + context.reasoning_effort = choice( + effort, + &[ + "none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra", + ], + ); + } + if let Some(sandbox) = value.get("sandbox").or_else(|| value.get("sandboxPolicy")) { + context.sandbox = Some( + match sandbox["type"].as_str().or_else(|| sandbox.as_str()) { + Some("readOnly" | "read-only") => "read-only", + Some("workspaceWrite" | "workspace-write") => "workspace-write", + Some("dangerFullAccess" | "danger-full-access") => "danger-full-access", + Some("externalSandbox" | "external-sandbox") => "external-sandbox", + _ => "custom", + } + .to_owned(), + ); + if sandbox.is_null() { + context.sandbox = None; + } + } + if let Some(approval) = value.get("approvalPolicy") { + context.approval_policy = Some( + choice( + approval, + &["never", "on-request", "on-failure", "untrusted"], + ) + .unwrap_or_else(|| "custom".into()), + ); + } + if let Some(reviewer) = value.get("approvalsReviewer") { + context.approvals_reviewer = + choice(reviewer, &["user", "auto_review", "guardian_subagent"]); + } + context +} + +fn choice(value: &Value, options: &[&str]) -> Option { + value + .as_str() + .filter(|v| options.contains(v)) + .map(str::to_owned) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + #[test] + fn native_context_projects_only_bounded_settings_not_paths_or_credentials() { + let input = json!({"model":"gpt-5.4","reasoningEffort":"high","sandbox":{"type":"workspaceWrite","writableRoots":["/secret/repo"]},"approvalPolicy":"on-request","approvalsReviewer":"user","cwd":"/private/project","api_key":"credential","instructions":"private body"}); + let context = project(&input, None); + assert_eq!(context.model.as_deref(), Some("gpt-5.4")); + assert_eq!(context.sandbox.as_deref(), Some("workspace-write")); + let encoded = serde_json::to_string(&context).unwrap(); + for private in [ + "/secret", + "/private", + "credential", + "private body", + "writableRoots", + ] { + assert!(!encoded.contains(private)); + } + let updated = project( + &json!({"model":"/private/model","reasoningEffort":"unknown"}), + Some(&context), + ); + assert!(updated.model.is_none()); + assert!(updated.reasoning_effort.is_none()); + assert_eq!(updated.sandbox, context.sandbox); + } +} diff --git a/farhelm-agent/src/codex/mod.rs b/farhelm-agent/src/codex/mod.rs index 8d4a2be..b139a80 100644 --- a/farhelm-agent/src/codex/mod.rs +++ b/farhelm-agent/src/codex/mod.rs @@ -1,4 +1,5 @@ //! Local Codex is the transcript/authentication authority. No vendor body is a Hub record. +mod context; pub mod history; mod protocol; #[cfg(test)] @@ -34,6 +35,8 @@ pub struct CodexStatus { struct Index { rows: HashMap, refreshed: Option, + // A complete empty snapshot is distinct from an index not yet initialized. + snapshot_generation: u64, revision: u64, changed: HashMap, } @@ -51,6 +54,7 @@ struct LoadedThread { thread: Value, mode: String, has_turns: bool, + context: farhelm_protocol::CodexSessionContext, } #[derive(Clone)] pub struct Codex { @@ -175,6 +179,13 @@ impl Codex { break; } let params = &event["params"]; + if event["method"] == "thread/settings/updated" + && let Some(id) = params["threadId"].as_str() + && let Some(loaded) = inner.loaded.write().await.get_mut(id) + { + loaded.context = + context::project(¶ms["threadSettings"], Some(&loaded.context)); + } let mut index = inner.index.write().await; if event["method"] == "thread/started" && let Some(id) = params["thread"]["id"].as_str() @@ -235,25 +246,37 @@ impl Codex { } async fn index(&self, force: bool) -> Result> { - let _refresh = self.inner.refresh.lock().await; - if !force - && self - .inner - .index - .read() - .await - .refreshed - .is_some_and(|time| time.elapsed() < Duration::from_secs(30)) + let generation = { + let index = self.inner.index.read().await; + if !force + && index + .refreshed + .is_some_and(|time| time.elapsed() < Duration::from_secs(30)) + { + return Ok(index.rows.values().cloned().collect()); + } + index.snapshot_generation + }; + let _refresh = match self.inner.refresh.try_lock() { + Ok(guard) => guard, + Err(_) => { + if !force { + let index = self.inner.index.read().await; + if index.snapshot_generation > 0 { + // Names/search use the last complete projection while the + // existing discovery lane prepares an atomic replacement. + return Ok(index.rows.values().cloned().collect()); + } + } + self.inner.refresh.lock().await + } + }; { - return Ok(self - .inner - .index - .read() - .await - .rows - .values() - .cloned() - .collect()); + let index = self.inner.index.read().await; + if index.snapshot_generation != generation { + // Another caller completed the requested scan while we waited. + return Ok(index.rows.values().cloned().collect()); + } } let connection = self.connection().await?; let revision = self.inner.index.read().await.revision; @@ -306,6 +329,7 @@ impl Codex { } index.rows = rows; index.refreshed = Some(Instant::now()); + index.snapshot_generation += 1; index.changed.retain(|_, changed| *changed > revision); Ok(index.rows.values().cloned().collect()) } @@ -399,7 +423,9 @@ impl Codex { if let Some(loaded) = self.inner.loaded.read().await.get(&session) && !loaded.has_turns { - return Ok(json!({"session_id":session,"turns":[],"next_cursor":null})); + return Ok( + json!({"session_id":session,"turns":[],"next_cursor":null,"context":loaded.context}), + ); } let limit = params["limit"].as_u64().unwrap_or(20).clamp(1, 50) as usize; let legacy = self @@ -408,7 +434,8 @@ impl Codex { .as_deref() .and_then(|v| semver::Version::parse(v).ok()) .is_some_and(|v| v < semver::Version::new(0, 153, 0)); - let (turns, next) = if legacy { + let latest = params["cursor"].is_null(); + let (turns, next, settings) = if legacy { let offset = upstream .as_deref() .map(|cursor| { @@ -435,6 +462,13 @@ impl Codex { ensure!(offset <= data.len(), "history_changed_reload"); let next = (offset + limit < data.len()).then(|| format!("legacy:{}", offset + limit)); + let loaded = self.inner.loaded.read().await; + let settings = latest.then(|| { + context::project( + &result["thread"], + loaded.get(&session).map(|thread| &thread.context), + ) + }); ( data.iter() .rev() @@ -443,23 +477,51 @@ impl Codex { .cloned() .collect::>(), next, + settings, ) } else { - let result: ThreadPage = serde_json::from_value( - connection - .request( - "thread/turns/list", - serde_json::to_value(ThreadTurnsListParams { + // The metadata RPC is independent of the history page and never resumes a thread. + // Continuations need no settings read and cannot overwrite the latest context. + let metadata = async { + if !latest { + return Ok(Value::Null); + } + tokio::time::timeout( + Duration::from_millis(250), + connection.request( + "thread/read", + serde_json::to_value(ThreadReadParams { thread_id: &session, - cursor: upstream.as_deref(), - limit: limit as u32, - items_view: "full", + include_turns: false, })?, - ) - .await?, - ) - .context("codex_history_invalid")?; - (result.data, result.next_cursor) + ), + ) + .await + .context("codex_context_timeout")? + }; + let (result, metadata) = tokio::join!( + connection.request( + "thread/turns/list", + serde_json::to_value(ThreadTurnsListParams { + thread_id: &session, + cursor: upstream.as_deref(), + limit: limit as u32, + items_view: "full", + })?, + ), + metadata + ); + let result: ThreadPage = + serde_json::from_value(result?).context("codex_history_invalid")?; + let loaded = self.inner.loaded.read().await; + let metadata = metadata.unwrap_or(Value::Null); + let settings = latest.then(|| { + context::project( + &metadata["thread"], + loaded.get(&session).map(|thread| &thread.context), + ) + }); + (result.data, result.next_cursor, settings) }; crate::runtime_tasks::blocking(move || { history::bounded_page( @@ -471,15 +533,17 @@ impl Codex { ) }) .await + .and_then(|mut page| { + if let Some(settings) = settings { + page["context"] = serde_json::to_value(settings)?; + } + Ok(page) + }) } "codex.session.start" | "codex.session.resume" => { let connection = self.connection().await?; let mode = params["mode"].as_str().unwrap_or("inspect"); - let sandbox = match mode { - "inspect" => "read-only", - "edit" => "workspace-write", - _ => bail!("invalid_session_mode"), - }; + ensure!(matches!(mode, "inspect" | "edit"), "invalid_session_mode"); if method == "codex.session.resume" && let Some(id) = params["session_id"].as_str() && let Some(loaded) = self.inner.loaded.read().await.get(id) @@ -490,8 +554,18 @@ impl Codex { ); return Ok(thread_result(&loaded.thread)); } - let mut request = - json!({"cwd":params["cwd"],"sandbox":sandbox,"approvalPolicy":"on-request"}); + let mut request = json!({"cwd":params["cwd"]}); + // Resuming belongs to Codex: explicit overrides here would erase the user's + // persisted permission profile and approval policy. Legacy explicit creation + // still supports inspect/edit; the Console opts into native project defaults. + if method == "codex.session.start" && params["inherit_permissions"] != true { + request["sandbox"] = json!(if mode == "edit" { + "workspace-write" + } else { + "read-only" + }); + request["approvalPolicy"] = json!("on-request"); + } if method == "codex.session.resume" { request["threadId"] = params["session_id"].clone(); if self @@ -529,6 +603,7 @@ impl Codex { thread: row.clone(), mode: mode.to_owned(), has_turns: method == "codex.session.resume", + context: context::project(&response, None), }, ); self.inner.index.write().await.rows.insert(id, row); diff --git a/farhelm-agent/src/codex/tests.rs b/farhelm-agent/src/codex/tests.rs index fdaf02a..ad0a557 100644 --- a/farhelm-agent/src/codex/tests.rs +++ b/farhelm-agent/src/codex/tests.rs @@ -1,5 +1,4 @@ use super::*; -use std::os::unix::fs::PermissionsExt; fn fixture(directory: &std::path::Path, version: &str) -> PathBuf { let path = directory.join("codex"); @@ -33,17 +32,201 @@ while IFS= read -r line; do printf '{"id":%s,"result":%s}\n' "$id" "$result" done "#; - std::fs::write( + transport::write_fixture_executable( &path, - script + &script .replace("VERSION", version) .replace("RECORD", directory.join("methods").to_str().unwrap()), - ) - .unwrap(); - std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700)).unwrap(); + ); path } +#[tokio::test] +async fn native_resume_inherits_settings_and_creation_retains_explicit_legacy_modes() { + for version in ["0.147.0", "0.153.4"] { + let directory = tempfile::tempdir().unwrap(); + let bin = fixture(directory.path(), version); + let requests = directory.path().join("requests"); + let script = std::fs::read_to_string(&bin).unwrap(); + let mut lines = Vec::new(); + for line in script.lines() { + if line.contains("Empty sessions must use their loaded thread") { + lines.push(" *'\"method\":\"thread/resume\"'*) result='{\"thread\":{\"id\":\"s\",\"cwd\":\"/tmp/project\"},\"model\":\"gpt-5.4\",\"reasoningEffort\":\"high\",\"sandbox\":{\"type\":\"dangerFullAccess\"},\"approvalPolicy\":\"never\",\"approvalsReviewer\":\"user\"}' ;;".to_owned()); + } else { + lines.push(line.to_owned()); + if line == " [ -n \"$id\" ] || continue" { + lines.push(format!( + " printf '%s\\n' \"$line\" >> '{}'", + requests.display() + )); + } + } + } + transport::write_fixture_executable(&bin, &lines.join("\n")); + let native = Codex::new(Some(bin)); + native + .call( + "codex.session.resume", + json!({"session_id":"s","cwd":"/tmp/project","mode":"inspect"}), + ) + .await + .unwrap(); + let page = native + .call("codex.session.history", json!({"session_id":"s"})) + .await + .unwrap(); + assert_eq!(page["context"]["model"], "gpt-5.4"); + assert_eq!(page["context"]["sandbox"], "danger-full-access"); + assert_eq!(page["context"]["approval_policy"], "never"); + native + .call( + "codex.session.start", + json!({"cwd":"/tmp/project","mode":"inspect","inherit_permissions":true}), + ) + .await + .unwrap(); + native + .call( + "codex.session.start", + json!({"cwd":"/tmp/project","mode":"inspect"}), + ) + .await + .unwrap(); + native + .call( + "codex.session.start", + json!({"cwd":"/tmp/project","mode":"edit"}), + ) + .await + .unwrap(); + let recorded = std::fs::read_to_string(requests) + .unwrap() + .lines() + .map(|line| serde_json::from_str::(line).unwrap()) + .collect::>(); + let resume = recorded + .iter() + .find(|v| v["method"] == "thread/resume") + .unwrap(); + for key in [ + "sandbox", + "approvalPolicy", + "model", + "config", + "permissionProfile", + ] { + assert!( + resume["params"].get(key).is_none(), + "resume must not override {key}" + ); + } + assert_eq!( + resume["params"].get("excludeTurns").is_some(), + version == "0.153.4" + ); + let starts = recorded + .iter() + .filter(|v| v["method"] == "thread/start") + .collect::>(); + assert_eq!(starts.len(), 3); + assert!(starts[0]["params"].get("sandbox").is_none()); + assert!(starts[0]["params"].get("approvalPolicy").is_none()); + assert_eq!(starts[1]["params"]["sandbox"], "read-only"); + assert_eq!(starts[2]["params"]["sandbox"], "workspace-write"); + native.shutdown().await; + } +} + +#[tokio::test] +async fn slow_optional_settings_do_not_hold_history_for_the_native_rpc_timeout() { + let directory = tempfile::tempdir().unwrap(); + let bin = fixture(directory.path(), "0.153.4"); + let script = std::fs::read_to_string(&bin) + .unwrap() + .replace("printf 'legacy\\n' >>", "sleep 1; printf 'legacy\\n' >>"); + transport::write_fixture_executable(&bin, &script); + let native = Codex::new(Some(bin)); + native.warm().await.unwrap(); + let start = Instant::now(); + let page = native + .call("codex.session.history", json!({"session_id":"s"})) + .await + .unwrap(); + assert!(start.elapsed() < Duration::from_millis(750)); + assert_eq!(page["turns"][0]["items"][0]["text"], "用户输入"); + assert_eq!(page["context"], json!({})); + native.shutdown().await; +} + +#[tokio::test] +#[ignore = "requires an installed Codex; creates and archives one synthetic local thread, no model turn"] +async fn installed_codex_preserves_native_permissions_and_model_on_resume() { + let directory = tempfile::tempdir().unwrap(); + let bin = std::env::var_os("FARHELM_TEST_CODEX_BIN").map(PathBuf::from); + let native = Codex::new(bin.clone()); + native.warm().await.unwrap(); + let connection = native.connection().await.unwrap(); + // Establish a native thread independently, as another Codex client would. + let original = connection + .request( + "thread/start", + json!({"cwd":directory.path(),"sandbox":"workspace-write","approvalPolicy":"never"}), + ) + .await + .unwrap(); + let id = original["thread"]["id"].as_str().unwrap(); + // Codex does not persist a thread until it has history. Inject one synthetic + // history item without starting a model turn or executing any tools. + connection + .request( + "thread/inject_items", + json!({"threadId":id,"items":[{"type":"message","role":"user","content":[{"type":"input_text","text":"FarHelm synthetic settings verification."}]}]}), + ) + .await + .unwrap(); + // Archive flushes Codex's lazily materialized rollout. Restore visibility + // before reconnecting so this checks a persisted thread, not a live cache. + connection + .request("thread/archive", json!({"threadId":id})) + .await + .unwrap(); + connection + .request("thread/unarchive", json!({"threadId":id})) + .await + .unwrap(); + native.shutdown().await; + // A fresh App Server must restore the saved settings, not its own defaults. + let native = Codex::new(bin); + native.warm().await.unwrap(); + let connection = native.connection().await.unwrap(); + let resumed = native + .call( + "codex.session.resume", + json!({"session_id":id,"cwd":directory.path(),"mode":"inspect"}), + ) + .await; + let confirmed = native + .inner + .loaded + .read() + .await + .get(id) + .map(|thread| thread.context.clone()); + // Always clean up the thread before checking assertions, including adapter failures. + let archived = connection + .request("thread/archive", json!({"threadId":id})) + .await; + native.shutdown().await; + resumed.unwrap(); + archived.unwrap(); + let confirmed = confirmed.unwrap(); + assert_eq!(confirmed, context::project(&original, None)); + assert_eq!(confirmed.sandbox.as_deref(), Some("workspace-write")); + assert_eq!(confirmed.approval_policy.as_deref(), Some("never")); + assert!(confirmed.model.is_some()); + println!("Native model and permissions preserved; no model turn executed."); +} + #[tokio::test] async fn native_versions_share_index_and_coalesce_history_without_losing_text() { for version in ["0.147.0", "0.153.4"] { @@ -88,6 +271,193 @@ async fn native_versions_share_index_and_coalesce_history_without_losing_text() } } +fn gated_index_fixture(directory: &std::path::Path) -> PathBuf { + let path = fixture(directory, "0.153.4"); + let script = std::fs::read_to_string(&path).unwrap(); + let gate = directory.join("hold-index"); + let fail = directory.join("fail-index"); + let script = script.replace( + " case \"$line\" in *'\"archived\":true'*)", + &format!( + " while [ -f '{}' ]; do sleep 0.01; done\n if [ -f '{}' ]; then printf '{{\"id\":%s,\"error\":{{\"code\":-32000,\"message\":\"fixture index unavailable\"}}}}\\n' \"$id\"; continue; fi\n case \"$line\" in *'\"archived\":true'*)", + gate.display(), fail.display() + ), + ); + assert!(script.contains("hold-index")); + transport::write_fixture_executable(&path, &script); + path +} + +async fn wait_for_index_calls(directory: &std::path::Path, expected: usize) { + tokio::time::timeout(Duration::from_secs(2), async { + loop { + let calls = std::fs::read_to_string(directory.join("methods")) + .unwrap_or_default() + .lines() + .filter(|line| *line == "list") + .count(); + if calls >= expected { + return; + } + tokio::time::sleep(Duration::from_millis(2)).await; + } + }) + .await + .expect("fixture index call started"); +} + +#[tokio::test] +async fn warm_index_reads_do_not_wait_for_background_scan() { + let directory = tempfile::tempdir().unwrap(); + let native = Codex::new(Some(gated_index_fixture(directory.path()))); + native.index(false).await.unwrap(); + // A completed snapshot remains usable when its next scan is already in flight. + native.inner.index.write().await.refreshed = None; + let gate = directory.path().join("hold-index"); + std::fs::write(&gate, "hold").unwrap(); + let refresh = tokio::spawn({ + let native = native.clone(); + async move { native.index(true).await } + }); + wait_for_index_calls(directory.path(), 3).await; + let release = tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(200)).await; + std::fs::remove_file(gate).unwrap(); + }); + let started = Instant::now(); + let display = native + .call( + "codex.session.display", + json!({"mode":"labels","session_ids":["s"],"bindings":{"s":{"project_id":"project","cwd":"/tmp/project"}}}), + ) + .await + .unwrap(); + let elapsed = started.elapsed(); + println!( + "warm_index_read_during_200ms_scan_ms={:.3}", + elapsed.as_secs_f64() * 1000.0 + ); + release.await.unwrap(); + refresh.await.unwrap().unwrap(); + native.shutdown().await; + assert_eq!(display["sessions"][0]["display_label"], "首条用户请求"); + assert!( + elapsed < Duration::from_millis(100), + "cached index waited for scan: {elapsed:?}" + ); +} + +#[tokio::test] +async fn index_cold_reads_share_one_complete_scan() { + let directory = tempfile::tempdir().unwrap(); + let native = Codex::new(Some(gated_index_fixture(directory.path()))); + let gate = directory.path().join("hold-index"); + std::fs::write(&gate, "hold").unwrap(); + let mut tasks = Vec::new(); + for _ in 0..8 { + let native = native.clone(); + tasks.push(tokio::spawn(async move { native.index(false).await })); + } + wait_for_index_calls(directory.path(), 1).await; + assert!(tasks.iter().all(|task| !task.is_finished())); + std::fs::remove_file(gate).unwrap(); + for task in tasks { + assert_eq!(task.await.unwrap().unwrap()[0]["id"], "s"); + } + let methods = std::fs::read_to_string(directory.path().join("methods")).unwrap(); + assert_eq!(methods.lines().filter(|line| *line == "list").count(), 2); + native.shutdown().await; +} + +#[tokio::test] +async fn index_overlapping_forced_scans_coalesce_and_preserve_native_changes() { + let directory = tempfile::tempdir().unwrap(); + let native = Codex::new(Some(gated_index_fixture(directory.path()))); + native.index(false).await.unwrap(); + let gate = directory.path().join("hold-index"); + std::fs::write(&gate, "hold").unwrap(); + let mut tasks = Vec::new(); + for _ in 0..8 { + let native = native.clone(); + tasks.push(tokio::spawn(async move { native.index(true).await })); + } + wait_for_index_calls(directory.path(), 3).await; + let connection = native.connection().await.unwrap(); + for event in [ + json!({"method":"thread/name/updated","params":{"threadId":"s","threadName":"新的正式标题"}}), + json!({"method":"thread/status/changed","params":{"threadId":"s","status":{"type":"active"}}}), + json!({"method":"thread/archived","params":{"threadId":"s"}}), + ] { + connection.events.send(event).unwrap(); + } + tokio::time::timeout(Duration::from_secs(1), async { + while native.inner.index.read().await.rows["s"]["archived"] != true { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + std::fs::remove_file(gate).unwrap(); + for task in tasks { + let rows = task.await.unwrap().unwrap(); + assert_eq!(rows[0]["name"], "新的正式标题"); + assert_eq!(rows[0]["status"]["type"], "active"); + assert_eq!(rows[0]["archived"], true); + } + let methods = std::fs::read_to_string(directory.path().join("methods")).unwrap(); + assert_eq!(methods.lines().filter(|line| *line == "list").count(), 4); + native.shutdown().await; +} + +#[tokio::test] +async fn index_failed_and_cancelled_scans_keep_snapshot_and_allow_retry() { + let directory = tempfile::tempdir().unwrap(); + let native = Codex::new(Some(gated_index_fixture(directory.path()))); + let snapshot = native.index(false).await.unwrap(); + let fail = directory.path().join("fail-index"); + std::fs::write(&fail, "fail").unwrap(); + native.inner.index.write().await.refreshed = None; + assert!(native.index(true).await.is_err()); + assert_eq!(native.inner.index.read().await.rows["s"], snapshot[0]); + assert!(native.inner.refresh.try_lock().is_ok()); + std::fs::remove_file(fail).unwrap(); + assert_eq!(native.index(false).await.unwrap(), snapshot); + + let gate = directory.path().join("hold-index"); + std::fs::write(&gate, "hold").unwrap(); + let refresh = tokio::spawn({ + let native = native.clone(); + async move { native.index(true).await } + }); + wait_for_index_calls(directory.path(), 6).await; + refresh.abort(); + assert!(refresh.await.unwrap_err().is_cancelled()); + assert!(native.inner.refresh.try_lock().is_ok()); + assert_eq!(native.inner.index.read().await.rows["s"], snapshot[0]); + std::fs::remove_file(gate).unwrap(); + assert_eq!(native.index(true).await.unwrap(), snapshot); + native.shutdown().await; +} + +#[tokio::test] +async fn empty_complete_index_is_available_during_refresh() { + let directory = tempfile::tempdir().unwrap(); + let path = gated_index_fixture(directory.path()); + let script = std::fs::read_to_string(&path).unwrap().replace( + r#"[{"id":"s","cwd":"/tmp/project","name":null,"preview":"首条用户请求","updatedAt":2}]"#, + "[]", + ); + transport::write_fixture_executable(&path, &script); + let native = Codex::new(Some(path)); + assert!(native.index(false).await.unwrap().is_empty()); + native.inner.index.write().await.refreshed = None; + let scan = native.inner.refresh.lock().await; + let rows = tokio::time::timeout(Duration::from_millis(100), native.index(false)).await; + drop(scan); + native.shutdown().await; + assert!(rows.unwrap().unwrap().is_empty()); +} + #[tokio::test] async fn loaded_empty_thread_can_send_and_interleaved_items_keep_offsets_before_terminal() { let directory = tempfile::tempdir().unwrap(); diff --git a/farhelm-agent/src/codex/transport.rs b/farhelm-agent/src/codex/transport.rs index fc2a699..6e9faa3 100644 --- a/farhelm-agent/src/codex/transport.rs +++ b/farhelm-agent/src/codex/transport.rs @@ -312,14 +312,33 @@ pub fn discover(explicit: Option<&Path>) -> Result { } } +#[cfg(test)] +pub(super) fn write_fixture_executable(path: &Path, script: &str) { + use std::io::Write; + // Other test threads can fork between a parent-side write and close, + // inheriting a writable fd until exec and causing ETXTBSY. Keep that fd + // entirely inside a writer process and wait for it to exit before exec. + let mut writer = std::process::Command::new("/bin/sh") + .args(["-c", "umask 077; cat > \"$1\" && chmod 700 \"$1\"", "--"]) + .arg(path) + .stdin(Stdio::piped()) + .spawn() + .unwrap(); + writer + .stdin + .take() + .unwrap() + .write_all(script.as_bytes()) + .unwrap(); + assert!(writer.wait().unwrap().success()); +} + #[cfg(test)] mod tests { use super::*; - use std::os::unix::fs::PermissionsExt; fn executable(directory: &Path, name: &str, script: &str) -> PathBuf { let path = directory.join(name); - std::fs::write(&path, script).unwrap(); - std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700)).unwrap(); + write_fixture_executable(&path, script); path } #[tokio::test] @@ -386,13 +405,17 @@ done "codex", "#!/usr/bin/env farhelm-fixture-node\n", ); - let interpreter = executable( + executable( directory.path(), "farhelm-fixture-node", "#!/bin/sh\nprintf 'codex-cli 0.153.4\\n'\n", ); assert_eq!(version(&bin).await.unwrap(), "0.153.4"); - std::fs::write(&interpreter, "#!/bin/sh\nprintf 'codex-cli 0.147.0\\n'\n").unwrap(); + executable( + directory.path(), + "farhelm-fixture-node", + "#!/bin/sh\nprintf 'codex-cli 0.147.0\\n'\n", + ); assert_eq!(version(&bin).await.unwrap(), "0.147.0"); let malformed = executable( directory.path(), diff --git a/farhelm-agent/src/main.rs b/farhelm-agent/src/main.rs index 1c08249..fd88315 100644 --- a/farhelm-agent/src/main.rs +++ b/farhelm-agent/src/main.rs @@ -112,7 +112,7 @@ enum CommandKind { /// Only report whether an update is available. #[arg(long)] check: bool, - /// Install one exact formal version, such as V0.8.0. + /// Install one exact formal version, such as V0.9.0. #[arg(long)] version: Option, /// Permit a user-approved first-number version change. @@ -1571,7 +1571,7 @@ async fn execute_remote_command_inner( } else { project_root.clone() }; - let value = worker_runtime.codex.call(method, serde_json::json!({"session_id":command.payload.get("session_id"),"cwd":cwd.clone(),"mode":mode})).await?; + let value = worker_runtime.codex.call(method, serde_json::json!({"session_id":command.payload.get("session_id"),"cwd":cwd.clone(),"mode":mode,"inherit_permissions":command.payload.get("inherit_permissions").and_then(serde_json::Value::as_bool).unwrap_or(false)})).await?; let session_id = value .get("session_id") .and_then(serde_json::Value::as_str) diff --git a/farhelm-console/e2e/codex.spec.ts b/farhelm-console/e2e/codex.spec.ts index 8e3e232..e97ce4f 100644 --- a/farhelm-console/e2e/codex.spec.ts +++ b/farhelm-console/e2e/codex.spec.ts @@ -15,6 +15,7 @@ async function setup(page: Page) { await page.route('**/api/v1/**', (route) => { const path = new URL(route.request().url()).pathname if (path.endsWith('/auth/session')) return route.fulfill({ json: { authenticated: true, user: 'admin', csrf_token: 'test-csrf', expires_at_unix: 2000000000 } }) + if (path.endsWith('/agents')) return route.fulfill({ json: { protocol: 'farhelm/1', agents: [{ agent_id: 'gpu-a', hostname: 'TITAN', agent_version: '0.8.0', online: true, last_seen_unix: 2000000000, capabilities: ['codex.session_context'] }] } }) if (path.endsWith('/codex/sessions')) return route.fulfill({ json: { protocol: 'farhelm/1', sessions: model.sessions } }) if (path.endsWith('/session-display')) return route.fulfill({ json: { protocol: 'farhelm/1', sessions: model.sessions.map((s) => ({ ...s, display_label: s.session_id === 'ses-a' ? '训练结果分析' : '另一个会话' })), incomplete_agents: [] } }) if (path.endsWith('/transcript')) { model.historyReads++; return route.fulfill({ status: model.historyStatus, json: model.historyStatus !== 200 ? { error: model.historyError } : path.includes('ses-b') ? { session_id: 'ses-b', turns: [turn('另一会话的回复')] } : model.history }) } @@ -38,6 +39,215 @@ async function composerFits(page: Page) { if (await navigation.isVisible()) { const nav = await navigation.boundingBox(); expect(bounds!.y + bounds!.height).toBeLessThanOrEqual(nav!.y + 1) } } +test('welcome leads to a real session and suggestions only prepare a focused draft', async ({ page }) => { + const model = await setup(page) + model.history.turns = [] + let sends = 0 + await page.route('**/ses-a/messages', (route) => { sends++; return route.fulfill({ json: { state: 'accepted' } }) }) + await page.goto('/codex') + await expect(page.getByRole('heading', { name: '继续你的工作' })).toBeVisible() + await expect(page.getByRole('button', { name: '开启新会话' })).toBeVisible() + await expect(page.locator('.composer')).toHaveCount(0) + await page.locator('.recent-session').filter({ hasText: '训练结果分析' }).click() + await expect(page).toHaveURL(/session=ses-a/) + await expect(page.getByRole('heading', { name: '准备好,开始下一步' })).toBeVisible() + await page.getByRole('button', { name: '检查代码问题' }).click() + const input = page.getByLabel('给 Codex 发送指令') + await expect(input).toBeFocused() + await expect(input).toHaveValue(/检查这个项目可能存在的问题/) + expect(sends).toBe(0) + await choose(page, '另一个会话') + await choose(page, '训练结果分析') + await expect(input).toHaveValue(/检查这个项目可能存在的问题/) + await expect(page.getByRole('button', { name: '检查代码问题' })).toHaveCount(0) + await composerFits(page) +}) + +test('projects group sessions across devices and collapse independently without changing targets', async ({ page }) => { + const model = await setup(page) + model.sessions.push({ ...session('ses-c'), agent_id: 'gpu-b', title: '另一台服务器的会话' }) + model.sessions.push({ ...session('ses-d'), project_id: 'vision', title: '另一个项目的会话' }) + await page.route('**/api/v1/projects', (route) => route.fulfill({ json: { protocol: 'farhelm/1', projects: ['gpu-a', 'gpu-b'].map((agent_id) => ({ candidate_id: agent_id, agent_id, suggested_project_id: 'cc08', display_name: '共享项目', state: 'approved' })) } })) + await page.route('**/api/v1/agents', (route) => route.fulfill({ json: { protocol: 'farhelm/1', agents: ['gpu-a', 'gpu-b'].map((agent_id, index) => ({ agent_id, hostname: index ? '3090' : 'TITAN', agent_version: '0.8.0', last_seen_unix: 2000000000, online: !index, credential_state: 'paired' })) } })) + await page.goto('/codex?session=ses-a') + await expect(page.locator('.conversation-title')).toContainText('训练结果分析') + await page.getByLabel('给 Codex 发送指令').fill('折叠项目时保留草稿') + if (await page.getByRole('button', { name: '打开会话列表' }).isVisible()) { + await page.getByRole('button', { name: '打开会话列表' }).click() + await expect(page.getByRole('dialog', { name: '项目和会话' })).toBeVisible() + } + const rail = page.getByRole('complementary', { name: '项目和会话' }).filter({ visible: true }) + const headers = rail.locator('.session-group-heading') + await expect(headers).toHaveCount(3) + await expect(headers.nth(0)).toHaveAccessibleName('项目 共享项目 · TITAN') + await expect(headers.nth(1)).toHaveAccessibleName('项目 共享项目 · 3090') + await expect(headers.nth(2)).toHaveAccessibleName('项目 vision · TITAN') + await expect(headers.nth(1)).toHaveAccessibleDescription('设备离线') + const first = rail.getByRole('button', { name: '项目 共享项目 · TITAN', exact: true }) + const other = rail.getByRole('button', { name: '项目 共享项目 · 3090', exact: true }) + const nameBounds = await first.locator('.project-name').boundingBox() + const deviceBounds = await first.locator('.project-device').boundingBox() + expect(Math.abs(nameBounds!.y - deviceBounds!.y)).toBeLessThan(3) + expect(deviceBounds!.x).toBeGreaterThan(nameBounds!.x) + expect((await first.boundingBox())!.height).toBeGreaterThanOrEqual(44) + await first.focus(); await page.keyboard.press('Enter') + await expect(first).toHaveAttribute('aria-expanded', 'false') + await expect(rail.getByRole('button', { name: '训练结果分析', exact: true })).toBeHidden() + await expect(other).toHaveAttribute('aria-expanded', 'true') + await expect(page).toHaveURL(/session=ses-a/) + const remote = rail.getByRole('button', { name: '另一台服务器的会话' }) + await remote.focus(); await page.keyboard.press('Home') + await expect(remote).toBeFocused() + await page.keyboard.press('ArrowUp') + await expect(remote).toBeFocused() + await page.keyboard.press('Enter') + await expect(page).toHaveURL(/session=ses-c/) + if (await page.getByRole('button', { name: '打开会话列表' }).isVisible()) await page.getByRole('button', { name: '打开会话列表' }).click() + await expect(first).toHaveAttribute('aria-expanded', 'false') + await first.click() + await rail.getByRole('button', { name: '训练结果分析', exact: true }).click() + await expect(page.getByLabel('给 Codex 发送指令')).toHaveValue('折叠项目时保留草稿') + await composerFits(page) +}) + +test('search shortcut opens the collapsed rail without changing the active draft', async ({ page }, info) => { + test.skip(info.project.name === 'mobile', 'Desktop keyboard shortcut') + await setup(page); await page.goto('/codex?session=ses-a') + await page.getByLabel('给 Codex 发送指令').fill('未发送的草稿') + await page.getByRole('button', { name: '折叠会话列表' }).click() + await page.keyboard.press('Control+k') + await expect(page.getByLabel('搜索全部会话')).toBeFocused() + await expect(page.getByLabel('给 Codex 发送指令')).toHaveValue('未发送的草稿') + await expect(page.locator('.app-sider')).toBeVisible() +}) + +test('compact navigation preserves every destination and aligns the reading column', async ({ page }, info) => { + test.skip(info.project.name === 'mobile', 'Desktop navigation and reading alignment') + await setup(page); await page.goto('/codex?session=ses-a') + await expect(page.locator('.markdown-body').first()).toBeVisible() + const nav = page.getByRole('navigation', { name: '系统导航' }) + for (const name of ['总览', 'Agent', '实验', 'Codex', '通知', '审计', '设置']) await expect(nav.getByRole('menuitem', { name, exact: true })).toBeVisible() + expect((await page.locator('.app-sider').boundingBox())!.width).toBe(88) + expect((await page.locator('.codex-desktop-rail').boundingBox())!.width).toBe(280) + const heading = await page.locator('.conversation-title h1').boundingBox() + const body = await page.locator('.codex-transcript').boundingBox() + expect(Math.abs(heading!.x - body!.x)).toBeLessThan(2) + expect(await page.evaluate(() => getComputedStyle(document.body).fontFamily)).toContain('Noto Sans SC Variable') + await composerFits(page) +}) + +test('project scope popover closes after selection and preserves the active draft', async ({ page }) => { + await setup(page) + await page.route('**/api/v1/projects', (route) => route.fulfill({ json: { protocol: 'farhelm/1', projects: [{ candidate_id: 'project-a', agent_id: 'gpu-a', suggested_project_id: 'cc08', display_name: '训练项目', state: 'approved' }] } })) + await page.goto('/codex?session=ses-a') + await page.getByLabel('给 Codex 发送指令').fill('筛选期间保留这条草稿') + if (await page.getByRole('button', { name: '打开会话列表' }).isVisible()) await page.getByRole('button', { name: '打开会话列表' }).click() + const rail = page.getByRole('complementary', { name: '项目和会话' }).filter({ visible: true }) + await rail.getByRole('button', { name: '筛选服务器与项目' }).click() + await page.getByLabel('筛选项目').click() + const scoped = page.waitForRequest((request) => request.url().endsWith('/session-display') && request.postDataJSON()?.mode === 'search' && request.postDataJSON()?.agent_id === 'gpu-a' && request.postDataJSON()?.project_id === 'cc08') + await page.getByText('gpu-a / 训练项目', { exact: true }).click() + await scoped + await expect(rail.locator('.active-session-scope')).toContainText('gpu-a / 训练项目') + await expect(page.getByLabel('筛选项目')).toBeHidden() + await rail.getByRole('button', { name: '清除项目筛选' }).click() + await expect(rail.locator('.active-session-scope')).toHaveCount(0) + await rail.getByRole('button', { name: '训练结果分析', exact: true }).click() + await expect(page.getByLabel('给 Codex 发送指令')).toHaveValue('筛选期间保留这条草稿') + await composerFits(page) +}) + +test('short desktop windows keep system navigation reachable above the account footer', async ({ page }, info) => { + test.skip(info.project.name === 'mobile', 'Desktop navigation geometry') + await setup(page); await page.setViewportSize({ width: 1440, height: 500 }) + await page.goto('/codex?session=ses-a') + const settings = page.getByRole('navigation', { name: '系统导航' }).getByRole('menuitem', { name: /设置/ }) + await settings.scrollIntoViewIfNeeded() + const item = await settings.boundingBox(), footer = await page.locator('.sider-footer').boundingBox() + expect(item!.y).toBeGreaterThanOrEqual(0) + expect(item!.y + item!.height).toBeLessThanOrEqual(footer!.y) + await settings.click() + await expect(page.getByRole('heading', { name: '设置', exact: true })).toBeVisible() +}) + +test('connection status follows the shared stream and keeps the draft during reconnect', async ({ page }) => { + await setup(page); await page.goto('/codex?session=ses-a') + await page.getByLabel('给 Codex 发送指令').fill('断线后继续编辑') + await emit(page, 'open', {}) + await expect(page.getByRole('status', { name: '实时连接' })).toBeVisible() + await emit(page, 'error', {}) + await expect(page.getByRole('status', { name: '正在重连' })).toBeVisible() + await expect(page.getByLabel('给 Codex 发送指令')).toHaveValue('断线后继续编辑') + await emit(page, 'open', {}) + await expect(page.getByRole('status', { name: '实时连接' })).toBeVisible() +}) + +test('motion communicates work and submission while reduced motion keeps the same controls', async ({ page }) => { + const model = await setup(page) + model.sessions[0].active_turn_id = 'turn-a'; model.sessions[0].state = 'running' + await page.emulateMedia({ reducedMotion: 'no-preference' }) + await page.goto('/codex?session=ses-a') + await expect(page.locator('.response-activity')).toContainText('Codex 正在处理') + expect(await page.locator('.activity-indicator i').first().evaluate((node) => node.getAnimations().some((animation) => animation.playState === 'running'))).toBe(true) + await expect(page.locator('.markdown-table')).toHaveCount(1) + await expect(page.locator('.response-activity')).toBeInViewport() + await page.locator('.conversation-scroll').hover() + await page.mouse.wheel(0, -720) + await expect(page.getByRole('button', { name: '回到底部', exact: true })).toBeVisible() + const input = page.getByLabel('给 Codex 发送指令') + await input.fill('下一轮继续检查') + await expect.poll(() => page.locator('.composer').evaluate((node) => getComputedStyle(node, '::after').opacity)).toBe('1') + let release: () => void = () => {} + const gate = new Promise((resolve) => { release = resolve }) + await page.route('**/ses-a/messages', async (route) => { await gate; return route.fulfill({ json: { command_id: 'cmd-motion', state: 'accepted' } }) }) + await page.getByRole('button', { name: '发送指令' }).click() + await expect(input).toBeFocused() + await expect(page.locator('.pending-message')).toContainText('正在提交') + await expect(page.locator('.pending-message')).toBeInViewport() + expect(await page.locator('.pending-message').evaluate((node) => getComputedStyle(node).animationName)).toBe('message-submit') + expect(await page.locator('.codex-message.assistant').first().evaluate((node) => node.getAnimations().length)).toBe(0) + await input.fill('保存确认到达前,继续编写新的草稿') + release() + await expect(page.locator('.submission-status')).toContainText('Agent 已保存') + await expect(input).toHaveValue('保存确认到达前,继续编写新的草稿') + await choose(page, '另一个会话'); await choose(page, '训练结果分析') + await expect(input).toHaveValue('保存确认到达前,继续编写新的草稿') + expect(await page.locator('.composer').evaluate((node) => getComputedStyle(node).animationName)).toBe('none') + expect(await page.locator('.pending-message').evaluate((node) => getComputedStyle(node).animationName)).toBe('none') + await page.emulateMedia({ reducedMotion: 'reduce' }) + await expect.poll(() => page.locator('.activity-indicator i').first().evaluate((node) => node.getAnimations().length)).toBe(0) + await input.fill('减少动态效果时继续输入') + const sendButton = page.getByRole('button', { name: '发送指令' }) + await sendButton.hover(); await page.mouse.down() + expect(await sendButton.evaluate((node) => getComputedStyle(node).transform)).toBe('none') + await page.mouse.move(1, 1); await page.mouse.up() + await expect(page.locator('.response-activity')).toContainText('Codex 正在处理') + await expect(page.getByRole('button', { name: '中断', exact: true })).toBeVisible() + await expect(page.getByRole('button', { name: '定时发送', exact: true })).toBeVisible() + await composerFits(page) +}) + +test('interrupt confirmation inherits the page theme and still requires confirmation of the visible turn', async ({ page }) => { + const model = await setup(page) + model.sessions[0].state = 'running'; model.sessions[0].active_turn_id = 'turn-a' + const targets: unknown[] = [] + await page.route('**/ses-a/interrupt', route => { targets.push(route.request().postDataJSON()); return route.fulfill({ json: { state: 'completed' } }) }) + for (const colorScheme of ['light', 'dark'] as const) { + await page.emulateMedia({ colorScheme }); await page.goto('/codex?session=ses-a') + await page.getByRole('button', { name: '中断', exact: true }).click() + const dialog = page.getByRole('dialog', { name: '中断当前对话?' }) + await expect(dialog.locator('.ant-modal-container')).toHaveCSS('background-color', colorScheme === 'dark' ? 'rgb(48, 48, 48)' : 'rgb(240, 240, 240)') + await expect(dialog).toContainText('轮次 turn-a') + await dialog.getByRole('button', { name: /^取\s*消$/ }).click() + expect(targets).toHaveLength(colorScheme === 'light' ? 0 : 1) + await expect(dialog).toBeHidden() + await page.getByRole('button', { name: '中断', exact: true }).click() + await dialog.getByRole('button', { name: '确认中断', exact: true }).click() + await expect(dialog).toBeHidden() + expect(targets.at(-1)).toEqual({ turn_id: 'turn-a' }) + } +}) + for (const theme of ['light', 'dark'] as const) for (const size of [{ width: 390, height: 844 }, { width: 1440, height: 900 }, { width: 2550, height: 1233 }]) { test(`Markdown and fixed workspace ${size.width} ${theme}`, async ({ page }, info) => { test.skip(info.project.name !== 'desktop', 'Explicit viewport matrix runs once') @@ -161,7 +371,7 @@ test('failed send belongs to its original session while switching', async ({ pag test('interleaved and repeated deltas retain item identity after a failed terminal refresh', async ({ page }) => { const model = await setup(page); model.history = { session_id: 'ses-a', turns: [] } - await page.goto('/codex?session=ses-a'); await expect(page.getByText(/这个会话还没有对话/)).toBeVisible() + await page.goto('/codex?session=ses-a'); await expect(page.getByRole('heading', { name: '准备好,开始下一步' })).toBeVisible() const delta = async (item_id: string, text_offset: number, delta: string) => emit(page, 'codex.message.delta', { session_id: 'ses-a', data: { turn_id: 'stream-turn', item_id, text_offset, delta } }) await delta('a', 0, '第一条🙂'); await delta('b', 0, '第二条'); await delta('a', 4, '完成'); await delta('a', 4, '完成') await expect(page.locator('.codex-message.assistant')).toHaveCount(2) @@ -254,6 +464,18 @@ test('2000 turns load through every page and retain the visible anchor when virt await page.getByRole('button', { name: /回到底部/ }).click() await expect(page.locator('[data-turn-id="long-1999"]')).toBeVisible() await composerFits(page) + await choose(page, '另一个会话') + await expect(page.getByText('另一会话的回复', { exact: true })).toBeVisible() + let resume: () => void = () => {} + const waiting = new Promise(resolve => { resume = resolve }) + // A cached virtual transcript must attach without a metadata/history response triggering another render. + await page.route('**/api/v1/**', async route => { await waiting; await route.fallback().catch(() => {}) }) + try { + await choose(page, '训练结果分析') + await expect(page.locator('[data-turn-id="long-1999"]')).toBeVisible() + await expect(page.getByLabel('给 Codex 发送指令')).toHaveValue('长历史中仍可输入中文🙂') + expect(await page.locator('.codex-turn').count()).toBeLessThan(35) + } finally { resume() } }) test('Enter submits identical prompts with independent identities and no command polling', async ({ page }, info) => { diff --git a/farhelm-console/e2e/composer.spec.ts b/farhelm-console/e2e/composer.spec.ts new file mode 100644 index 0000000..b198620 --- /dev/null +++ b/farhelm-console/e2e/composer.spec.ts @@ -0,0 +1,99 @@ +import AxeBuilder from '@axe-core/playwright' +import { expect, test, type Page } from '@playwright/test' +import type { SessionContext } from '../src/api/features' + +async function setup(page: Page, modern = true) { + const context: SessionContext = { model: 'gpt-5.4', reasoning_effort: 'high', sandbox: 'danger-full-access', approval_policy: 'never' } + const state = { context: modern ? context : undefined, reads: 0, creations: [] as { agent_id: string; mode: string; inherit_permissions?: boolean }[] } + const session = { session_id: 's', agent_id: 'a', project_id: 'p', title: '会话设置验收', mode: 'inspect', state: 'idle', updated_at_unix: 1 } + await page.addInitScript(() => { + const sources: EventTarget[] = [] + class Source extends EventTarget { constructor() { super(); sources.push(this) } close() {} } + Object.assign(window, { EventSource: Source, emitSettingsTest: (type: string, payload: unknown) => sources.forEach(s => s.dispatchEvent(new MessageEvent(type, { data: JSON.stringify({ payload }) }))) }) + }) + await page.route('**/api/v1/**', async route => { + const path = new URL(route.request().url()).pathname + if (path.endsWith('/auth/session')) return route.fulfill({ json: { authenticated: true, user: 'admin', csrf_token: 'test', expires_at_unix: 2000000000 } }) + if (path.endsWith('/agents')) return route.fulfill({ json: { protocol: 'farhelm/1', agents: ['a', 'b'].map(id => ({ agent_id: id, hostname: id, agent_version: '0.8.0', last_seen_unix: 1, online: true, credential_state: 'paired', capabilities: modern ? ['codex.session_context'] : [] })) } }) + if (path.endsWith('/projects')) return route.fulfill({ json: { protocol: 'farhelm/1', projects: ['a', 'b'].map(id => ({ agent_id: id, candidate_id: 'same-candidate', suggested_project_id: 'p', display_name: '项目', state: 'approved' })) } }) + if (path.endsWith('/transcript')) { state.reads++; return route.fulfill({ json: { session_id: 's', turns: [{ turn_id: 't', status: 'completed', items: [{ item_id: 'i', kind: 'assistant_message', text: '这是合成验收内容。' }] }], context: state.context } }) } + if (path.endsWith('/codex/sessions') && route.request().method() === 'POST') { state.creations.push(route.request().postDataJSON()); return route.fulfill({ json: { state: 'completed', data: { session_id: 's' } } }) } + if (path.endsWith('/codex/sessions') || path.endsWith('/session-display')) return route.fulfill({ json: { protocol: 'farhelm/1', sessions: [session], incomplete_agents: [] } }) + if (path.endsWith('/codex/sessions/s')) return route.fulfill({ json: session }) + return route.fulfill({ json: { protocol: 'farhelm/1', notifications: [], unread_count: 0, latest_id: 0 } }) + }) + return state +} + +test('composer controls share a 44px axis and archive tabs fill the rail in light and dark', async ({ page }, info) => { + await setup(page) + const sizes = info.project.name === 'mobile' ? [[390, 844]] : [[1440, 900], [2550, 1233]] + for (const [width, height] of sizes) for (const colorScheme of ['light', 'dark'] as const) { + await page.setViewportSize({ width, height }); await page.emulateMedia({ colorScheme }); await page.goto('/codex?session=s') + await expect(page.getByRole('button', { name: '会话模型:gpt-5.4,推理强度高' })).toBeVisible() + await expect(page.getByRole('button', { name: '会话权限:完全访问' })).toBeVisible() + await page.evaluate(() => document.fonts.ready) + const bounds = await page.locator('.composer-actions .ant-btn').evaluateAll(nodes => nodes.map(n => { const r = n.getBoundingClientRect(); return { height: r.height, y: r.y, right: r.right } })) + expect(bounds).toHaveLength(4) + for (const box of bounds) { expect(box.height).toBe(44); expect(Math.abs(box.y - bounds[0].y)).toBeLessThan(1); expect(box.right).toBeLessThan(width) } + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true) + const violations = (await new AxeBuilder({ page }).analyze()).violations.filter(v => ['serious', 'critical'].includes(v.impact!)) + expect(violations).toEqual([]) + if (width < 768) await page.getByRole('button', { name: '打开会话列表' }).click() + const rail = page.locator('.codex-rail:visible') + const shape = await rail.evaluate(n => { const tabs = n.querySelector('.archive-tabs')!; return { inner: n.clientWidth - 24, tabs: tabs.getBoundingClientRect().width, items: [...tabs.querySelectorAll('.ant-segmented-item')].map(n => n.getBoundingClientRect().width) } }) + expect(Math.abs(shape.tabs - shape.inner)).toBeLessThan(1) + expect(Math.max(...shape.items) - Math.min(...shape.items)).toBeLessThan(1) + await expect(rail.locator('.codex-rail-head').getByRole('button', { name: '筛选服务器与项目' })).toBeVisible() + } +}) + +test('native model and permissions remain session facts, with readable long and missing values', async ({ page }) => { + const state = await setup(page); await page.goto('/codex?session=s') + await page.getByRole('button', { name: '会话权限:完全访问' }).click() + await expect(page.getByText('无需人工批准', { exact: true })).toBeVisible() + await expect(page.getByText('权限由服务器上的 Codex 管理,此处展示实际设置。')).toBeVisible() + await page.keyboard.press('Escape') + state.context = { model: 'custom-model-'.repeat(9), reasoning_effort: 'medium', sandbox: 'workspace-write', approval_policy: 'on-request', approvals_reviewer: 'user' } + await page.getByRole('button', { name: '刷新对话' }).click() + const model = page.getByRole('button', { name: /会话模型:custom-model-/ }); await expect(model).toBeVisible() + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true) + await model.click(); await expect(page.locator('.session-settings-details:visible dd').first()).toHaveText(state.context.model!) + await page.keyboard.press('Escape'); await page.getByRole('button', { name: '会话权限:可编辑' }).click() + await expect(page.getByText('网页暂不支持人工审批;需要你批准的操作会被拒绝,不会自动放行。')).toBeVisible() + await page.keyboard.press('Escape'); state.context = {}; await page.getByRole('button', { name: '刷新对话' }).click() + await expect(page.getByRole('button', { name: '会话模型:模型未提供' })).toBeVisible() + await expect(page.getByRole('button', { name: '会话权限:跟随 Codex' })).toBeVisible() + await page.getByLabel('给 Codex 发送指令').fill('保留草稿') + state.context = { model: 'gpt-5.4', sandbox: 'read-only', approval_policy: 'on-request' } + await page.evaluate(() => (window as unknown as { emitSettingsTest: (type: string, data: object) => void }).emitSettingsTest('codex.turn.started', { session_id: 's', data: { turn_id: 't' } })) + await expect(page.getByRole('button', { name: '会话权限:仅分析' })).toBeVisible() + await expect(page.getByLabel('给 Codex 发送指令')).toHaveValue('保留草稿') +}) + +test('new sessions inherit native settings and isolate only when explicitly selected', async ({ page }) => { + const state = await setup(page); await page.goto('/codex?session=s') + for (const isolated of [false, true]) { + if (page.viewportSize()!.width < 768) await page.getByRole('button', { name: '打开会话列表' }).click() + await page.getByRole('button', { name: '新建会话', exact: true }).click() + const dialog = page.getByRole('dialog', { name: '创建 Codex 会话' }) + await dialog.getByRole('combobox').click(); await page.getByTitle('项目 · b', { exact: true }).click() + if (isolated) await dialog.getByRole('checkbox', { name: '使用隔离工作区' }).check() + await dialog.getByRole('button', { name: '创建会话', exact: true }).click() + await expect(dialog).toBeHidden() + expect(state.creations.at(-1)).toEqual({ agent_id: 'b', project_id: 'p', mode: isolated ? 'edit' : 'inspect', inherit_permissions: true }) + } +}) + +test('older Agents are not represented as inheriting native permissions', async ({ page }) => { + const state = await setup(page, false); await page.goto('/codex?session=s') + await page.getByRole('button', { name: '会话权限:仅分析' }).click() + await expect(page.getByText('当前 Agent 使用旧的权限逻辑,请升级 Agent 后使用会话权限继承。')).toBeVisible() + await page.keyboard.press('Escape') + if (page.viewportSize()!.width < 768) await page.getByRole('button', { name: '打开会话列表' }).click() + await page.getByRole('button', { name: '新建会话', exact: true }).click() + const dialog = page.getByRole('dialog', { name: '创建 Codex 会话' }) + await dialog.getByRole('combobox').click(); await page.getByTitle('项目 · a', { exact: true }).click() + await expect(dialog.getByRole('button', { name: '创建会话', exact: true })).toBeDisabled() + expect(state.creations).toEqual([]) +}) diff --git a/farhelm-console/e2e/layout.spec.ts b/farhelm-console/e2e/layout.spec.ts index 561a7b6..c2aa0f7 100644 --- a/farhelm-console/e2e/layout.spec.ts +++ b/farhelm-console/e2e/layout.spec.ts @@ -74,11 +74,132 @@ test('responsive navigation and validated status are visible', async ({ page }, }) test('keyboard navigation reaches visible controls', async ({ page }) => { + await expect(page.getByRole('heading', { name: '运行总览' })).toBeVisible() + if (page.viewportSize()!.width >= 768) await expect(page.locator('.app-sider')).toBeVisible() await page.keyboard.press('Tab') const focused = page.locator(':focus-visible') await expect(focused).toHaveCount(1) }) +test('appearance presets and custom colors persist without writing to Hub', async ({ page, context }) => { + const writes: string[] = [] + page.on('request', (request) => { if (request.url().includes('/api/') && request.method() !== 'GET') writes.push(request.url()) }) + await page.goto('/settings') + const canvas = () => page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue('--farhelm-canvas').trim()) + const originalCanvas = await canvas() + await page.getByRole('button', { name: '玫瑰', exact: true }).focus() + await page.keyboard.press('Enter') + await expect(page.getByRole('button', { name: '玫瑰', exact: true })).toHaveAttribute('aria-pressed', 'true') + await expect.poll(() => page.evaluate(() => localStorage.getItem('farhelm-accent'))).toBe('rose') + expect(await canvas()).toBe(originalCanvas) + await page.reload() + await expect(page.getByRole('button', { name: '玫瑰', exact: true })).toHaveAttribute('aria-pressed', 'true') + const input = page.getByLabel('自定义颜色', { exact: true }) + await input.fill('#12xx') + await expect(page.getByRole('button', { name: '应用颜色' })).toBeDisabled() + await expect(input).toHaveAttribute('aria-invalid', 'true') + await input.fill('#a3f') + await input.press('Enter') + await expect.poll(() => page.evaluate(() => localStorage.getItem('farhelm-accent'))).toBe('#AA33FF') + await page.getByRole('radiogroup', { name: '显示模式' }).getByText('浅色', { exact: true }).click() + await expect(page.locator('html')).toHaveAttribute('data-theme', 'light') + await page.reload() + await expect(page.getByLabel('自定义颜色', { exact: true })).toHaveValue('#AA33FF') + const other = await context.newPage() + await other.route('**/api/v1/auth/session', (route) => route.fulfill({ status: 401, json: {} })) + await other.goto('/') + await expect(other.getByLabel('用户名')).toBeVisible() + await page.getByRole('button', { name: '绿色', exact: true }).click() + await expect.poll(() => other.evaluate(() => document.documentElement.style.getPropertyValue('--farhelm-accent'))).toBe(await page.evaluate(() => document.documentElement.style.getPropertyValue('--farhelm-accent'))) + await other.close() + expect(writes).toEqual([]) +}) + +test('one theme color reaches navigation, selected sessions and primary actions while the logo keeps its brand', async ({ page }) => { + for (const [mode, color] of [['浅色', '#00865A'], ['深色', '#AA33FF']]) { + await page.goto('/settings') + await page.getByRole('radiogroup', { name: '显示模式' }).getByText(mode, { exact: true }).click() + await page.getByLabel('自定义颜色', { exact: true }).fill(color) + await page.getByRole('button', { name: '应用颜色' }).click() + await page.goto('/codex?session=ses-a') + await page.getByLabel('给 Codex 发送指令').fill('主题切换时仍保留的草稿') + const themeColors = await page.evaluate(() => { + const rgb = (name: string) => { const hex = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); return `rgb(${[1, 3, 5].map(i => parseInt(hex.slice(i, i + 2), 16)).join(', ')})` } + return { accent: rgb('--farhelm-accent'), soft: rgb('--farhelm-accent-soft') } + }) + await expect(page.getByRole('button', { name: '发送指令' })).toHaveCSS('background-color', themeColors.accent) + const activeNavigation = page.locator('.app-sider .ant-menu-item-selected, .mobile-nav button.active').filter({ visible: true }) + await expect(activeNavigation).toHaveCSS('color', themeColors.accent) + await expect(activeNavigation).toHaveCSS('background-color', themeColors.soft) + const logo = page.locator('.brand img, .mobile-brand img').filter({ visible: true }) + const brand = await logo.evaluate(async (node: HTMLImageElement) => { + await node.decode() + const canvas = document.createElement('canvas'); canvas.width = canvas.height = 96 + const ctx = canvas.getContext('2d')!; ctx.drawImage(node, 0, 0, 96, 96) + const filters = []; let parent: Element | null = node + while (parent) { filters.push(getComputedStyle(parent).filter); parent = parent.parentElement } + return { pixel: [...ctx.getImageData(30, 30, 1, 1).data], filters } + }) + expect(brand.pixel).toEqual([34, 199, 169, 255]); expect(brand.filters.every(v => v === 'none')).toBe(true) + if (page.viewportSize()!.width < 768) await page.getByRole('button', { name: '打开会话列表' }).click() + const selected = page.locator('.codex-rail:visible .session-select[aria-current="page"]') + await expect(selected).toHaveCSS('color', themeColors.accent) + await expect(page.locator('.codex-rail:visible .ant-conversations-item-active')).toHaveCSS('background-color', themeColors.soft) + for (const path of ['/', '/agents', '/experiments', '/notifications', '/audit', '/settings']) { + await page.goto(path) + await expect(page.locator('.app-sider .ant-menu-item-selected, .mobile-nav button.active').filter({ visible: true })).toHaveCSS('color', themeColors.accent) + } + } +}) + +test('project hover and session selection paint one background through pointer and keyboard actions', async ({ page }, info) => { + for (const colorScheme of ['light', 'dark'] as const) { + await page.emulateMedia({ colorScheme }); await page.goto('/codex?session=ses-a') + await page.getByLabel('给 Codex 发送指令').fill('折叠项目后保留草稿') + if (page.viewportSize()!.width < 768) await page.getByRole('button', { name: '打开会话列表' }).click() + const rail = page.locator('.codex-rail:visible'), heading = rail.locator('.session-group-heading').first() + const outer = rail.locator('.ant-conversations-group-title').first() + await heading.hover() + await expect(heading).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)') + await expect(outer).toHaveCSS('height', '44px') + const hover = await outer.evaluate(n => getComputedStyle(n).backgroundColor) + expect(hover).not.toBe('rgba(0, 0, 0, 0)') + await page.mouse.down() + await expect(heading).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)') + await expect(heading).toHaveCSS('transform', 'none') + await expect(outer).toHaveCSS('background-color', hover) + await rail.screenshot({ path: info.outputPath(`single-layer-${colorScheme}.png`) }) + await page.mouse.up(); await expect(heading).toHaveAttribute('aria-expanded', 'false') + await heading.focus(); await page.keyboard.press('Space'); await expect(heading).toHaveAttribute('aria-expanded', 'true') + const selected = rail.locator('.session-select[aria-current="page"]'), row = rail.locator('.ant-conversations-item-active') + const selectedColor = await row.evaluate(n => getComputedStyle(n).backgroundColor) + await selected.hover(); await page.mouse.down() + await expect(selected).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)') + await expect(row).toHaveCSS('background-color', selectedColor) + await page.mouse.up(); await expect(page).toHaveURL(/session=ses-a/) + await expect(page.getByLabel('给 Codex 发送指令')).toHaveValue('折叠项目后保留草稿') + } +}) + +test('extreme custom colors keep appearance controls readable and within the mobile viewport', async ({ page }, info) => { + test.setTimeout(60_000) + await page.goto('/settings') + for (const mode of ['浅色', '深色']) { + await page.getByRole('radiogroup', { name: '显示模式' }).getByText(mode, { exact: true }).click() + await page.getByLabel('自定义颜色', { exact: true }).fill(mode === '浅色' ? '#FFFFFF' : '#000000') + await page.getByRole('button', { name: '应用颜色' }).click() + const results = await new AxeBuilder({ page }).analyze() + expect(results.violations.filter((violation) => violation.impact === 'critical' || violation.impact === 'serious')).toEqual([]) + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true) + const choices = await page.locator('.accent-choice').evaluateAll((nodes) => nodes.map((node) => ({ w: node.getBoundingClientRect().width, h: node.getBoundingClientRect().height }))) + expect(choices.every(({ w, h }) => w >= 44 && h >= 44)).toBe(true) + await page.screenshot({ path: info.outputPath(`appearance-${mode}.png`), fullPage: true }) + } + await page.getByRole('button', { name: '打开自定义取色器' }).click() + await expect(page.locator('.ant-color-picker-panel')).toBeVisible() + await page.keyboard.press('Escape') +}) + test('agent page renders validated Hub data', async ({ page }) => { await page.goto('/agents') await expect(page.getByRole('heading', { name: '服务器' })).toBeVisible() @@ -129,7 +250,8 @@ test('experiment deep link and Codex manual queue use the mobile-safe workflow', await route.fulfill({ status: 202, contentType: 'application/json', body: '{}' }) }) await page.getByRole('button', { name: /定时发送/ }).click() - await page.getByLabel('发送时间(本地时区)').fill('2030-01-01T12:00') + const localTime = await page.evaluate(() => { const date = new Date(Date.now() + 300_000); return new Date(date.getTime() - date.getTimezoneOffset() * 60_000).toISOString().slice(0, 16) }) + await page.getByLabel('发送时间(本地时区)').fill(localTime) await page.getByLabel('指令', { exact: true }).fill('定时检查结果') await page.getByRole('button', { name: '创建定时任务' }).click() await expect.poll(() => scheduled).toMatchObject({ prompt: '定时检查结果', trigger: { type: 'at_time' } }) @@ -190,6 +312,7 @@ test('switching sessions ignores late history responses', async ({ page }, testI }) test('same project names on different Agents retain the selected target', async ({ page }, testInfo) => { + await page.route('**/api/v1/agents', (route) => route.fulfill({ json: { protocol: 'farhelm/1', agents: ['gpu-a', 'gpu-b'].map(agent_id => ({ agent_id, hostname: agent_id, agent_version: '0.8.0', online: true, last_seen_unix: 2000000000, capabilities: ['codex.session_context'] })) } })) await page.route('**/api/v1/projects', (route) => route.fulfill({ json: { protocol: 'farhelm/1', projects: ['gpu-a', 'gpu-b'].map((agent_id) => ({ candidate_id: `candidate-${agent_id}`, agent_id, display_name: 'shared', suggested_project_id: 'shared', session_count: 1, state: 'approved', updated_at_unix: 2000000000 })) } })) let target: unknown await page.route('**/api/v1/codex/sessions', async (route) => { target = route.request().postDataJSON(); await route.fulfill({ status: 202, json: {} }) }) @@ -199,7 +322,7 @@ test('same project names on different Agents retain the selected target', async await page.getByLabel('项目', { exact: true }).click() await page.getByText('shared · gpu-b', { exact: true }).click() await page.getByRole('dialog').getByRole('button', { name: /创\s*建/ }).click() - await expect.poll(() => target).toEqual({ agent_id: 'gpu-b', project_id: 'shared', mode: 'inspect' }) + await expect.poll(() => target).toEqual({ agent_id: 'gpu-b', project_id: 'shared', mode: 'inspect', inherit_permissions: true }) }) diff --git a/farhelm-console/e2e/metadata.spec.ts b/farhelm-console/e2e/metadata.spec.ts new file mode 100644 index 0000000..1831532 --- /dev/null +++ b/farhelm-console/e2e/metadata.spec.ts @@ -0,0 +1,148 @@ +import { expect, test, type Page } from '@playwright/test' + +const notice = (id: number, title: string) => ({ id, event_id: `event-${id}`, agent_id: 'gpu-a', category: 'experiment', state: 'succeeded', target_id: `run-${id}`, title, message: '合成实验结果', created_at_unix: 2_000_000_000, read_at_unix: null as number | null }) +const run = (id: number, name: string) => ({ id: `run-${id}`, agent_id: 'gpu-a', project_id: 'project', source: 'script_report', name, state: 'succeeded', updated_at_unix: 2_000_000_000 }) +const session = { session_id: 'ses-a', agent_id: 'gpu-a', project_id: 'project', mode: 'inspect', state: 'idle', title: '测试会话', updated_at_unix: 2_000_000_000 } +const emit = (page: Page, type: string, payload = {}) => page.evaluate(({ type, payload }) => { + (window as unknown as { metadataEvent: (type: string, payload: object) => void }).metadataEvent(type, payload) +}, { type, payload }) + +test.beforeEach(async ({ page }) => { + await page.emulateMedia({ reducedMotion: 'reduce' }) + await page.addInitScript(() => { + const sources = new Set() + class Source extends EventTarget { constructor() { super(); sources.add(this); setTimeout(() => this.dispatchEvent(new Event('open')), 0) } close() { sources.delete(this) } } + Object.assign(window, { EventSource: Source, metadataEvent: (type: string, payload: object) => sources.forEach(source => source.dispatchEvent(new MessageEvent(type, { data: JSON.stringify({ payload }) }))) }) + }) + await page.route('**/api/v1/**', route => { + const url = new URL(route.request().url()), path = url.pathname, more = url.searchParams.has('cursor') + let value: unknown = {} + if (path.endsWith('/auth/session')) value = { authenticated: true, user: 'admin', csrf_token: 'test-csrf', expires_at_unix: 2_000_000_000 } + else if (path.endsWith('/agents')) value = { protocol: 'farhelm/1', agents: ['a', 'b'].map(id => ({ agent_id: `gpu-${id}`, hostname: `服务器 ${id}`, agent_version: '0.9.0', online: true, capabilities: ['codex.session_context'], last_seen_unix: 2_000_000_000, codex: { state: 'ready', version: '0.153.4' } })) } + else if (path.endsWith('/health')) value = { protocol: 'farhelm/1', service: 'farhelm-hub', status: 'ok', version: '0.9.0' } + else if (path.endsWith('/notifications/preferences')) value = { experiments: true, codex: true } + else if (path.endsWith('/notifications')) value = { notifications: [notice(more ? 1 : 2, more ? '第二页记录' : '第一页记录')], latest_id: 2, unread_count: 2, next_cursor: more ? null : 2 } + else if (path.endsWith('/experiment-runs')) value = { experiments: [run(more ? 1 : 2, more ? '第二页记录' : '第一页记录')], next_cursor: more ? null : 2 } + else if (path.endsWith('/audit')) value = { entries: [{ id: more ? 1 : 2, action: more ? '第二页记录' : '第一页记录', target: 'synthetic', outcome: 'succeeded', created_at_unix: 2_000_000_000 }], next_cursor: more ? null : 2 } + else if (path.endsWith('/codex/sessions')) value = { protocol: 'farhelm/1', sessions: [session] } + else if (path.endsWith('/ses-a')) value = session + else if (path.endsWith('/transcript')) value = { session_id: 'ses-a', turns: [] } + else if (path.endsWith('/session-display')) value = { sessions: [], incomplete_agents: [] } + else if (path.endsWith('/projects')) value = { protocol: 'farhelm/1', projects: [] } + else if (path.endsWith('/experiments')) value = { protocol: 'farhelm/1', experiments: [] } + else if (path.endsWith('/schedules')) value = { protocol: 'farhelm/1', schedules: [] } + return route.fulfill({ json: value }) + }) +}) + +for (const [path, more] of [['notifications', '加载更多通知'], ['experiments', '加载更多实验'], ['audit', '加载更多记录']]) { + test(`${path} refresh retains every loaded page and clears recovered errors`, async ({ page }) => { + await page.goto(`/${path}`) + await expect(page.getByText('第一页记录', { exact: true })).toBeVisible() + await page.getByRole('button', { name: more }).click() + await expect(page.getByText('第二页记录', { exact: true })).toBeVisible() + const endpoint = path === 'experiments' ? 'experiment-runs' : path + await page.route(`**/api/v1/${endpoint}?*`, route => route.fulfill({ status: 503, json: { error: 'synthetic_failure' } })) + if (path === 'audit') await page.route('**/api/v1/audit', route => route.fulfill({ status: 503, json: { error: 'synthetic_failure' } })) + await page.getByRole('button', { name: /^刷\s*新$/ }).click() + await expect(page.getByRole('alert').first()).toBeVisible() + await expect(page.getByText('第二页记录', { exact: true })).toBeVisible() + await page.unroute(`**/api/v1/${endpoint}?*`) + if (path === 'audit') await page.unroute('**/api/v1/audit') + await page.getByRole('button', { name: /^刷\s*新$/ }).click() + await expect(page.getByRole('alert')).toHaveCount(0) + await expect(page.getByText('第二页记录', { exact: true })).toBeVisible() + }) +} + +test('all registered Agents remain available outside the current page; obsolete errors cannot replace the new filter', async ({ page }) => { + let release: () => void = () => {} + const pending = new Promise(resolve => { release = resolve }) + await page.route('**/api/v1/experiment-runs?*', async route => { + const agent = new URL(route.request().url()).searchParams.get('agent') + if (agent === 'gpu-a') { await pending; return route.fulfill({ status: 503, json: { error: 'old_filter' } }).catch(() => {}) } + return route.fulfill({ json: { experiments: [run(1, agent === 'gpu-b' ? '服务器 b 的结果' : '当前结果')], next_cursor: null } }) + }) + await page.goto('/experiments') + await page.getByRole('combobox', { name: '实验服务器' }).click() + await expect(page.getByText('服务器 b', { exact: true }).last()).toBeVisible() + await page.getByText('服务器 a', { exact: true }).last().click() + await page.getByRole('combobox', { name: '实验服务器' }).click() + await page.getByText('服务器 b', { exact: true }).last().click() + await expect(page.getByText('服务器 b 的结果', { exact: true })).toBeVisible() + release(); await page.waitForTimeout(150) + await expect(page.getByRole('alert')).toHaveCount(0) +}) + +test('notification detail exposes read errors and retries without repeating read writes on filtering', async ({ page }) => { + const item = notice(2, '通知详情内容'); let fail = true, writes = 0 + await page.route('**/api/v1/notifications/2', route => fail ? route.fulfill({ status: 503, json: {} }) : route.fulfill({ json: { notification: item, deliveries: [] } })) + await page.route('**/api/v1/notifications/2/read', route => { writes++; item.read_at_unix = 2_000_000_001; return route.fulfill({ json: {} }) }) + await page.goto('/notifications?id=2') + const drawer = page.getByRole('dialog', { name: '通知详情', exact: true }) + await expect(drawer).toBeVisible(); await expect(drawer.getByText('无法读取通知详情')).toBeVisible() + fail = false; await drawer.getByRole('button', { name: '重试读取' }).click() + await expect(drawer.getByRole('heading', { name: '通知详情内容' })).toBeVisible() + await expect.poll(() => writes).toBe(1) + await drawer.getByRole('button', { name: 'Close' }).click() + await page.getByRole('button', { name: '仅未读' }).click() + await page.getByRole('button', { name: '查看详情' }).first().click() + await expect(drawer.getByRole('heading', { name: '通知详情内容' })).toBeVisible() + expect(writes).toBe(1) +}) + +test('notification projection events refresh loaded pages without relying on a turn event', async ({ page }) => { + await page.goto('/notifications'); await page.getByRole('button', { name: '加载更多通知' }).click() + await expect(page.getByText('第二页记录', { exact: true })).toBeVisible() + await page.route('**/api/v1/notifications?*', route => route.fulfill({ json: { notifications: new URL(route.request().url()).searchParams.has('cursor') ? [notice(1, '第二页记录')] : [notice(3, '刚完成的实验'), notice(2, '第一页记录')], latest_id: 3, unread_count: 3, next_cursor: new URL(route.request().url()).searchParams.has('cursor') ? null : 2 } })) + await emit(page, 'notification.created', notice(3, '刚完成的实验')) + await expect(page.getByText('刚完成的实验', { exact: true })).toBeVisible() + await expect(page.getByText('第二页记录', { exact: true })).toBeVisible() +}) + +test('created schedules appear immediately and cancellation requires the task confirmation', async ({ page }) => { + let created = false, cancelled = false + await page.route('**/api/v1/codex/schedules?*', route => route.fulfill({ json: { protocol: 'farhelm/1', schedules: created ? [{ schedule_id: 'schedule-a', session_id: 'ses-a', agent_id: 'gpu-a', project_id: 'project', trigger: { type: 'at_time', run_at_unix: 2_000_000_000 }, state: cancelled ? 'cancelled' : 'pending' }] : [] } })) + await page.route('**/api/v1/codex/sessions/ses-a/schedules', route => { created = true; return route.fulfill({ json: { state: 'completed' } }) }) + await page.route('**/api/v1/codex/schedules/schedule-a/cancel', route => { cancelled = true; return route.fulfill({ json: { state: 'completed' } }) }) + await page.goto('/codex?session=ses-a') + const openList = async () => { await page.getByRole('button', { name: '会话操作' }).click(); await page.getByRole('menuitem', { name: '定时任务' }).click() } + await openList(); await expect(page.getByText('当前会话没有定时任务')).toBeVisible() + await page.getByRole('dialog').getByRole('button', { name: 'Close' }).click() + await page.getByRole('button', { name: '定时发送' }).click() + await page.getByLabel('发送时间(本地时区)').fill('2020-01-01T12:00') + await page.getByLabel('指令', { exact: true }).fill('合成调度指令') + await page.getByRole('button', { name: '创建定时任务', exact: true }).click() + await expect(page.getByText('发送时间必须在 60 秒至 365 天之后')).toBeVisible() + expect(created).toBe(false) + const localTime = await page.evaluate(() => { const date = new Date(Date.now() + 300_000); return new Date(date.getTime() - date.getTimezoneOffset() * 60_000).toISOString().slice(0, 16) }) + await page.getByLabel('发送时间(本地时区)').fill(localTime) + await page.getByRole('button', { name: '创建定时任务', exact: true }).click() + await expect(page.getByRole('dialog')).toBeHidden() + await openList(); const drawer = page.getByRole('dialog') + await expect(drawer.getByText('等待触发', { exact: true })).toBeVisible() + await drawer.getByRole('button', { name: /^取\s*消$/ }).click(); expect(cancelled).toBe(false) + await page.getByRole('button', { name: '确认取消', exact: true }).click() + await expect(drawer.getByText('已取消', { exact: true })).toBeVisible() +}) + +test('a saved schedule from a closed dialog cannot close the next draft', async ({ page }) => { + let release: () => void = () => {}, received = false + const pending = new Promise(resolve => { release = resolve }) + await page.route('**/api/v1/codex/sessions/ses-a/schedules', async route => { received = true; await pending; await route.fulfill({ json: { state: 'completed' } }) }) + await page.goto('/codex?session=ses-a') + await expect(page.getByRole('heading', { name: '测试会话', exact: true })).toBeVisible() + await page.getByRole('button', { name: '定时发送' }).click() + const localTime = await page.evaluate(() => { const date = new Date(Date.now() + 300_000); return new Date(date.getTime() - date.getTimezoneOffset() * 60_000).toISOString().slice(0, 16) }) + await page.getByLabel('发送时间(本地时区)').fill(localTime) + await page.getByLabel('指令', { exact: true }).fill('第一份调度') + await page.getByRole('button', { name: '创建定时任务', exact: true }).click() + await expect.poll(() => received).toBe(true) + await page.getByRole('dialog').getByRole('button', { name: 'Close' }).click() + await page.getByLabel('给 Codex 发送指令').fill('下一份调度草稿') + await page.getByRole('button', { name: '定时发送' }).click() + const saved = page.waitForResponse(response => response.url().endsWith('/ses-a/schedules')) + release(); await saved; await page.waitForTimeout(100) + await expect(page.getByRole('dialog')).toBeVisible() + await expect(page.getByLabel('指令', { exact: true })).toHaveValue('下一份调度草稿') +}) diff --git a/farhelm-console/package.json b/farhelm-console/package.json index bcd96cb..04b9087 100644 --- a/farhelm-console/package.json +++ b/farhelm-console/package.json @@ -1,7 +1,7 @@ { "name": "farhelm-console", "private": true, - "version": "0.8.0", + "version": "0.9.0", "type": "module", "packageManager": "pnpm@10.17.1", "engines": { @@ -19,6 +19,8 @@ "dependencies": { "@ant-design/icons": "6.3.4", "@ant-design/x": "2.9.0", + "@fontsource-variable/manrope": "5.3.0", + "@fontsource-variable/noto-sans-sc": "5.3.0", "@tanstack/react-query": "5.102.8", "@tanstack/react-virtual": "3.14.10", "antd": "6.6.2", diff --git a/farhelm-console/pnpm-lock.yaml b/farhelm-console/pnpm-lock.yaml index 6bf5c54..2a3ad28 100644 --- a/farhelm-console/pnpm-lock.yaml +++ b/farhelm-console/pnpm-lock.yaml @@ -14,6 +14,12 @@ importers: '@ant-design/x': specifier: 2.9.0 version: 2.9.0(antd@6.6.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@fontsource-variable/manrope': + specifier: 5.3.0 + version: 5.3.0 + '@fontsource-variable/noto-sans-sc': + specifier: 5.3.0 + version: 5.3.0 '@tanstack/react-query': specifier: 5.102.8 version: 5.102.8(react@19.2.8) @@ -809,6 +815,12 @@ packages: '@noble/hashes': optional: true + '@fontsource-variable/manrope@5.3.0': + resolution: {integrity: sha512-6D5dgokHsWDDMtmXHznKa0hK229NN+1a4BLPmUCLqcO1Pw5EEhWY5RFt0AcXnVRAljFFPfRtLkJePQj6LSsV6g==} + + '@fontsource-variable/noto-sans-sc@5.3.0': + resolution: {integrity: sha512-lNar1dF7Ik/lHNPo/7JWG0TolXY29LtsqYgMvEysooZ5bsO9uH4shJmRrwyJ3PjyTPljhpMJEK0jDuLSU4vJ1w==} + '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} @@ -4747,6 +4759,10 @@ snapshots: '@exodus/bytes@1.15.1': {} + '@fontsource-variable/manrope@5.3.0': {} + + '@fontsource-variable/noto-sans-sc@5.3.0': {} + '@humanfs/core@0.19.2': dependencies: '@humanfs/types': 0.15.0 diff --git a/farhelm-console/public/third-party-notices.txt b/farhelm-console/public/third-party-notices.txt index cc90c68..1664c02 100644 --- a/farhelm-console/public/third-party-notices.txt +++ b/farhelm-console/public/third-party-notices.txt @@ -11261,3 +11261,204 @@ agreement. 5.4. "Results" means any outcome obtained by computational analysis of Data, including for example machine learning models and models' insights. + +======================================================================== +@fontsource-variable/manrope 5.3.0 +License: OFL-1.1 +https://fontsource.org/fonts/manrope +Unmodified variable font files, self-hosted with Unicode ranges. + +Copyright 2019 The Manrope Project Authors (https://github.com/sharanda/manrope) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. + + +======================================================================== +@fontsource-variable/noto-sans-sc 5.3.0 +License: OFL-1.1 +https://fontsource.org/fonts/noto-sans-sc +Unmodified variable font files, self-hosted with Unicode ranges. + +Google Inc. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/farhelm-console/src/App.tsx b/farhelm-console/src/App.tsx index 160608e..7227e06 100644 --- a/farhelm-console/src/App.tsx +++ b/farhelm-console/src/App.tsx @@ -1,3 +1,4 @@ +import { version } from '../package.json' import { clearMarkdownCache } from './components/codex/markdownService' import { clearOperationReceipts } from './api/features' import { @@ -6,6 +7,7 @@ import { DashboardOutlined, DesktopOutlined, FileSearchOutlined, + LogoutOutlined, MenuOutlined, MoonOutlined, MoreOutlined, @@ -15,14 +17,16 @@ import { } from '@ant-design/icons' import { QueryClientProvider } from '@tanstack/react-query' import { queryClient, connectCodexCache } from './api/cache' -import { Button, ConfigProvider, Drawer, Grid, Layout, Menu, Space, Spin, Typography } from 'antd' -import { lazy, Suspense, useEffect, useState } from 'react' +import { connectMetadataCache } from './api/metadata' +import { Avatar, Button, ConfigProvider, Drawer, Grid, Layout, Menu, Space, Spin, Tooltip, Typography } from 'antd' +import { lazy, Suspense, useEffect, useMemo, useState } from 'react' import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom' import { AuditPage } from './components/AuditPage' import { SettingsPage } from './components/SettingsPage' import { LiveNotifications } from './components/LiveNotifications' -import type { ColorPreference } from './hooks/useColorMode' +import { ConnectionStatus } from './components/ConnectionStatus' +import type { AppearanceProps } from './components/AppearanceSettings' import { AgentListPage } from './components/AgentListPage' import { ExperimentPage } from './components/ExperimentPage' import { LoginPage } from './components/LoginPage' @@ -39,23 +43,23 @@ const CodexPage = lazy(() => import('./components/CodexPage').then((module) => ( const codexFallback =
正在加载 Codex 工作区…
const desktopItems = [ - { key: '/', icon: , label: '总览' }, - { key: '/agents', icon: , label: 'Agent' }, - { key: '/experiments', icon: , label: '实验' }, - { key: '/codex', icon: , label: 'Codex' }, - { key: '/notifications', icon: , label: '通知' }, - { key: '/audit', icon: , label: '审计' }, - { key: '/settings', icon: , label: '设置' }, + { key: '/', icon: , label: '总览' }, + { key: '/agents', icon: , label: 'Agent' }, + { key: '/experiments', icon: , label: '实验' }, + { key: '/codex', icon: , label: 'Codex' }, + { key: '/notifications', icon: , label: '通知' }, + { key: '/audit', icon: , label: '审计' }, + { key: '/settings', icon: , label: '设置' }, ] const mobileItems = [ - { key: '/', icon: , label: '总览' }, - { key: '/experiments', icon: , label: '实验' }, - { key: '/codex', icon: , label: 'Codex' }, - { key: '/more', icon: , label: '更多' }, + { key: '/', icon: , label: '总览' }, + { key: '/experiments', icon: , label: '实验' }, + { key: '/codex', icon: , label: 'Codex' }, + { key: '/more', icon: , label: '更多' }, ] -function FeatureRoutes({ csrf, preference, onPreference, onLogout }: { csrf: string; preference: ColorPreference; onPreference: (value: ColorPreference) => void; onLogout: () => void }) { +function FeatureRoutes({ csrf, onLogout, ...appearance }: AppearanceProps & { csrf: string; onLogout: () => void }) { const { health, refresh } = useHubHealth() const { agents, refresh: refreshAgents } = useAgents() const refreshOverview = () => { @@ -68,10 +72,10 @@ function FeatureRoutes({ csrf, preference, onPreference, onLogout }: { csrf: str } /> } /> } /> - } /> + } /> } /> } /> - } /> + } /> } /> ) @@ -83,16 +87,19 @@ function AppContent() { const navigate = useNavigate() const location = useLocation() const [drawerOpen, setDrawerOpen] = useState(false) - const { mode, preference, setPreference, toggleMode } = useColorMode() + const { mode, preference, setPreference, accent, setAccent, toggleMode } = useColorMode() + const appearanceTheme = useMemo(() => createTheme(mode, accent), [mode, accent]) const [session, setSession] = useState(undefined) useEffect(() => { void readSession().then(setSession).catch(() => setSession(null)) }, []) useEffect(() => { if (!session) { queryClient.clear(); clearMarkdownCache(); clearOperationReceipts(); return } - return connectCodexCache() + const codex = connectCodexCache(), metadata = connectMetadataCache() + return () => { codex(); metadata() } }, [session]) const mobileSelection = ['/agents', '/notifications', '/audit', '/settings'].includes(location.pathname) ? '/more' : location.pathname + const currentPage = desktopItems.find((item) => item.key === location.pathname)?.label ?? '控制台' const go = (key: string) => { if (key === '/more') { @@ -103,39 +110,46 @@ function AppContent() { setDrawerOpen(false) } - if (session === undefined) return
正在恢复安全会话…
- if (session === null) return + if (session === undefined) return
正在恢复安全会话…
+ if (session === null) return return ( - + {isDesktop && ( - +
-
FarHelm远程训练控制台
+ FarHelm
- go(key)} /> -
V0.8.0 · Codex workspace
+ +
V{version}
)}
- {!isDesktop && FarHelm} + {isDesktop ?
{desktopItems.slice(4).some((item) => item.key === location.pathname) ? '管理' : '工作空间'}{currentPage}
: FarHelm} + + } + + {isDesktop &&
- void logout(session.csrf_token).then(() => setSession(null))} /> + void logout(session.csrf_token).then(() => setSession(null))} />
{!isDesktop && ( diff --git a/farhelm-console/src/api/agents.ts b/farhelm-console/src/api/agents.ts index ea14046..1a69c55 100644 --- a/farhelm-console/src/api/agents.ts +++ b/farhelm-console/src/api/agents.ts @@ -4,6 +4,7 @@ export type AgentSummary = { agent_id: string hostname: string agent_version: string + capabilities?: string[] last_seen_unix: number online: boolean codex?: { state: 'ready' | 'unconfigured' | 'unavailable' | 'starting' | 'login_required'; version?: string; reason?: string } diff --git a/farhelm-console/src/api/cache.test.ts b/farhelm-console/src/api/cache.test.ts index 2bb8c9f..788a6ac 100644 --- a/farhelm-console/src/api/cache.test.ts +++ b/farhelm-console/src/api/cache.test.ts @@ -1,10 +1,28 @@ -import { afterEach, describe, expect, it } from 'vitest' -import { cacheHistory, cacheOperation, keys, mergeSession, queryClient } from './cache' -import type { CodexSession, TranscriptPage } from './features' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { QueryObserver, type InfiniteData } from '@tanstack/react-query' +import { cacheHistory, cacheOperation, connectCodexCache, keys, mergeSession, queryClient } from './cache' +import type { CodexSession, DisplayPage, TranscriptPage } from './features' -afterEach(() => queryClient.clear()) +const cleanup: (() => void)[] = [] +afterEach(() => { cleanup.splice(0).forEach((stop) => stop()); queryClient.clear(); vi.useRealTimers(); vi.unstubAllGlobals() }) const page = (id: string, text = '正文'): TranscriptPage => ({ session_id: id, turns: [{ turn_id: 't', status: 'completed', items: [{ item_id: 'i', kind: 'assistant_message', text }] }] }) +const session = (id: string, changes: Partial = {}): CodexSession => ({ session_id: id, agent_id: 'a', project_id: 'p', mode: 'edit', state: 'idle', revision: 1, updated_at_unix: 1, ...changes }) +const listKey = (agent = 'a', project = 'p', archived = 'false', search = '') => ['codex', 'sessions', archived, agent, project, search] +const listing = (...sessions: CodexSession[]): InfiniteData => ({ pages: [{ sessions, incomplete_agents: [] }], pageParams: [undefined] }) +function stream() { + const sources: EventTarget[] = [] + vi.stubGlobal('EventSource', class extends EventTarget { constructor() { super(); sources.push(this) } close() {} }) + cleanup.push(connectCodexCache()) + return (type: string, payload: object, lastEventId = '') => sources[0].dispatchEvent(new MessageEvent(type, { data: JSON.stringify({ event_id: `event-${lastEventId}`, payload }), lastEventId })) +} describe('shared workspace cache', () => { + it('keeps current native settings through older pages and clears stale settings on a fresh response', () => { + const context = { model: 'gpt-5.4', sandbox: 'danger-full-access' } + queryClient.setQueryData(keys.history('s'), { ...page('s'), context }) + expect(cacheHistory('s', { ...page('s'), context: { model: 'older-model' } }, true).context).toEqual(context) + expect(cacheHistory('s', { ...page('s'), context: {} }).context).toEqual({}) + expect(cacheHistory('other', page('other')).context).toBeUndefined() + }) it('retains the newest 20 inactive histories even when writes share a timestamp', () => { for (let i = 0; i < 25; i++) queryClient.setQueryData(keys.history(String(i)), page(String(i))) expect(queryClient.getQueryData(keys.history('0'))).toBeUndefined() @@ -27,4 +45,166 @@ describe('shared workspace cache', () => { cacheOperation('op', { command_id: 'op', state: 'completed' }); cacheOperation('op', { command_id: 'op', state: 'accepted' }) expect(queryClient.getQueryData(keys.operation('op'))).toMatchObject({ state: 'completed' }) }) + + it('does not refetch a live session list for unrelated Agent/project events', () => { + const key = listKey(), data = listing(session('visible')) + queryClient.setQueryData(key, data) + const fetch = vi.fn(async () => data) + cleanup.push(new QueryObserver(queryClient, { queryKey: key, queryFn: fetch }).subscribe(() => {})) + const emit = stream() + for (let index = 0; index < 20; index++) emit('codex.session.updated', session(`outside-${index}`, { agent_id: 'b', project_id: 'other' })) + expect(fetch).not.toHaveBeenCalled() + expect(queryClient.getQueryData(key)).toBe(data) + }) + + it('removes sessions that leave a scope and reconciles both destination and source', () => { + const source = listKey(), target = listKey('a', 'other'), archived = listKey('a', 'other', 'true') + queryClient.setQueryData(source, listing(session('moving'))) + queryClient.setQueryData(target, listing()) + queryClient.setQueryData(archived, listing()) + const emit = stream() + emit('codex.session.updated', session('moving', { project_id: 'other', revision: 2 })) + expect(queryClient.getQueryData>(source)?.pages[0].sessions).toEqual([]) + expect(queryClient.getQueryState(source)?.isInvalidated).toBe(true) + expect(queryClient.getQueryState(target)?.isInvalidated).toBe(true) + expect(queryClient.getQueryState(archived)?.isInvalidated).toBe(false) + queryClient.setQueryData(target, listing(session('moving', { project_id: 'other', revision: 2 }))) + emit('codex.session.updated', { session_id: 'moving', state: 'archived', revision: 3 }) + expect(queryClient.getQueryData>(target)?.pages[0].sessions).toEqual([]) + expect(queryClient.getQueryState(archived)?.isInvalidated).toBe(true) + }) + + it('keeps reconciliation for unknown scope and search preview membership', () => { + const ordinary = listKey(), search = listKey('a', 'p', 'false', '训练') + queryClient.setQueryData(ordinary, listing(session('known'))) + queryClient.setQueryData(search, listing(session('known'))) + const emit = stream() + emit('codex.session.updated', { session_id: 'unknown', state: 'running', revision: 2 }) + expect(queryClient.getQueryState(ordinary)?.isInvalidated).toBe(true) + queryClient.setQueryData(ordinary, listing(session('known'))) + queryClient.setQueryData(search, listing(session('known'))) + emit('codex.session.updated', { session_id: 'known', title: '新名称', revision: 2 }) + expect(queryClient.getQueryState(ordinary)?.isInvalidated).toBe(false) + expect(queryClient.getQueryState(search)?.isInvalidated).toBe(true) + }) + + it('keeps a migrated row out of its old scope when the reconciliation fails', async () => { + const key = listKey() + queryClient.setQueryData(key, listing(session('moving'))) + cleanup.push(new QueryObserver(queryClient, { queryKey: key, queryFn: async () => { throw new Error('offline') } }).subscribe(() => {})) + const emit = stream() + emit('codex.session.updated', session('moving', { state: 'archived', revision: 2 })) + await vi.waitFor(() => expect(queryClient.getQueryState(key)?.status).toBe('error')) + expect(queryClient.getQueryData>(key)?.pages[0].sessions).toEqual([]) + }) + + it('preserves metadata mode/state and rejects stale scope changes', () => { + const key = listKey('a', 'p', 'true'), current = session('known', { state: 'archived', revision: 5 }) + queryClient.setQueryData(keys.session('known'), current) + queryClient.setQueryData(key, listing(current)) + const emit = stream() + emit('codex.session.updated', { ...current, project_id: 'elsewhere', state: 'idle', revision: 4 }) + expect(queryClient.getQueryState(key)?.isInvalidated).toBe(false) + emit('codex.session.updated', { session_id: 'known', title: '正式名称', mode: 'inspect', state: 'idle', update_kind: 'metadata', revision: 6 }) + expect(queryClient.getQueryData>(key)?.pages[0].sessions[0]).toMatchObject({ state: 'archived', mode: 'edit', title: '正式名称' }) + expect(queryClient.getQueryState(key)?.isInvalidated).toBe(false) + }) + + it('flushes all queued fragments before a terminal event and rejects later text', () => { + vi.useFakeTimers() + const emit = stream(), data = (item_id: string, text_offset: number, delta: string) => ({ session_id: 's', data: { turn_id: 't', item_id, text_offset, delta } }) + emit('codex.message.delta', data('a', 3, '完成')) + emit('codex.message.delta', data('b', 0, '旁白')) + emit('codex.message.delta', data('a', 0, '你好🙂')) + expect(queryClient.getQueryData(keys.history('s'))).toBeUndefined() + emit('codex.turn.completed', { session_id: 's', data: { turn_id: 't' } }) + const saved = queryClient.getQueryData(keys.history('s'))! + expect(saved.turns[0].status).toBe('completed') + expect(saved.turns[0].items.map((item) => item.text)).toEqual(['你好🙂完成', '旁白']) + emit('codex.message.delta', data('a', 5, '迟到')) + vi.advanceTimersByTime(24) + expect(queryClient.getQueryData(keys.history('s'))).toBe(saved) + }) + + it('publishes a received delta batch once without losing any text', () => { + vi.useFakeTimers() + const emit = stream(), published = vi.fn() + cleanup.push(queryClient.getQueryCache().subscribe((event) => { if (event.type === 'updated' && event.query.queryKey[1] === 'history') published() })) + for (let index = 0; index < 100; index++) emit('codex.message.delta', { session_id: 's', data: { turn_id: 't', item_id: 'i', text_offset: index, delta: '字' } }) + expect(published).not.toHaveBeenCalled() + vi.advanceTimersByTime(24) + expect(published).toHaveBeenCalledOnce() + expect(queryClient.getQueryData(keys.history('s'))?.turns[0].items[0].text).toBe('字'.repeat(100)) + }) + + it.each(['reconnect', 'resync'])('reconciles archive membership once per %s without trusting raw replay', async (recovery) => { + vi.useFakeTimers() + const key = listKey(), archived = listKey('a', 'p', 'true'), labels = ['codex', 'labels', ['known']] + const original = session('known', { title: '正式名称', revision: 5 }) + queryClient.setQueryData(keys.session('known'), original) + queryClient.setQueryData(key, listing(original)) + queryClient.setQueryData(archived, listing()) + queryClient.setQueryData(labels, { sessions: [original], incomplete_agents: [] }) + let serverState: 'idle' | 'archived' = 'idle', revision = 5 + const fetch = vi.fn(async () => { + const data = listing(...(serverState === 'archived' ? [] : [session('known', { title: '正式名称', revision })])) + for (const row of data.pages[0].sessions) queryClient.setQueryData(keys.session(row.session_id), mergeSession(row)) + return data + }) + const fetchLabels = vi.fn(async () => ({ sessions: [session('known', { title: '正式名称', revision, state: serverState })], incomplete_agents: [] })) + cleanup.push(new QueryObserver(queryClient, { queryKey: key, queryFn: fetch }).subscribe(() => {})) + cleanup.push(new QueryObserver(queryClient, { queryKey: labels, queryFn: fetchLabels }).subscribe(() => {})) + const emit = stream() + emit('open', {}) + // Matches persisted Hub payload: event id is not the session projection revision. + const replay = (state: 'idle' | 'archived') => { + for (let index = 0; index < 20; index++) emit('codex.session.updated', { + update_kind: 'metadata', session_id: index % 2 ? 'unloaded' : 'known', project_id: 'p', mode: 'inspect', state, title: null, active_turn_id: null, updated_at_unix: 1, + }, String(10_000 + index)) + } + replay('archived') + await vi.advanceTimersByTimeAsync(50) + expect(fetch).not.toHaveBeenCalled() + expect(queryClient.getQueryData(keys.session('known'))).toBe(original) + expect(queryClient.getQueryData(keys.session('unloaded'))).toBeUndefined() + for (const state of ['archived', 'idle'] as const) { + serverState = state; revision++ + if (recovery === 'reconnect') { emit('error', {}); emit('open', {}) } + else emit('codex.stream.resync', {}) + // A reconnect and resync may describe the same recovery boundary. + emit('codex.stream.resync', {}) + replay(state) + const previousCalls = fetch.mock.calls.length + await vi.advanceTimersByTimeAsync(50) + expect(fetch).toHaveBeenCalledTimes(previousCalls + 1) + expect(fetchLabels).toHaveBeenCalledTimes(previousCalls + 1) + expect(queryClient.getQueryData>(key)?.pages[0].sessions.map((row) => row.session_id)).toEqual(state === 'archived' ? [] : ['known']) + expect(queryClient.getQueryState(archived)?.isInvalidated).toBe(true) + // Late raw replay does not create another read after the recovery batch. + replay(state) + await vi.advanceTimersByTimeAsync(100) + expect(fetch).toHaveBeenCalledTimes(previousCalls + 1) + } + expect(queryClient.getQueryData(keys.session('known'))).toMatchObject({ state: 'idle', title: '正式名称', revision: 7 }) + emit('codex.session.updated', session('known', { state: 'archived', revision: 6 })) + expect(queryClient.getQueryData(keys.session('known'))).toMatchObject({ state: 'idle', revision: 7 }) + expect(fetch).toHaveBeenCalledTimes(2) + }) + + it('retains visible cached membership when recovery cannot read the authoritative list', async () => { + vi.useFakeTimers() + const key = listKey(), original = session('known', { title: '正式名称', revision: 5 }), data = listing(original) + queryClient.setQueryData(key, data) + queryClient.setQueryData(keys.session('known'), original) + const fetch = vi.fn(async () => { throw new Error('Agent offline') }) + cleanup.push(new QueryObserver(queryClient, { queryKey: key, queryFn: fetch }).subscribe(() => {})) + const emit = stream() + emit('open', {}); emit('error', {}); emit('open', {}) + emit('codex.session.updated', { session_id: 'known', project_id: 'p', mode: 'inspect', state: 'archived', title: null }, '99999') + await vi.advanceTimersByTimeAsync(50) + expect(fetch).toHaveBeenCalledOnce() + expect(queryClient.getQueryState(key)?.status).toBe('error') + expect(queryClient.getQueryData(key)).toBe(data) + expect(queryClient.getQueryData(keys.session('known'))).toBe(original) + }) }) diff --git a/farhelm-console/src/api/cache.ts b/farhelm-console/src/api/cache.ts index 249c0e4..181c07d 100644 --- a/farhelm-console/src/api/cache.ts +++ b/farhelm-console/src/api/cache.ts @@ -1,7 +1,7 @@ import { QueryClient, notifyManager, type InfiniteData } from '@tanstack/react-query' import type { CodexSession, DisplayPage, Operation, TranscriptPage } from './features' import type { AgentListResponse, AgentSummary } from './agents' -import { mergeDelta, mergeTurns } from './transcript' +import { mergeDeltas, mergeTurns, type TranscriptDelta } from './transcript' import { subscribeEvents } from './events' export const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 30_000, gcTime: 5 * 60_000, retry: false, refetchOnWindowFocus: false, structuralSharing: false }, mutations: { retry: false } } }) @@ -21,7 +21,7 @@ export function cacheOperation(id: string, value: Operation) { } export function cacheHistory(id: string, page: TranscriptPage, older = false): TranscriptPage { const previous = queryClient.getQueryData(keys.history(id)) - return { ...page, ...(previous?.older_loaded && !older ? { next_cursor: previous.next_cursor, continuation: previous.continuation } : {}), older_loaded: older || previous?.older_loaded, turns: mergeTurns(previous?.turns ?? [], [...page.turns].reverse(), older) } + return { ...page, context: older ? previous?.context : page.context, ...(previous?.older_loaded && !older ? { next_cursor: previous.next_cursor, continuation: previous.continuation } : {}), older_loaded: older || previous?.older_loaded, turns: mergeTurns(previous?.turns ?? [], [...page.turns].reverse(), older) } } // Estimate retained strings without serializing megabyte bodies on the UI thread. @@ -44,7 +44,11 @@ queryClient.getQueryCache().subscribe((event) => { function updateSession(payload: Partial & { update_kind?: string }) { const id = payload.session_id - if (!id) return + // Durable replay contains raw Agent events, not the versioned live projection. + // Recovery reconciles lists once; replay cannot overwrite or refetch per row. + if (!id || !Number.isSafeInteger(payload.revision) || payload.revision! < 0) return + const known = queryClient.getQueryData(keys.session(id)) + if (known && (known.revision ?? 0) > (payload.revision ?? 0)) return const patch = (old?: CodexSession): CodexSession | undefined => { if (old && (old.revision ?? 0) > (payload.revision ?? 0)) return old if (!old && (!payload.agent_id || !payload.project_id || !payload.mode)) return old @@ -57,11 +61,21 @@ function updateSession(payload: Partial & { update_kind?: string } for (const query of queryClient.getQueryCache().findAll({ queryKey: ['codex', 'sessions'] })) { const old = query.state.data as InfiniteData | undefined if (!old) continue - const existing = old.pages.flatMap((page) => page.sessions).find((row) => row.session_id === id) - if (!existing || (existing.state === 'archived') !== (payload.state === 'archived')) { - // A membership change needs one paginated reconciliation, not a refresh per delta. - void queryClient.invalidateQueries({ queryKey: query.queryKey, exact: true }) - } else queryClient.setQueryData(query.queryKey, { ...old, pages: old.pages.map((page) => ({ ...page, sessions: page.sessions.map((row) => row.session_id === id ? patch(row)! : row) })) }) + const existing = old.pages.find((page) => page.sessions.some((row) => row.session_id === id))?.sessions.find((row) => row.session_id === id) + if (existing && (existing.revision ?? 0) > (payload.revision ?? 0)) continue + const value = patch(existing) ?? queryClient.getQueryData(keys.session(id)) ?? payload + const [, , archived, agent, project, search] = query.queryKey + // Missing metadata is unknown, not evidence that the event belongs elsewhere. + const outside = (agent && value.agent_id && agent !== value.agent_id) || (project && value.project_id && project !== value.project_id) + || (value.state && ((archived === 'true' && value.state !== 'archived') || (archived === 'false' && value.state === 'archived'))) + if (!existing && outside) continue + if (existing) { + queryClient.setQueryData(query.queryKey, { ...old, pages: old.pages.map((page) => !page.sessions.some((row) => row.session_id === id) ? page : { + ...page, sessions: outside ? page.sessions.filter((row) => row.session_id !== id) : page.sessions.map((row) => row.session_id === id ? value : row), + }) }) + } + // Search also depends on Agent-only preview text, so its membership needs a read. + if (!existing || outside || search) void queryClient.invalidateQueries({ queryKey: query.queryKey, exact: true }) } } @@ -71,11 +85,15 @@ export function connectCodexCache() { const refresh = new Map>() const reconciled = new Set() let frame: ReturnType | undefined - const deltas = new Map[1][]>() + let listingRefresh: ReturnType | undefined + const deltas = new Map() const flush = () => { clearTimeout(frame); frame = undefined notifyManager.batch(() => { - for (const [id, chunks] of deltas) queryClient.setQueryData(keys.history(id), (old) => ({ session_id: id, ...old, turns: chunks.reduce(mergeDelta, old?.turns ?? []) })) + for (const [id, chunks] of deltas) queryClient.setQueryData(keys.history(id), (old) => { + const turns = mergeDeltas(old?.turns ?? [], chunks) + return old?.turns === turns ? old : { session_id: id, ...old, turns } + }) deltas.clear() }) } @@ -83,6 +101,13 @@ export function connectCodexCache() { if (refresh.has(id)) return refresh.set(id, setTimeout(() => { refresh.delete(id); void queryClient.invalidateQueries({ queryKey: keys.history(id), exact: true, refetchType: 'active' }) }, 50)) } + const reconcileLists = () => { + listingRefresh ??= setTimeout(() => { + listingRefresh = undefined + // Also stale inactive scopes so archive/search switches do not reuse old membership. + void queryClient.invalidateQueries({ queryKey: ['codex'], predicate: (query) => ['sessions', 'labels'].includes(String(query.queryKey[1])), refetchType: 'active' }) + }, 50) + } const reconcileTerminal = (session: string, operation?: string, turn?: string) => { const identities = [...(operation ? [`operation:${operation}`] : []), ...(turn ? [`turn:${session}:${turn}`] : [])] const seen = identities.some((identity) => reconciled.has(identity)) @@ -94,7 +119,8 @@ export function connectCodexCache() { if (event.type === 'open' || event.type === 'codex.stream.resync') { if (opened || event.type === 'codex.stream.resync') { flush() - for (const query of queryClient.getQueryCache().findAll({ queryKey: ['codex'], type: 'active' })) if (['history', 'session', 'operation'].includes(String(query.queryKey[1]))) void queryClient.invalidateQueries({ queryKey: query.queryKey, exact: true }) + reconcileLists() + for (const query of queryClient.getQueryCache().findAll({ queryKey: ['codex'], type: 'active' })) if (['history', 'session', 'operation', 'schedules'].includes(String(query.queryKey[1]))) void queryClient.invalidateQueries({ queryKey: query.queryKey, exact: true }) } opened = true; return } @@ -117,12 +143,17 @@ export function connectCodexCache() { else if (payload.session_id) { const id = payload.session_id; const data = payload.data if (event.type === 'codex.message.delta' && data?.turn_id && data.item_id && typeof data.delta === 'string' && Number.isSafeInteger(data.text_offset) && data.text_offset! >= 0) { - deltas.set(id, [...(deltas.get(id) ?? []), { turn_id: data.turn_id, item_id: data.item_id, delta: data.delta, text_offset: data.text_offset! }]) + const chunks = deltas.get(id) ?? [] + chunks.push({ turn_id: data.turn_id, item_id: data.item_id, delta: data.delta, text_offset: data.text_offset! }) + deltas.set(id, chunks) frame ??= setTimeout(flush, 24) } else { flush() const state = event.type === 'codex.turn.started' ? 'running' : event.type.split('.').at(-1)! if (payload.operation_id) cacheOperation(payload.operation_id, { command_id: payload.operation_id, state, data: { turn_id: data?.turn_id, session_id: id } }) + const context = queryClient.getQueryData(keys.history(id))?.context + // A first resume confirms the previously unknown native permission profile. + if (state === 'running' && context && !context.sandbox) refreshHistory(id) if (terminal(state)) { queryClient.setQueryData(keys.history(id), (old) => old && ({ ...old, turns: old.turns.map((turn) => turn.turn_id === data?.turn_id ? { ...turn, status: state } : turn) })) reconcileTerminal(id, payload.operation_id, data?.turn_id) @@ -131,5 +162,5 @@ export function connectCodexCache() { } } catch { /* Unknown frames never create anonymous transcript content. */ } }) - return () => { off(); clearTimeout(frame); for (const timer of refresh.values()) clearTimeout(timer); deltas.clear() } + return () => { off(); clearTimeout(frame); clearTimeout(listingRefresh); for (const timer of refresh.values()) clearTimeout(timer); deltas.clear() } } diff --git a/farhelm-console/src/api/events.test.ts b/farhelm-console/src/api/events.test.ts new file mode 100644 index 0000000..ba8c890 --- /dev/null +++ b/farhelm-console/src/api/events.test.ts @@ -0,0 +1,35 @@ +import { expect, test, vi } from 'vitest' +import { readEventConnection, subscribeEventConnection, subscribeEvents } from './events' + +test('connection status shares the live stream and resets after the authenticated page closes', () => { + const sources: Source[] = [] + class Source extends EventTarget { + closed = false + constructor() { super(); sources.push(this) } + close() { this.closed = true } + } + vi.stubGlobal('EventSource', Source) + const business = vi.fn(), changed = vi.fn() + const unsubscribeBusiness = subscribeEvents(['codex.message.delta'], business) + const unsubscribeStatus = subscribeEventConnection(changed) + try { + expect(sources).toHaveLength(1) + expect(readEventConnection()).toBe('connecting') + sources[0].dispatchEvent(new Event('open')) + expect(readEventConnection()).toBe('connected') + sources[0].dispatchEvent(new MessageEvent('codex.message.delta', { data: '{}' })) + expect(business).toHaveBeenCalledOnce() + sources[0].dispatchEvent(new Event('error')) + expect(readEventConnection()).toBe('reconnecting') + sources[0].dispatchEvent(new Event('open')) + expect(readEventConnection()).toBe('connected') + expect(changed).toHaveBeenCalledTimes(3) + unsubscribeStatus() + expect(sources[0].closed).toBe(false) + unsubscribeBusiness() + expect(sources[0].closed).toBe(true) + expect(readEventConnection()).toBe('connecting') + } finally { + unsubscribeStatus(); unsubscribeBusiness(); vi.unstubAllGlobals() + } +}) diff --git a/farhelm-console/src/api/events.ts b/farhelm-console/src/api/events.ts index e1ee4e8..2e88ba4 100644 --- a/farhelm-console/src/api/events.ts +++ b/farhelm-console/src/api/events.ts @@ -3,8 +3,16 @@ type Listener = (event: MessageEvent) => void const listeners = new Map>() let stream: EventSource | undefined const dispatchers = new Map() +let connectionState: 'connecting' | 'connected' | 'reconnecting' = 'connecting' +export function readEventConnection() { return connectionState } +export function subscribeEventConnection(listener: () => void) { return subscribeEvents(['open', 'error'], listener) } export function subscribeEvents(names: string[], listener: Listener): () => void { - stream ??= new EventSource('/api/v1/events/stream') + if (!stream) { + connectionState = 'connecting' + stream = new EventSource('/api/v1/events/stream') + stream.addEventListener('open', () => { connectionState = 'connected' }) + stream.addEventListener('error', () => { connectionState = 'reconnecting' }) + } for (const name of names) { if (!listeners.has(name)) { listeners.set(name, new Set()) @@ -21,7 +29,7 @@ export function subscribeEvents(names: string[], listener: Listener): () => void return () => { for (const name of names) listeners.get(name)?.delete(listener) if ([...listeners.values()].every((set) => set.size === 0)) { - stream?.close(); stream = undefined; listeners.clear(); dispatchers.clear() + stream?.close(); stream = undefined; connectionState = 'connecting'; listeners.clear(); dispatchers.clear() } } } diff --git a/farhelm-console/src/api/features.ts b/farhelm-console/src/api/features.ts index f5a4f57..6024def 100644 --- a/farhelm-console/src/api/features.ts +++ b/farhelm-console/src/api/features.ts @@ -8,7 +8,8 @@ export type SessionState = 'creating' | 'idle' | 'queued' | 'running' | 'interru export type CodexSession = { session_id: string; agent_id: string; project_id: string; mode: 'inspect' | 'edit'; state: SessionState; title?: string; display_label?: string; active_turn_id?: string; updated_at_unix: number; revision?: number } export type TranscriptItem = { item_id: string; kind: 'user_message' | 'assistant_message' | 'command_summary' | 'file_change_summary' | 'error'; text: string; text_offset?: number; text_complete?: boolean; status?: string; exit_code?: number; duration_ms?: number; streaming?: boolean } export type TranscriptTurn = { turn_id: string; status: string; started_at_unix?: number; completed_at_unix?: number; items: TranscriptItem[] } -export type TranscriptPage = { older_loaded?: boolean; protocol?: string; session_id: string; turns: TranscriptTurn[]; next_cursor?: string; continuation?: { kind: 'message' | 'history'; turn_id: string; item_id: string; text_offset: number } } +export type SessionContext = { model?: string; reasoning_effort?: string; sandbox?: string; approval_policy?: string; approvals_reviewer?: string } +export type TranscriptPage = { older_loaded?: boolean; protocol?: string; session_id: string; turns: TranscriptTurn[]; context?: SessionContext; next_cursor?: string; continuation?: { kind: 'message' | 'history'; turn_id: string; item_id: string; text_offset: number } } export type DisplayPage = { sessions: CodexSession[]; next_cursor?: string; incomplete_agents: { agent_id: string; reason: string }[] } export async function fetchSessionDisplay(csrf: string, request: { mode: 'labels' | 'search'; session_ids?: string[]; query?: string; agent_id?: string; project_id?: string; archived?: 'false' | 'true' | 'all'; cursor?: string }, signal?: AbortSignal): Promise { const response = await fetch('/api/v1/codex/session-display', { method: 'POST', credentials: 'same-origin', signal, headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf }, body: JSON.stringify(request) }) @@ -50,9 +51,9 @@ export async function fetchTranscript(sessionId: string, cursor?: string, signal return value } -export async function fetchSchedules(sessionId?: string): Promise { +export async function fetchSchedules(sessionId?: string, signal?: AbortSignal): Promise { const suffix = sessionId ? `?session=${encodeURIComponent(sessionId)}` : '' - const value = await json<{ protocol: string; schedules: CodexSchedule[] }>(`/api/v1/codex/schedules${suffix}`) + const value = await json<{ protocol: string; schedules: CodexSchedule[] }>(`/api/v1/codex/schedules${suffix}`, signal) if (value.protocol !== PROTOCOL_VERSION || !Array.isArray(value.schedules)) throw new Error('Hub returned invalid schedules') return value.schedules } @@ -73,7 +74,7 @@ export class ApiError extends Error { } async function apiError(response: Response) { const value = await response.json().catch(() => ({})) as { error?: string } - const errors: Record = { operation_expired: '这次操作已过期,草稿已保留;核对状态后可修改指令重新提交', operation_failed: '这次操作已失败,草稿已保留;请先核对执行结果', agent_offline: 'Agent 离线,连接恢复后重试', agent_upgrade_required: '请先升级 Agent 至 V0.8.0', agent_save_unconfirmed: '尚未确认 Agent 保存,重试会核对同一次操作', invalid_schedule_time: '时间必须在 60 秒至 365 天之间', session_is_not_running: '当前会话已没有活动对话', visible_turn_changed: '活动对话已改变,请刷新后再操作', idempotency_conflict: '操作身份与之前的请求冲突' } + const errors: Record = { operation_expired: '这次操作已过期,草稿已保留;核对状态后可修改指令重新提交', operation_failed: '这次操作已失败,草稿已保留;请先核对执行结果', agent_offline: 'Agent 离线,连接恢复后重试', agent_upgrade_required: '请先升级 Agent 至 V0.9.0', agent_save_unconfirmed: '尚未确认 Agent 保存,重试会核对同一次操作', invalid_schedule_time: '时间必须在 60 秒至 365 天之间', session_is_not_running: '当前会话已没有活动对话', visible_turn_changed: '活动对话已改变,请刷新后再操作', idempotency_conflict: '操作身份与之前的请求冲突' } return new ApiError(errors[value.error ?? ''] ?? (response.status === 401 ? '登录已过期,请重新登录' : `请求失败(HTTP ${response.status})${value.error ? `:${value.error}` : ''}`), value.error ?? 'request_failed', response.status) } export async function mutate(url: string, csrf: string, body?: unknown, method = 'POST', operationId?: string): Promise { @@ -125,8 +126,8 @@ export async function waitForCommand(operation: Operation): Promise { } } -export function createSession(csrf: string, agentId: string, projectId: string, mode: 'inspect' | 'edit') { - return mutate('/api/v1/codex/sessions', csrf, { agent_id: agentId, project_id: projectId, mode }) +export function createSession(csrf: string, agentId: string, projectId: string, mode: 'inspect' | 'edit', inheritPermissions = false) { + return mutate('/api/v1/codex/sessions', csrf, { agent_id: agentId, project_id: projectId, mode, ...(inheritPermissions ? { inherit_permissions: true } : {}) }) } export function sendMessage(csrf: string, sessionId: string, prompt: string, delivery: 'queue' | 'steer', turnId?: string, operationId?: string) { return mutate(`/api/v1/codex/sessions/${encodeURIComponent(sessionId)}/messages`, csrf, { prompt, delivery, ...(delivery === 'steer' ? { turn_id: turnId } : {}) }, 'POST', operationId) diff --git a/farhelm-console/src/api/metadata.ts b/farhelm-console/src/api/metadata.ts new file mode 100644 index 0000000..9bc5c00 --- /dev/null +++ b/farhelm-console/src/api/metadata.ts @@ -0,0 +1,33 @@ +import { queryClient } from './cache' +import { subscribeEvents } from './events' + +// Metadata pages share the authenticated SSE connection and retain their loaded pages. +export function connectMetadataCache() { + const pending = new Set() + let timer: ReturnType | undefined + let opened = false + const queue = (...keys: string[]) => { + keys.forEach(key => pending.add(key)) + timer ??= setTimeout(() => { + timer = undefined + for (const key of pending) { + if (key.includes('/') && pending.has(key.split('/')[0])) continue + void queryClient.invalidateQueries({ queryKey: key.split('/') }) + } + pending.clear() + }, 50) + } + const off = subscribeEvents(['open', 'notification.created', 'notification.changed', 'experiment.updated', 'experiment.reported', 'command.updated', 'project.updated'], event => { + if (event.type === 'open') { if (opened) queue('notifications', 'experiment-runs', 'audit'); opened = true } + else if (event.type === 'notification.created') queue('notifications/list', 'audit') + else if (event.type === 'notification.changed') queue('notifications', 'audit') + else if (event.type.startsWith('experiment.')) queue('experiment-runs', 'audit') + else queue('audit') + }) + return () => { off(); clearTimeout(timer) } +} + +export function uniqueRows(rows: T[], identity: (row: T) => string | number): T[] { + const seen = new Set() + return rows.filter(row => { const key = identity(row); if (seen.has(key)) return false; seen.add(key); return true }) +} diff --git a/farhelm-console/src/api/notifications.ts b/farhelm-console/src/api/notifications.ts index 7bca95a..b51b9b8 100644 --- a/farhelm-console/src/api/notifications.ts +++ b/farhelm-console/src/api/notifications.ts @@ -3,12 +3,13 @@ export type Notice = { id: number; event_id: string; agent_id: string; category: export type NoticePage = { notifications: Notice[]; next_cursor?: number; unread_count: number; latest_id: number; preferences?: { experiments: boolean; codex: boolean } } export type Device = { id: string; name: string; experiments: boolean; codex: boolean } export type Delivery = { device_id: string; state: string; attempts: number; last_error?: string } -export function notices(params = new URLSearchParams()) { return json(`/api/v1/notifications?${params}`) } -export function noticeDetail(id: number) { return json<{ notification: Notice; deliveries: Delivery[] }>(`/api/v1/notifications/${id}`) } +export function notices(params = new URLSearchParams(), signal?: AbortSignal) { return json(`/api/v1/notifications?${params}`, signal) } +export type NoticeDetail = { notification: Notice; deliveries: Delivery[] } +export function noticeDetail(id: number, signal?: AbortSignal) { return json(`/api/v1/notifications/${id}`, signal) } export function markRead(csrf: string, id: number) { return mutate(`/api/v1/notifications/${id}/read`, csrf) } export function markAllRead(csrf: string, through: number) { return mutate('/api/v1/notifications/read-all', csrf, { through_id: through }) } export function devices() { return json<{ devices: Device[] }>('/api/v1/push/devices') } export function updateDevice(csrf: string, device: Device) { return mutate(`/api/v1/push/devices/${device.id}`, csrf, device) } export function removeDevice(csrf: string, id: string) { return mutate(`/api/v1/push/devices/${id}`, csrf, undefined, 'DELETE') } export function testDevice(csrf: string, id: string) { return mutate(`/api/v1/push/devices/${id}/test`, csrf) } -export const stateLabel = (state: string) => ({ succeeded: '已完成', failed: '失败', unknown: '结果未知', watching: '观察中', pending: '待投递', accepted: '推送服务已接受', expired: '已过期' })[state] ?? state +export const stateLabel = (state: string) => ({ succeeded: '已完成', failed: '失败', unknown: '结果未知', watching: '观察中', cancelled: '已取消', pending: '待投递', accepted: '推送服务已接受', expired: '已过期' })[state] ?? state diff --git a/farhelm-console/src/api/transcript.test.ts b/farhelm-console/src/api/transcript.test.ts new file mode 100644 index 0000000..6d1a70b --- /dev/null +++ b/farhelm-console/src/api/transcript.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest' +import type { TranscriptTurn } from './features' +import { mergeDeltas, mergeTurns, type TranscriptDelta } from './transcript' + +const delta = (turn_id: string, item_id: string, text_offset: number, text: string): TranscriptDelta => ({ turn_id, item_id, text_offset, delta: text }) +const serial = (current: TranscriptTurn[], chunks: TranscriptDelta[]) => chunks.reduce((turns, chunk) => mergeTurns(turns, [{ + turn_id: chunk.turn_id, status: 'inProgress', items: [{ item_id: chunk.item_id, kind: 'assistant_message', text: chunk.delta, text_offset: chunk.text_offset, text_complete: false, streaming: true }], +}]), current) + +describe('batched transcript deltas', () => { + it('preserves interleaved turn/item identity, Unicode gaps, overlaps and duplicate order', () => { + const chunks = [ + delta('a', 'answer', 3, '完成'), delta('b', 'answer', 0, '独立'), + delta('a', 'second', 0, '旁白'), delta('a', 'answer', 0, '你好🙂'), + delta('b', 'answer', 2, '输出'), delta('a', 'answer', 3, '完成'), + delta('a', 'answer', 2, '🙂完成!'), delta('a', 'answer', 8, '末'), + delta('a', 'answer', 6, '补齐'), + ] + const result = mergeDeltas([], chunks) + expect(result).toEqual(serial([], chunks)) + expect(result.map((turn) => turn.turn_id)).toEqual(['a', 'b']) + expect(result[0].items.map((item) => item.text)).toEqual(['你好🙂完成!补齐末', '旁白']) + expect(result[1].items[0].text).toBe('独立输出') + for (let split = 0; split <= chunks.length; split++) expect(mergeDeltas(mergeDeltas([], chunks.slice(0, split)), chunks.slice(split))).toEqual(result) + }) + + it('does not mutate cached turns and retains unaffected turn/item references', () => { + const current: TranscriptTurn[] = Array.from({ length: 2000 }, (_, index) => ({ turn_id: `t${index}`, status: 'inProgress', items: [ + { item_id: 'user', kind: 'user_message', text: '问题' }, { item_id: 'answer', kind: 'assistant_message', text: '开始🙂', text_offset: 0, text_complete: false }, + ] })) + for (const turn of current) { turn.items.forEach(Object.freeze); Object.freeze(turn.items); Object.freeze(turn) } + Object.freeze(current) + const result = mergeDeltas(current, [delta('t1999', 'answer', 3, '完成'), delta('t1999', 'answer', 5, '!')]) + expect(result[1999].items[1].text).toBe('开始🙂完成!') + expect(current[1999].items[1].text).toBe('开始🙂') + expect(result[1999].items[0]).toBe(current[1999].items[0]) + expect(result[1999]).not.toBe(current[1999]) + for (let index = 0; index < 1999; index++) expect(result[index]).toBe(current[index]) + }) + + it('keeps authoritative history/continuations and ignores late terminal deltas', () => { + const streamed = mergeDeltas([], [delta('t', 'answer', 3, '结尾'), delta('t', 'answer', 0, '前缀🙂')]) + const bounded: TranscriptTurn[] = [{ turn_id: 't', status: 'completed', items: [{ item_id: 'answer', kind: 'assistant_message', text: '前缀🙂', text_offset: 0, text_complete: false }] }] + const refreshed = mergeTurns(streamed, bounded) + const completed = mergeTurns(refreshed, [{ turn_id: 't', status: 'completed', items: [{ item_id: 'answer', kind: 'assistant_message', text: '结尾', text_offset: 3, text_complete: true }] }], true) + const late = [delta('t', 'answer', 5, '不能追加'), delta('t', 'other', 0, '不能创建')] + expect(completed[0].items[0]).toMatchObject({ text: '前缀🙂结尾', text_complete: true }) + expect(mergeDeltas(completed, late)).toBe(completed) + expect(mergeDeltas(completed, late)).toEqual(serial(completed, late)) + expect(mergeDeltas(completed, [])).toBe(completed) + }) + + it('retains a complete item while accepting another item in the same active turn', () => { + const current: TranscriptTurn[] = [{ turn_id: 'active', status: 'inProgress', items: [{ item_id: 'done', kind: 'assistant_message', text: '已完成', text_complete: true }] }] + const chunks = [delta('active', 'done', 3, '迟到'), delta('active', 'next', 0, ''), delta('active', 'next', 0, '继续🙂')] + const result = mergeDeltas(current, chunks) + expect(result).toEqual(serial(current, chunks)) + expect(result[0].items[0]).toBe(current[0].items[0]) + expect(result[0].items[1].text).toBe('继续🙂') + }) +}) diff --git a/farhelm-console/src/api/transcript.ts b/farhelm-console/src/api/transcript.ts index 638db8e..4518241 100644 --- a/farhelm-console/src/api/transcript.ts +++ b/farhelm-console/src/api/transcript.ts @@ -38,6 +38,39 @@ export function mergeTurns(current: TranscriptTurn[], incoming: TranscriptTurn[] return [...new Set(order.map((turn) => turn.turn_id))].map((id) => merged.get(id)!) } -export function mergeDelta(current: TranscriptTurn[], data: { turn_id: string; item_id: string; delta: string; text_offset: number }): TranscriptTurn[] { - return mergeTurns(current, [{ turn_id: data.turn_id, status: 'inProgress', items: [{ item_id: data.item_id, kind: 'assistant_message', text: data.delta, text_offset: data.text_offset, text_complete: false, streaming: true }] }]) +export type TranscriptDelta = { turn_id: string; item_id: string; delta: string; text_offset: number } + +/** Apply one received batch in order, copying only the turns/items that it changes. */ +export function mergeDeltas(current: TranscriptTurn[], chunks: readonly TranscriptDelta[]): TranscriptTurn[] { + if (!chunks.length) return current + const positions = new Map() + current.forEach((turn, index) => positions.set(turn.turn_id, index)) + const edited = new Map }>() + let result = current + for (const data of chunks) { + const position = positions.get(data.turn_id) + const previous = position === undefined ? undefined : result[position] + if (previous && terminal(previous.status)) continue + let edit = edited.get(data.turn_id) + const itemPosition = edit ? edit.items.get(data.item_id) : previous?.items.findIndex((item) => item.item_id === data.item_id) + const old = itemPosition !== undefined && itemPosition >= 0 ? previous?.items[itemPosition] : undefined + const item = mergeItem(old, { item_id: data.item_id, kind: 'assistant_message', text: data.delta, text_offset: data.text_offset, text_complete: false, streaming: true }) + if (old === item && previous?.status === 'inProgress') continue + if (!edit) { + const turn: TranscriptTurn = { ...previous, turn_id: data.turn_id, status: 'inProgress', items: previous ? [...previous.items] : [] } + edit = { turn, items: new Map(turn.items.map((value, index) => [value.item_id, index])) } + edited.set(data.turn_id, edit) + if (result === current) result = [...current] + if (position === undefined) { positions.set(data.turn_id, result.length); result.push(turn) } + else result[position] = turn + } + const index = edit.items.get(data.item_id) + if (index === undefined) { edit.items.set(data.item_id, edit.turn.items.length); edit.turn.items.push(item) } + else edit.turn.items[index] = item + } + return result +} + +export function mergeDelta(current: TranscriptTurn[], data: TranscriptDelta): TranscriptTurn[] { + return mergeDeltas(current, [data]) } diff --git a/farhelm-console/src/components/AppearanceSettings.tsx b/farhelm-console/src/components/AppearanceSettings.tsx new file mode 100644 index 0000000..ceb6318 --- /dev/null +++ b/farhelm-console/src/components/AppearanceSettings.tsx @@ -0,0 +1,50 @@ +import { ArrowUpOutlined } from '@ant-design/icons' +import { Button, Card, ColorPicker, Input, Segmented } from 'antd' +import { useState } from 'react' +import type { ColorPreference } from '../hooks/useColorMode' +import { accentColor, accentPresets, parseAccent, type AccentPreference } from '../theme' +import './appearance.css' + +export interface AppearanceProps { + preference: ColorPreference + onPreference: (value: ColorPreference) => void + accent: AccentPreference + onAccent: (value: AccentPreference) => void +} + +function CustomColor({ accent, onAccent }: Pick) { + const [hex, setHex] = useState(accentColor(accent)) + const parsed = parseAccent(hex) + const valid = parsed?.startsWith('#') === true + return
+ +
+ onAccent(color.toHexString().toUpperCase() as AccentPreference)}> + + + setHex(event.target.value)} onPressEnter={() => { if (valid && parsed) onAccent(parsed) }} /> + +
+

{valid ? '颜色会适配浅深模式,保持文字清晰。' : '请输入十六进制颜色,例如 #2563EB。'}

+
+} + +export function AppearanceSettings({ preference, onPreference, accent, onAccent }: AppearanceProps) { + return 仅此浏览器}> +
+
+
显示模式
+
主题色

用于主要按钮、选中状态、链接和焦点。Logo 保留品牌原色。

+ {accentPresets.map((preset) => )} +
+ +
+
+ 预览 +
检查一下训练结果
+
Codex

训练已完成。我们来看看结果。

查看实验详情 →
+
继续提问…
+
+
+
+} diff --git a/farhelm-console/src/components/AuditPage.tsx b/farhelm-console/src/components/AuditPage.tsx index 50a8a88..208d569 100644 --- a/farhelm-console/src/components/AuditPage.tsx +++ b/farhelm-console/src/components/AuditPage.tsx @@ -1,13 +1,17 @@ -import { Alert, Button, Empty, List, Space, Tag, Typography } from 'antd' -import { useCallback, useEffect, useState } from 'react' +import { Alert, Button, Empty, List, Skeleton, Space, Tag, Typography } from 'antd' +import { useInfiniteQuery } from '@tanstack/react-query' +import { useMemo } from 'react' import { json } from '../api/features' +import { uniqueRows } from '../api/metadata' type Entry = { id: number; action: string; target: string; outcome: string; created_at_unix: number } export function AuditPage() { - const [rows, setRows] = useState([]); const [cursor, setCursor] = useState(); const [error, setError] = useState(); const [busy, setBusy] = useState(false) - const load = useCallback(async (after?: number) => { - setBusy(true) - try { const page = await json<{ entries: Entry[]; next_cursor?: number }>(`/api/v1/audit${after ? `?cursor=${after}` : ''}`); setRows((old) => after ? [...old, ...page.entries] : page.entries); setCursor(page.next_cursor) } catch (e) { setError(e instanceof Error ? e.message : '审计不可用') } finally { setBusy(false) } - }, []) - useEffect(() => { const initial = setTimeout(() => void load(), 0); return () => clearTimeout(initial) }, [load]) - return
审计
{error && } }} renderItem={(row) => {row.action}{row.outcome}} description={`${row.target} · ${new Date(row.created_at_unix * 1000).toLocaleString()}`} />} />{cursor && }
+ const listing = useInfiniteQuery({ queryKey: ['audit'], initialPageParam: undefined as number | undefined, + queryFn: ({ pageParam, signal }) => json<{ entries: Entry[]; next_cursor?: number }>(`/api/v1/audit${pageParam ? `?cursor=${pageParam}` : ''}`, signal), + getNextPageParam: page => page.next_cursor ?? undefined, + }) + const rows = useMemo(() => uniqueRows(listing.data?.pages.flatMap(page => page.entries) ?? [], row => row.id), [listing.data]) + return
审计
{listing.error && void listing.refetch()}>重试} />} + {listing.isPending ?
正在读取审计记录…
: }} renderItem={row => {row.action}{row.outcome}} description={`${row.target} · ${new Date(row.created_at_unix * 1000).toLocaleString()}`} />} />} + {listing.hasNextPage && } +
} diff --git a/farhelm-console/src/components/CodexPage.tsx b/farhelm-console/src/components/CodexPage.tsx index 402f653..bf357c8 100644 --- a/farhelm-console/src/components/CodexPage.tsx +++ b/farhelm-console/src/components/CodexPage.tsx @@ -1,27 +1,32 @@ -import { ArrowLeftOutlined, CheckCircleOutlined, ClockCircleOutlined, CloseCircleOutlined, FolderOpenOutlined, LoadingOutlined, PlusOutlined, SearchOutlined } from '@ant-design/icons' -import { Alert, Button, Drawer, Empty, Input, Segmented, Select, Skeleton } from 'antd' +import { CloseOutlined, FilterOutlined, PlusOutlined, SearchOutlined } from '@ant-design/icons' +import { Alert, Button, Drawer, Empty, Input, Popover, Segmented, Select, Skeleton } from 'antd' import { useEffect, useMemo, useState } from 'react' -import { Conversations } from '@ant-design/x' +import { SessionGroups } from './codex/SessionGroups' import { useQuery } from '@tanstack/react-query' import { flushSync } from 'react-dom' -import { useNavigate, useSearchParams } from 'react-router-dom' +import { useSearchParams } from 'react-router-dom' import { fetchProjects, type CodexSession } from '../api/features' +import type { AgentSummary } from '../api/agents' import { Conversation } from './codex/Conversation' import { CreateDialog, ScheduleDialog, SchedulesDrawer } from './codex/Dialogs' -import { errorText, sessionName, stateNames } from './codex/presentation' +import { errorText } from './codex/presentation' import { useOperations } from './codex/useOperations' import { useSessions, type ArchiveFilter } from './codex/useSessions' +import './codex/workspace.css' +import './codex/markdown.css' -function StateIcon({ state }: { state: string }) { - return state === 'running' ? : ['failed', 'orphaned'].includes(state) ? : ['queued', 'creating'].includes(state) ? : +function ScopeFilter({ value, options, onChange }: { value?: string; options: { value: string; label: string }[]; onChange: (value?: string) => void }) { + const [open, setOpen] = useState(false) + return } placeholder="搜索全部会话" aria-label="搜索全部会话" value={query} onChange={(e) => setQuery(e.target.value)} /> - { setArchive(value as ArchiveFilter); select(undefined) }} options={[{ label: '当前', value: 'false' }, { label: '归档', value: 'true' }, { label: '全部', value: 'all' }]} /> - {projects.length > 0 && } suffix={{navigator.platform.includes('Mac') ? '⌘ K' : 'Ctrl K'}} placeholder="搜索全部会话" aria-label="搜索全部会话" value={query} onChange={(e) => setQuery(e.target.value)} /> +
{ setArchive(value as ArchiveFilter); select(undefined) }} options={[{ label: '当前', value: 'false' }, { label: '归档', value: 'true' }, { label: '全部', value: 'all' }]} />
+ {scoped &&
{scoped.label}
} {(sessions.error || projectError) && { void sessions.refresh(); if (projectError) void projectQuery.refetch() }}>重试} />} {sessions.incomplete.length > 0 &&
部分会话信息不可用:{sessions.incomplete.map((a) => `${a.agent_id}(${a.reason === 'agent_offline' ? '离线' : a.reason === 'agent_upgrade_required' ? '需要升级 Agent' : '读取失败'})`).join('、')}
}
{ if (['Enter', ' '].includes(event.key) && (event.target as HTMLElement).classList.contains('session-select')) { event.preventDefault(); (event.target as HTMLElement).click(); return } if (!['ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) return - const buttons = [...event.currentTarget.querySelectorAll('.session-select')]; const index = buttons.indexOf(document.activeElement as HTMLButtonElement); if (index < 0) return + const buttons = [...event.currentTarget.querySelectorAll('.session-select')].filter((button) => button.offsetParent !== null); const index = buttons.indexOf(document.activeElement as HTMLButtonElement); if (index < 0) return event.preventDefault(); const next = event.key === 'Home' ? 0 : event.key === 'End' ? buttons.length - 1 : Math.max(0, Math.min(buttons.length - 1, index + (event.key === 'ArrowDown' ? 1 : -1))); buttons[next]?.focus() - }}>{sessions.loading && !sessions.rows.length ? : groups.length ? !closedGroups[group]).map(([group]) => group), onExpand: (keys) => setClosedGroups((old) => ({ ...old, ...Object.fromEntries(groups.map(([group]) => [group, !keys.includes(group)])) })), label: (group) => { const rows = groups.find(([key]) => key === group)![1]; return } }} items={sessions.rows.map((s) => ({ key: s.session_id, group: `${s.agent_id} / ${s.project_id}`, className: 'session-row', label: }))} /> : !sessions.error && }{sessions.cursor && }
+ }}>{sessions.loading && !sessions.rows.length ? : sessions.rows.length ? : !sessions.error && }{sessions.cursor && } - return
{rail}
setRailOpen(false)} size={Math.min(320, window.innerWidth - 24)} closable={false}>{rail} - s.session_id === id)} draft={operations.get(id ?? '')} onDraft={(change) => { if (id) operations.update(id, change) }} onSend={(turn) => { if (id) void operations.send(id, turn) }} onRail={() => setRailOpen(true)} onCollapse={() => setCollapsed((old) => !old)} collapsed={collapsed} onSchedule={setSchedule} onSchedules={setSchedules} /> + return
{rail}
setRailOpen(false)} afterOpenChange={(open) => { if (!open && createAfterRail) { setCreateAfterRail(false); setCreating(true) } }} size={Math.min(320, window.innerWidth - 24)} closable={false}>{rail} + s.session_id === id)} draft={operations.get(id ?? '')} onDraft={(change) => { if (id) operations.update(id, change) }} onSend={(turn) => { if (id) void operations.send(id, turn) }} onRail={() => setRailOpen(true)} onCollapse={() => setCollapsed((old) => !old)} collapsed={collapsed} onSchedule={setSchedule} onSchedules={setSchedules} /> {creating && setCreating(false)} onCreated={(sessionId) => { if (sessionId) select(sessionId); void sessions.refresh() }} />} {schedule && setSchedule(undefined)} />} {schedules && setSchedules(undefined)} />} -
+ } diff --git a/farhelm-console/src/components/ConnectionStatus.tsx b/farhelm-console/src/components/ConnectionStatus.tsx new file mode 100644 index 0000000..2217de3 --- /dev/null +++ b/farhelm-console/src/components/ConnectionStatus.tsx @@ -0,0 +1,15 @@ +import { CloudSyncOutlined, LoadingOutlined } from '@ant-design/icons' +import { Tooltip } from 'antd' +import { useSyncExternalStore } from 'react' +import { readEventConnection, subscribeEventConnection } from '../api/events' + +export function ConnectionStatus() { + const state = useSyncExternalStore(subscribeEventConnection, readEventConnection) + const label = state === 'connected' ? '实时连接' : state === 'connecting' ? '正在连接' : '正在重连' + return + + {state === 'connected' ? : state === 'connecting' ? : } + {label} + + +} diff --git a/farhelm-console/src/components/ExperimentPage.tsx b/farhelm-console/src/components/ExperimentPage.tsx index 7aec62a..5f8f7f3 100644 --- a/farhelm-console/src/components/ExperimentPage.tsx +++ b/farhelm-console/src/components/ExperimentPage.tsx @@ -1,23 +1,30 @@ -import { Alert, Button, Card, Empty, List, Select, Space, Tag, Typography } from 'antd' -import { useCallback, useEffect, useRef, useState } from 'react' +import { Alert, Button, Card, Empty, List, Select, Skeleton, Space, Tag, Typography } from 'antd' +import { useInfiniteQuery } from '@tanstack/react-query' +import { useMemo, useState } from 'react' import { useNavigate, useSearchParams } from 'react-router-dom' import { json } from '../api/features' -import { subscribeEvents } from '../api/events' +import { uniqueRows } from '../api/metadata' import { stateLabel } from '../api/notifications' +import { useAgents } from '../hooks/useAgents' + type Run = { id: string; watch_id?: string; agent_id: string; project_id: string; name: string; pid?: number; source: 'pid_watch' | 'script_report'; state: string; message?: string; session_id?: string; followup_schedule_id?: string; updated_at_unix: number } export function ExperimentPage() { const navigate = useNavigate(); const [params] = useSearchParams(); const target = params.get('run') ?? params.get('watch') - const [items, setItems] = useState([]); const [cursor, setCursor] = useState(); const [source, setSource] = useState(''); const [state, setState] = useState(''); const [agent, setAgent] = useState(''); const [error, setError] = useState(); const [loading, setLoading] = useState(false); const generation = useRef(0) - const load = useCallback(async (after?: number) => { - const request = ++generation.current; setLoading(true) - const query = new URLSearchParams(); if (after) query.set('cursor', String(after)); if (state) query.set('state', state); if (agent) query.set('agent', agent); if (target) query.set('id', target); if (source) query.set('source', source) - try { const page = await json<{ experiments: Run[]; next_cursor?: number }>(`/api/v1/experiment-runs?${query}`); if (generation.current === request) { setItems((old) => after ? [...old, ...page.experiments] : page.experiments); setCursor(page.next_cursor); setError(undefined) } } catch (e) { setError(e instanceof Error ? e.message : '实验不可用') } finally { if (generation.current === request) setLoading(false) } - }, [agent, state, target, source]) - useEffect(() => { const initial = setTimeout(() => void load(), 0); const timer = setInterval(() => void load(), 15000); const off = subscribeEvents(['experiment.updated', 'experiment.reported', 'open'], () => void load()); return () => { clearTimeout(initial); clearInterval(timer); off() } }, [load]) - return
实验服务器本地登记的 PID 和脚本显式上报的结果。
- {error && } - {target && } ({ value, label: value ? stateLabel(value) : '全部状态' }))} /> ({ value: value.agent_id, label: value.hostname || value.agent_id }))]} /> + {listing.isPending ?
正在读取实验…
: JSON.stringify([item.agent_id, item.source, item.id])} dataSource={items} locale={{ emptyText: listing.error ? '实验暂时不可用,请重试' : }} renderItem={item => {item.name}{stateLabel(item.state)}{item.source === 'script_report' ? '脚本上报' : `PID ${item.pid}`}} description={{item.agent_id} · {item.project_id} · {new Date(item.updated_at_unix * 1000).toLocaleString()}{item.message && {item.message}}{item.session_id && }} />} />} + {listing.hasNextPage && }
} diff --git a/farhelm-console/src/components/LiveNotifications.tsx b/farhelm-console/src/components/LiveNotifications.tsx index 8f3ab40..7949579 100644 --- a/farhelm-console/src/components/LiveNotifications.tsx +++ b/farhelm-console/src/components/LiveNotifications.tsx @@ -11,7 +11,6 @@ export function LiveNotifications() { useQuery({ queryKey: ['notifications', 'latest'], queryFn: () => notices() }) useEffect(() => { const seen = new Set() - let opened = false const show = (item: Notice) => { const page = queryClient.getQueryData(['notifications', 'latest']) if (seen.has(item.id)) return @@ -21,9 +20,7 @@ export function LiveNotifications() { const enabled = item.category === 'test' || (item.category === 'codex' ? page.preferences?.codex !== false : page.preferences?.experiments !== false) if (enabled && Date.now() / 1000 - item.created_at_unix < 30 && document.visibilityState === 'visible') api.open({ key: String(item.id), title: `${item.title} · ${stateLabel(item.state)}`, description: item.agent_id, onClick: () => navigate(`/notifications?id=${item.id}`) }) } - const off = subscribeEvents(['open', 'notification.created', 'notification.changed'], (event) => { - if (event.type === 'open') { if (opened) void queryClient.invalidateQueries({ queryKey: ['notifications'] }); opened = true; return } - if (event.type === 'notification.changed') { void queryClient.invalidateQueries({ queryKey: ['notifications'] }); return } + const off = subscribeEvents(['notification.created'], (event) => { try { const { payload } = JSON.parse(event.data) as { payload: Notice }; if (Number.isSafeInteger(payload.id)) show(payload) } catch { /* Malformed notices are ignored. */ } }) return off diff --git a/farhelm-console/src/components/NotificationPage.tsx b/farhelm-console/src/components/NotificationPage.tsx index 1870e10..303665d 100644 --- a/farhelm-console/src/components/NotificationPage.tsx +++ b/farhelm-console/src/components/NotificationPage.tsx @@ -1,48 +1,61 @@ -import { Alert, Badge, Button, Drawer, Empty, List, Select, Space, Tag, Typography } from 'antd' -import { useCallback, useEffect, useRef, useState } from 'react' +import { Alert, Badge, Button, Drawer, Empty, List, Select, Skeleton, Space, Tag, Typography } from 'antd' +import { useInfiniteQuery, useMutation, useQuery } from '@tanstack/react-query' +import { useEffect, useMemo, useState } from 'react' import { useNavigate, useSearchParams } from 'react-router-dom' -import { subscribeEvents } from '../api/events' -import { markAllRead, markRead, noticeDetail, notices, stateLabel, type Delivery, type Notice, type NoticePage } from '../api/notifications' +import { queryClient } from '../api/cache' +import { uniqueRows } from '../api/metadata' +import { markAllRead, markRead, noticeDetail, notices, stateLabel, type NoticeDetail } from '../api/notifications' +import { useAgents } from '../hooks/useAgents' import { NotificationSettings } from './NotificationSettings' export function NotificationPage({ csrf }: { csrf: string }) { const navigate = useNavigate(); const [params, setParams] = useSearchParams() - const [page, setPage] = useState(); const [category, setCategory] = useState(''); const [state, setState] = useState(''); const [agent, setAgent] = useState(''); const [unread, setUnread] = useState(false) - const [detail, setDetail] = useState<{ notification: Notice; deliveries: Delivery[] }>(); const [error, setError] = useState(); const [busy, setBusy] = useState(false); const generation = useRef(0) - const load = useCallback(async (cursor?: number) => { - const current = ++generation.current; setBusy(true) - try { - const query = new URLSearchParams(); if (category) query.set('category', category); if (state) query.set('state', state); if (agent) query.set('agent', agent); if (unread) query.set('unread', 'true'); if (cursor) query.set('cursor', String(cursor)) - const result = await notices(query) - if (generation.current === current) setPage((previous) => cursor && previous ? { ...result, notifications: [...previous.notifications, ...result.notifications] } : result) - } catch (e) { setError(e instanceof Error ? e.message : '无法读取通知') } finally { if (generation.current === current) setBusy(false) } - }, [agent, category, state, unread]) - useEffect(() => { - const initial = setTimeout(() => void load(), 0); const timer = setInterval(() => void load(), 15000) - const off = subscribeEvents(['notification.changed', 'open', 'experiment.updated', 'experiment.reported', 'codex.turn.completed', 'codex.turn.failed', 'codex.turn.orphaned'], () => void load()) - return () => { clearTimeout(initial); clearInterval(timer); off() } - }, [load]) - const target = params.get('id') - useEffect(() => { - let cancelled = false - if (target) void noticeDetail(Number(target)).then(async (value) => { if (!cancelled) { setDetail(value); await markRead(csrf, value.notification.id); void load() } }).catch((e: unknown) => setError(e instanceof Error ? e.message : '通知不可用')) - return () => { cancelled = true } - }, [csrf, target, load]) - const markAll = async () => { try { await markAllRead(csrf, page?.latest_id ?? 0); await load() } catch (e) { setError(e instanceof Error ? e.message : '标记失败') } } + const [category, setCategory] = useState(''); const [state, setState] = useState(''); const [agent, setAgent] = useState(''); const [unread, setUnread] = useState(false) + const { agents } = useAgents() + const listing = useInfiniteQuery({ + queryKey: ['notifications', 'list', { category, state, agent, unread }], initialPageParam: undefined as number | undefined, + queryFn: ({ pageParam, signal }) => { + const query = new URLSearchParams(); if (category) query.set('category', category); if (state) query.set('state', state); if (agent) query.set('agent', agent); if (unread) query.set('unread', 'true'); if (pageParam) query.set('cursor', String(pageParam)) + return notices(query, signal) + }, + getNextPageParam: page => page.next_cursor ?? undefined, refetchInterval: 15_000, + }) + const items = useMemo(() => uniqueRows(listing.data?.pages.flatMap(page => page.notifications) ?? [], item => item.id), [listing.data]) + const page = listing.data?.pages[0] + const target = params.get('id'), id = Number(target) + const validTarget = !!target && Number.isSafeInteger(id) && id > 0 + const detail = useQuery({ queryKey: ['notifications', 'detail', id], enabled: validTarget, queryFn: ({ signal }) => noticeDetail(id, signal) }) + const read = useMutation({ + mutationFn: (target: number) => markRead(csrf, target), + onSuccess: (_, target) => { + queryClient.setQueryData(['notifications', 'detail', target], old => old && ({ ...old, notification: { ...old.notification, read_at_unix: Math.floor(Date.now() / 1000) } })) + void queryClient.invalidateQueries({ queryKey: ['notifications'] }) + }, + }) + const mark = read.mutate, detailId = detail.data?.notification.id, readAt = detail.data?.notification.read_at_unix + useEffect(() => { if (validTarget && detailId === id && !readAt) mark(id) }, [validTarget, id, detailId, readAt, mark]) + const all = useMutation({ mutationFn: (through: number) => markAllRead(csrf, through), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['notifications'] }) }) return
-
通知
- {error && } +
通知
+ {listing.error && void listing.refetch()}>重试} />} + {all.error && } ({ value, label: value ? stateLabel(value) : '全部结果' }))} /> - ({ value, label: value ? stateLabel(value) : '全部结果' }))} /> + p.state === 'approved').map((p) => ({ value: p.candidate_id, label: `${p.display_name} · ${p.agent_id}` }))} />只读编辑(隔离工作区) + try { + const result = await waitForCommand(await createSession(csrf, project.agent_id, project.suggested_project_id, values.isolated ? 'edit' : 'inspect', true)) + if (active.current) { onCreated(result.data?.session_id ?? result.result?.session_id); onClose() } + } catch (e) { if (active.current) setError(errorText(e)) } finally { if (active.current) setBusy(false) } + }}> + {error && } + : : <>{experiments.error && void experiments.refetch()}>重试} />}