feat(config): add recovery and health diagnostics
This commit is contained in:
parent
93e74120ac
commit
9ca78864a2
@ -21,6 +21,7 @@ This file is the operational contract for coding agents working in this reposito
|
||||
- Config load order: `~/.picobot/config.json` then fallback to `./config.json` (`Config::load_default` in `src/config/mod.rs`)
|
||||
- `.env` files use a custom parser, not dotenv: load `<config-dir>/.env`, then `<workspace_dir>/.env`, while pre-existing process variables remain highest priority; config placeholders `<VAR_NAME>` use the merged values
|
||||
- Config example: `resources/templates/config.example.json` (released to `~/.picobot/` on first run)
|
||||
- Runtime config loading ignores recoverable unknown/type-mismatched fields and invalid non-core named entries while retaining diagnostics and the raw file revision; malformed JSON, an unusable `default` Agent chain, and unsafe runtime construction remain fatal. WebUI writes are strict, and backend cleanup may remove only diagnosed paths from the same revision
|
||||
- CLI TUI identity is stored in `~/.picobot/tui_client_id`; it is a non-secret stable chat scope used to restore dialogs across reconnects
|
||||
- One-shot `run` uses a unique chat scope per invocation; for loopback Gateway URLs it authenticates `/ws` with `~/.picobot/web_admin_token`, while remote URLs use the existing paired CLI token
|
||||
|
||||
@ -97,6 +98,7 @@ Scheduler → SessionManager scheduled execution → AgentLoop → Scheduler del
|
||||
- **Context compaction** keeps `messages` append-only and uses one active checkpoint per Session (`summary + first_retained_seq`) for deterministic Provider projection; `/compact`, Turn-boundary auto compaction, and overflow share the same compactor/CAS commit path, Session restoration never derives context from Timeline or calls a Provider, the Model `token_limit` (default 128K) is the hard window ceiling and an optional Agent `token_limit` can only narrow it via `min(agent, model)`, summary input is bounded from that effective window rather than a fixed cap, and the only automatic threshold is `context_tokens > context_window - effective_reserve`
|
||||
- **AgentCatalog** is immutable per runtime generation; candidate preparation strictly validates trusted Markdown definitions, Provider profiles, tool/Skill allowlists, and delegation edges before activation. A definition that fails per-file validation (bad YAML, unknown provider/profile/model/tool/skill, or an explicit delegate edge to an absent target) is disabled for that generation only and reported via `load_errors` (exposed by `GET /api/agents`), never blocking startup or reload; config- and directory-trust-level failures remain fatal. Sub-Agent orchestration is an intrinsic, always-on mechanism (no feature switch). Named Agents support foreground (single/batch) and Root-initiated single background execution with durable run/inbox delivery; a built-in general-purpose definition is released to `~/.picobot/agents/` on first run. Background batches and nested background remain restricted
|
||||
- **WebUI management APIs** only expose allowlisted config/profile/log/storage operations; keep response limits, secret redaction, atomic config writes, and profile path allowlists intact
|
||||
- **Configuration recovery** builds an effective typed `Config` from a request-local copy and never rewrites the source automatically; diagnostics use raw RFC 6901 paths, array recovery preserves original indexes, ordinary writes reject ignored fields, and cleanup requires the exact source SHA-256 revision before atomically deleting diagnosed paths
|
||||
- **WebUI styling** uses the local Fluent 2 semantic tokens in `webui/src/styles.css`; page and component styles should consume the aliases instead of introducing independent hard-coded palettes, and must preserve selectable light/dark and brand-color themes, keyboard focus, responsive layout, and reduced-motion behavior. Browser-only appearance settings belong in `lib/theme.js`/`localStorage`, and `public/theme-init.js` must restore them before Svelte mounts
|
||||
- **Gateway config reload** validates and prepares a complete next runtime generation before retiring the old one; close runtime admission before draining inbound lanes, Sessions, Scheduler jobs, and background sub-agents; MCP connects only during activation; host/port, workspace, and effective storage path remain restart-only, and runtime reload must never mutate process environment variables
|
||||
- **WebUI slash completion** must consume the existing `get_slash_commands` WebSocket response; do not duplicate the backend command list in frontend source
|
||||
@ -113,7 +115,7 @@ Scheduler → SessionManager scheduled execution → AgentLoop → Scheduler del
|
||||
- **Skill enable/disable**: skills default to enabled; user-disabled skill names are persisted in `<config_dir>/skills_state.json` (skill files are never modified), and disabled skills are excluded from prompts, listings, and `get_skill` at load time
|
||||
- **MCP enable/disable**: `mcp.servers[].enabled` defaults to true; disabled servers are skipped at activation (no connection attempt) and by health checks
|
||||
- **Stateful tools** receive `ToolExecutionContext`; browser calls without `persistent_id` map each PicoBot dialog to an opaque transient agent-browser session, whose idle daemon timeout is controlled by `browser.idle_timeout_secs` (one hour by default). For long-running work an Agent may autonomously create a persistent identity and must pass its validated ID on every related action; persistent browser daemons disable idle auto-close, the same ID shares one agent-browser session and serialization gate across dialogs, and different IDs have independent sessions/gates. `browser_profiles` may create IDs, persist bounded semantic labels, list, or delete only validated IDs beneath the configured profile root. Do not introduce a global persistence mode switch, a default persistent ID, automatic per-dialog persistent Profiles, Fantoccini, ChromeDriver, WebDriver, model-controlled raw agent-browser session IDs, or arbitrary Profile paths
|
||||
- **Health diagnostics** are read-only and share `HealthService` across `picobot health`, the `health` tool, and `/health`; checks must not install/fix dependencies, call Provider APIs, or expose secrets
|
||||
- **Health diagnostics** are read-only and share `HealthService` across `picobot health`, the `health` tool, `/health`, and authenticated WebUI `GET /api/health`; the public `GET /health` remains a lightweight liveness/version probe, while full checks run only on demand and must not install/fix dependencies, call Provider APIs, or expose secrets. Treat `fd`/`fdfind` as equivalent preferred backends; browser launch diagnostics must isolate their socket namespace from active agent-browser sessions and must not use `--quick`, which skips the live launch probe
|
||||
|
||||
### Concurrency and Lifecycle Invariants
|
||||
|
||||
|
||||
@ -1,11 +1,12 @@
|
||||
[package]
|
||||
name = "picobot"
|
||||
version = "1.20.0"
|
||||
version = "1.21.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
reqwest = { version = "0.13.4", default-features = false, features = ["json", "rustls", "multipart"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_path_to_error = "0.1"
|
||||
regex = "1.13"
|
||||
serde_json = "1.0"
|
||||
serde_yaml = "0.9"
|
||||
|
||||
@ -150,7 +150,9 @@ picobot health
|
||||
picobot health --json
|
||||
```
|
||||
|
||||
缺少核心或当前配置要求的依赖时退出码为 `1`;`rg` / `fd` 等有回退实现的加速项只会标记为 `DEGRADED`。运行中的 Gateway 也提供 `/health` 斜杠命令,Agent 可调用同名 `health` 工具,三者共享同一套只读检查逻辑。
|
||||
缺少核心或当前配置要求的依赖时退出码为 `1`;`rg` / `fd` 等有回退实现的加速项只会标记为 `DEGRADED`。运行中的 Gateway 也提供 `/health` 斜杠命令,Agent 可调用同名 `health` 工具,WebUI 的“配置 → 健康检查”可显示相同的结构化结果并手动复查;这些入口共享同一套只读检查逻辑。
|
||||
|
||||
Debian/Ubuntu 将同一个 fd 程序安装为 `fdfind`,两者都视为首选文件搜索后端;只有退回传统 `find` 时才提示性能警告。启用浏览器工具后,Health 除了检查 agent-browser 版本和浏览器路径,还会在隔离的临时 socket namespace 中执行完整离线 doctor,分别报告浏览器安装、真实 headless 启动和运行环境,因此可发现“文件存在但 Chrome 无法启动”或缺少 Linux 共享库等问题。
|
||||
|
||||
### 5.3 使用 WebUI
|
||||
|
||||
@ -185,9 +187,10 @@ WebUI 随二进制嵌入,不需要 Node.js、npm 或单独部署静态文件
|
||||
- 当前聊天 session 的可展开 Todo 侧栏;计划变化时自动展开,其他 session 的变化显示未读提示。
|
||||
- Knowledge/Timeline 记忆的分类与全文检索。
|
||||
- 本地滚动日志的尾部查看、过滤和自动刷新。
|
||||
- 健康检查结果:按核心与已配置功能展示通过、警告、失败及处理建议。
|
||||
- `config.json`、`~/.picobot/USER.md`、`~/.picobot/AGENTS.md` 编辑。
|
||||
|
||||
配置接口会掩码 API Key、secret、password 和 token;保留 `********` 再保存不会覆盖原密钥。运行配置采用原子写入,保存后可执行 `picobot reload` 或发送 `/reload` 热重载;`USER.md` 和 `AGENTS.md` 的修改用于后续构建的 Agent 上下文。
|
||||
配置接口会掩码 API Key、secret、password 和 token;保留 `********` 再保存不会覆盖原密钥。Gateway 加载历史配置时会忽略可恢复的未知字段、类型不匹配字段和不再可用的非核心命名条目,并在日志、Health 和 WebUI 配置页显示对应 JSON Pointer;原始文件不会被自动修改。WebUI 的“一键清除”由后端按配置 revision 删除这些已忽略项,文件在展示后发生变化时会拒绝覆盖;普通保存仍严格拒绝包含无效项的新配置。JSON 语法错误、不可用的 `default` Agent 链路以及无法安全构造 Gateway 的错误仍会阻止启动或重载。运行配置采用原子写入,保存或清理后可执行 `picobot reload` 或发送 `/reload` 热重载;`USER.md` 和 `AGENTS.md` 的修改用于后续构建的 Agent 上下文。
|
||||
|
||||
WebUI 默认启用设备配对鉴权,管理 API 与 `/ws` 都拒绝未配对客户端;静态配对页、公开健康检查和配对提交接口除外。唯一的 WebSocket 例外是本机 `picobot run`:请求必须同时来自真实回环对端并持有权限为 `0600` 的 `~/.picobot/web_admin_token`,该管理令牌不能绕过任何管理 API 的设备鉴权。配对令牌只以 SHA-256 哈希写入 `~/.picobot/web_auth.json`。鉴权不提供传输加密;如果通过 `--host 0.0.0.0`、反向代理或端口转发暴露 Gateway,仍必须使用 TLS。可通过 `gateway.require_pairing=false` 显式关闭配对,但不建议在非隔离环境使用。
|
||||
|
||||
|
||||
@ -157,7 +157,7 @@ agent-browser install
|
||||
## 8. 使用
|
||||
|
||||
1. 浏览器工具默认启用;从旧配置删除 `webdriver_url`、`chrome_path`,仅在需要改变持久目录位置时配置 `persistence.profile_dir`。没有持久化模式开关。缺少依赖不会阻止 Gateway 启动,只会让 health 和实际浏览器调用失败。
|
||||
2. 运行 `picobot health`;应看到 agent-browser CLI 版本和 offline quick doctor 通过。
|
||||
2. 运行 `picobot health`;应看到 agent-browser CLI 版本、浏览器安装、隔离的 offline headless 启动和环境检查通过。
|
||||
3. 启动或重载 Gateway。
|
||||
4. 对 Agent 说“使用浏览器打开 …”。模型的推荐动作序列是:
|
||||
|
||||
@ -193,7 +193,7 @@ browser_profiles(delete, id=picobot-profile-...)
|
||||
- `/health`:当前 Gateway 配置的聊天入口。
|
||||
- `health` Tool:Agent 可调用的只读入口,支持 `json=true`。
|
||||
|
||||
检查项包括 workspace、Bash、内容/文件搜索后端、可选 systemctl、配置中的 stdio MCP 命令,以及浏览器启用时的持久 Profile 可用性、agent-browser 版本、显式浏览器路径和 `doctor --offline --quick --json`。检查不创建或删除 Profile、不安装软件、不执行 `doctor --fix`、不访问 Provider API,也不输出配置密钥。
|
||||
检查项包括 workspace、Bash、内容/文件搜索后端、可选 systemctl、配置中的 stdio MCP 命令,以及浏览器启用时的持久 Profile 可用性、agent-browser 版本、显式浏览器路径和完整的 offline doctor。Health 为 doctor 创建临时 socket 目录并使用专用 namespace,不读取或清理活动 daemon socket;不使用会跳过真实启动测试的 `--quick`。结构化结果分别展示浏览器安装、headless launch 和其余环境诊断。检查不创建或删除 PicoBot 持久 Profile、不安装软件、不执行 `doctor --fix`、不访问 Provider API,也不输出配置密钥。
|
||||
|
||||
## 10. 迁移和故障处理
|
||||
|
||||
|
||||
@ -266,13 +266,14 @@ MCP 发现的工具在 `ToolRegistry` 中使用 `mcp_<server-name>_<tool-name>`
|
||||
|
||||
`browser` 是有状态工具适配器:`BrowserTool` 保持模型侧 action schema,`BrowserManager` 按每次调用是否带 `persistent_id` 分流。省略 ID 时把 PicoBot dialog 映射到随机临时 agent-browser session,并用每 session mutex 保证同一页面串行、不同 dialog 并发;临时 daemon 按 `browser.idle_timeout_secs`(默认一小时)自动退出,Manager 在容量检查时惰性回收对应空闲条目。长期工作需要保持浏览器进程或保留登录和站点状态时,Agent 可自主创建持久身份并在后续相关 action 中持续传入同一个 ID。Manager 按持久 ID 保存 agent-browser session 和 mutex,持久 daemon 的空闲超时固定为禁用;同一 ID 跨 dialog 共享且串行,不同 ID 相互独立并可并发,Gateway 重启或显式关闭浏览器后仍可继续使用原 Profile;没有全局持久化开关、默认 ID 或按 dialog 隐式选择。`browser_profiles` 在受控根目录下创建、设置语义化标签、列出或删除格式合法的 ID;标签只负责识别,选择仍使用不可变 ID,删除活动 ID 时先等待其 action 并关闭浏览器。`AgentBrowserRunner` 以 argv 和 `--json` 调用外部原生 CLI,设置硬超时、输出/content boundaries/domain allowlist,并在持久调用中传入受控 `--profile` 路径,底层 daemon 通过 Chrome CDP 工作。PicoBot 不链接 agent-browser 内部 crate、不直接暴露其 MCP、不使用 Fantoccini/ChromeDriver/WebDriver。持久 Profile 与 `allowed_domains` 因上游安全边界互斥;设置域名限制时临时浏览器仍可用,持久调用会被拒绝。截图只能写入配置的 artifact directory,并作为 `ModelAndUser` 产物返回,默认附到最终用户回复;仅当调用显式设置 `present_to_user=false` 时才作为模型内部观察。完整边界见 [AGENT_BROWSER_INTEGRATION.md](AGENT_BROWSER_INTEGRATION.md)。
|
||||
|
||||
`HealthService` 是依赖检查的唯一实现。CLI `picobot health`、只读 `health` 工具和 `/health` 斜杠命令必须复用它;检查可探测命令、版本、配置路径和 agent-browser offline quick doctor,但不能安装/修复软件、连接模型 API 或泄漏配置秘密。
|
||||
`HealthService` 是依赖检查的唯一实现。CLI `picobot health`、只读 `health` 工具、`/health` 斜杠命令和 WebUI“配置 → 健康检查”必须复用它;受鉴权的 `GET /api/health` 按需返回 `HealthReport`,公开 `GET /health` 仍只承担轻量在线与版本探测,避免页面常驻轮询反复执行外部诊断命令。检查可探测命令、版本和配置路径;`fd` 与 Debian/Ubuntu 的同程序命令名 `fdfind` 都是首选后端,传统 `find` 才是降级回退。浏览器检查使用临时 socket 目录和专用 namespace 运行完整的 agent-browser offline doctor,将浏览器安装、真实 headless 启动和其余环境问题拆分报告,既不接触活动 daemon socket,也不跳过 launch probe。Health 不能安装/修复软件、连接模型 API 或泄漏配置秘密。
|
||||
|
||||
`AuthManager` 默认保护所有管理 API 和 `/ws`。静态资源、`/health`、`/api/auth/status` 与 `/api/auth/pair` 保持公开,使未配对浏览器只能加载配对界面。`picobot pair` 使用权限为 `0600` 的本机管理密钥调用仅接受真实回环连接的 `/api/auth/code`;反向代理即使从回环连接也无法在没有该密钥时签发代码。本机 `picobot run` 可用同一个管理密钥直接认证 `/ws`,但中间件必须同时验证请求路径严格等于 `/ws` 且 `ConnectInfo` 中的真实 TCP 对端为回环地址;这一身份不能访问管理 API。远程 `run` 与 TUI 一样使用已配对的 Bearer token。配对码为 8 位、5 分钟有效、单次消费,并按来源实施失败锁定。浏览器收到 HttpOnly、SameSite=Strict Cookie,CLI 使用 Bearer token;服务端仅持久化 SHA-256 哈希。`--revoke-all` 的持久化成功后才清空内存令牌,活动 WebSocket 每 5 秒复核身份并回收已撤销连接。
|
||||
|
||||
同源 `/api/*` 管理接口只提供显式白名单能力:
|
||||
|
||||
- 配置读取/原子写入;响应中的密钥字段统一掩码,原样提交掩码会恢复现有值。`POST /api/config/reload` 通过同一重载控制器校验并切换 Gateway 运行代,`GET /api/config/reload/status` 查询 generation、相位与最近错误。
|
||||
- `GET /api/health` 返回当前运行代 `HealthService` 的完整只读报告;它只在用户进入健康检查页或手动刷新时运行,不属于 Gateway 在线探测轮询。
|
||||
- 配置读取/原子写入;响应中的密钥字段统一掩码,原样提交掩码会恢复现有值。配置加载器在不修改原始文件的前提下,从请求内副本移除可恢复的未知字段、类型/枚举错误和失效的非核心命名条目,生成有效 `Config`、RFC 6901 诊断路径和原始文件 SHA-256 revision;数组元素另外保留原始索引映射,避免恢复过程中索引移动导致清理错位。`models.*` 的 `flatten` Provider 扩展参数以及 MCP 的 `env`/`headers`/`tool_settings` 动态键属于显式扩展面,不作为未知项。JSON 损坏、不可用的 `default` Agent Provider/Model 链路和候选运行代无法安全构造仍是致命错误。普通 `PUT /api/config` 保持严格,不允许写入会被忽略的新配置;`POST /api/config/cleanup-invalid` 只按同一加载器报告的路径清理,并在 revision 不匹配时返回冲突。`POST /api/config/reload` 通过同一重载控制器校验并切换 Gateway 运行代,`GET /api/config/reload/status` 查询 generation、相位与最近错误。
|
||||
- `USER.md`、`AGENTS.md` 只允许固定文件名,不接受任意路径。
|
||||
- 日志、记忆、任务和运行记录均限制单次返回数量;日志目录固定为 `~/.picobot/logs`。
|
||||
- 任务与记忆读取复用 Storage API,不允许 WebUI 直接持有 SQLite 连接或拼接任意查询。
|
||||
@ -285,7 +286,7 @@ MCP 发现的工具在 `ToolRegistry` 中使用 `mcp_<server-name>_<tool-name>`
|
||||
|
||||
### 启动
|
||||
|
||||
1. 解析配置路径,加载配置目录 `.env`,据此定位 workspace,再加载 workspace `.env`;既有进程环境保持最高优先级,合并后重新解析配置。
|
||||
1. 解析配置路径,加载配置目录 `.env`,据此定位 workspace,再加载 workspace `.env`;既有进程环境保持最高优先级,合并后通过统一容错加载器重新解析配置。可恢复项只影响有效运行时投影并产生诊断,原始 `config.json` 保持不变。
|
||||
2. 初始化日志,创建并切换到 workspace,初始化 WebUI 配对存储与本机管理密钥。
|
||||
3. 初始化 SQLite、MemoryManager、MessageBus 和 SessionManager;Scheduler 启用时幂等创建默认日常维护巡检。
|
||||
4. 注册内置工具、渠道、MCP 工具和 Cron 工具。
|
||||
|
||||
@ -150,8 +150,8 @@ sequenceDiagram
|
||||
准备阶段在旧运行代继续提供服务时执行:
|
||||
|
||||
1. `load_candidate()` 重新读取当前 Gateway 启动时确定的配置文件。
|
||||
2. 使用启动环境快照和启动 cwd 解析 `.env`、占位符与相对 workspace。
|
||||
3. 校验 default agent 能解析为完整 `LLMProviderConfig`。
|
||||
2. 使用启动环境快照和启动 cwd 解析 `.env`、占位符与相对 workspace;统一配置加载器在请求内副本上忽略可恢复的未知字段、类型错误和失效非核心条目,并保留原始 JSON Pointer 诊断,不改写磁盘文件。
|
||||
3. 校验 default agent 能解析为完整 `LLMProviderConfig`;核心链路不可恢复时仍拒绝候选。
|
||||
4. 若飞书启用,校验 `app_id` 和 `app_secret` 非空。
|
||||
5. 比较不可热变更字段。
|
||||
6. 调用 `GatewayState::from_config()` 构造候选运行代。
|
||||
@ -271,7 +271,7 @@ Gateway 在首次调用 `Config::load_from()` 前保存:
|
||||
| 失败阶段 | 行为 |
|
||||
|----------|------|
|
||||
| 已有 pending 重载/控制器关闭 | 分别返回 409/503,不读取配置 |
|
||||
| JSON、`.env`、占位符或默认 Agent 校验失败 | 返回错误,旧运行代保持不变 |
|
||||
| JSON、`.env`、占位符或默认 Agent 校验失败 | 返回错误,旧运行代保持不变;可恢复的历史字段问题只产生诊断,不进入此失败分支 |
|
||||
| 不可热变更字段发生变化 | 返回 restart-required 错误,旧运行代保持不变 |
|
||||
| `GatewayState::from_config()` 构造失败 | 返回错误;候选被丢弃,其 TaskSupervisor 随对象释放取消;旧请求处理运行代保持不变 |
|
||||
| 单个 MCP Server 连接或工具发现失败 | 记录 MCP 失败状态,候选继续构造且不注册该 Server 的工具;这不视为整体 reload 失败 |
|
||||
@ -282,7 +282,7 @@ Gateway 在首次调用 `Config::load_from()` 前保存:
|
||||
|
||||
准备阶段成功后才向调用者返回 accepted。激活阶段仍可能遇到运行时错误,因此调用者不应把 accepted 当作健康检查;可使用 `GET /api/config/reload/status` 等待相同 generation 进入 `active`,并结合 `/health` 与客户端重连确认。
|
||||
|
||||
WebUI `PUT /api/config` 只负责原子写文件、恢复被掩码的 secret 并返回 `restart_required: true`;它不会隐式触发重载。显式的 `POST /api/config/reload` 将文件写入与运行代切换解耦,使用户可以批量编辑后主动决定生效时机。
|
||||
WebUI `PUT /api/config` 只负责严格校验、原子写文件、恢复被掩码的 secret 并返回 `restart_required: true`;它不会允许新提交内容依赖容错忽略,也不会隐式触发重载。`GET /api/config` 返回原始文件 SHA-256 revision 和可恢复诊断;`POST /api/config/cleanup-invalid` 必须提交相同 revision,后端才会在共享配置写锁下原子删除已诊断路径,revision 变化返回 409。显式的 `POST /api/config/reload` 将文件写入与运行代切换解耦,使用户可以批量编辑或清理后主动决定生效时机。
|
||||
|
||||
## 10. 并发与生命周期不变量
|
||||
|
||||
@ -320,6 +320,7 @@ WebUI `PUT /api/config` 只负责原子写文件、恢复被掩码的 secret 并
|
||||
当前回归测试覆盖:
|
||||
|
||||
- 候选配置允许 Provider/模型等运行时字段变化。
|
||||
- 可恢复未知字段、类型错误和失效非核心条目不阻止候选,并保留原始数组索引诊断;严格 WebUI 写入拒绝同样内容,清理只删除诊断路径。
|
||||
- 相对 `workspace_dir` 按启动 cwd 正确解析。
|
||||
- workspace 变化被拒绝且返回明确错误。
|
||||
- `None` 与显式指向同一有效数据库路径(默认 `{config_dir}/data/picobot.db`)时允许重载。
|
||||
|
||||
@ -32,7 +32,7 @@ Scheduler → SessionManager.handle_cron_message → AgentLoop → send_message
|
||||
| `observability` | Observer 模式,agent/工具遥测事件 |
|
||||
| `protocol` | WebSocket 协议消息定义 |
|
||||
| `config` | 配置加载、环境变量替换、路径解析 |
|
||||
| `health` | CLI、工具和斜杠命令共用的只读运行依赖检查 |
|
||||
| `health` | CLI、工具、斜杠命令和 WebUI 共用的只读运行依赖检查 |
|
||||
| `memory` | 长期记忆存储与检索 |
|
||||
| `mcp` | MCP(Model Context Protocol)工具集成 |
|
||||
| `task_supervisor` | Gateway 后台任务注册、取消、限时等待和强制回收 |
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
配置文件加载顺序:`~/.picobot/config.json` → 当前目录 `./config.json`。
|
||||
占位符 `<VAR_NAME>` 从启动环境替换。PicoBot 依次加载 `config.json` 同目录的 `.env`、`workspace_dir/.env`,最后保留启动进程已有环境变量作为最高优先级;workspace 层覆盖配置目录层。合并值也会进入进程环境,供 MCP 和工具子进程继承。workspace `.env` 不能修改用于定位自身的 `workspace_dir`。
|
||||
|
||||
Gateway WebUI 的“配置”页可以编辑实际加载的配置文件。读取时 API Key、secret、password 和 token 会显示为 `********`,保持掩码不变再保存会保留原值;写入采用同目录临时文件替换。运行配置保存后可执行 `picobot reload`、发送 `/reload`,或由根交互 Agent 在用户明确要求时调用 `reload_config` 工具。Gateway 会先校验候选配置,停止接收新工作并等待交互 Turn、Scheduler job 和后台子 Agent 到达安全边界后切换;失败时继续使用旧配置。`GET /api/config/reload/status` 可查询 generation、相位与最近错误。`gateway.host`、`gateway.port`、`workspace_dir` 和 `gateway.session_db_path` 的有效路径必须通过完整重启变更。`USER.md` 与 `AGENTS.md` 的修改用于后续构建的 Agent 上下文。
|
||||
Gateway WebUI 的“配置”页可以编辑实际加载的配置文件。读取时 API Key、secret、password 和 token 会显示为 `********`,保持掩码不变再保存会保留原值;写入采用同目录临时文件替换。Gateway 会忽略可恢复的历史未知字段、类型不匹配字段和失效的非核心命名条目,在配置页按 JSON Pointer 显示诊断,但不会自动改写原始文件;“一键清除”由后端校验文件 revision 后原子删除已诊断项,普通保存仍严格拒绝无效配置。JSON 损坏、不可用的 `default` Agent 链路和无法安全构造运行代的错误不会被忽略。运行配置保存或清理后可执行 `picobot reload`、发送 `/reload`,或由根交互 Agent 在用户明确要求时调用 `reload_config` 工具。Gateway 会先校验候选配置,停止接收新工作并等待交互 Turn、Scheduler job 和后台子 Agent 到达安全边界后切换;失败时继续使用旧配置。`GET /api/config/reload/status` 可查询 generation、相位与最近错误。`gateway.host`、`gateway.port`、`workspace_dir` 和 `gateway.session_db_path` 的有效路径必须通过完整重启变更。`USER.md` 与 `AGENTS.md` 的修改用于后续构建的 Agent 上下文。
|
||||
|
||||
## config.json 结构
|
||||
|
||||
@ -198,7 +198,7 @@ MCP 服务器单条配置:
|
||||
|
||||
agent-browser 0.33.0 不允许持久 Profile 与 `allowed_domains` 同时使用。配置域名限制后普通临时浏览器仍可用,创建或使用持久身份会被拒绝,health 会提示该可选能力受限。Profile 包含登录凭据,应把目录视为敏感数据,不得提交到版本控制、跨用户共享或放在不受信任的网络文件系统中。
|
||||
|
||||
旧字段 `webdriver_url`、`chrome_path` 不再接受。推荐安装 `agent-browser@0.33.0` 后运行 `agent-browser install`;Linux 可运行 `agent-browser install --with-deps`。使用前用 `picobot health` 检查 CLI 与 Chrome 环境。
|
||||
旧字段 `webdriver_url`、`chrome_path` 不再接受。推荐安装 `agent-browser@0.33.0` 后运行 `agent-browser install`;Linux 可运行 `agent-browser install --with-deps`。使用前用 `picobot health` 检查 CLI、浏览器安装和真实 headless 启动环境。
|
||||
|
||||
### 浏览器依赖故障处置
|
||||
|
||||
|
||||
@ -189,7 +189,7 @@ Cron 不是一个带 `action` 的统一工具,而是六个独立工具;仅
|
||||
|
||||
## health — 依赖检查
|
||||
|
||||
无参数时返回可读报告;`json=true` 返回结构化报告。核心必需项、当前配置启用后必需的依赖、可选功能分别标记。该工具只读,与 CLI `picobot health [--json]` 和 `/health` 斜杠命令复用同一个 `HealthService`。
|
||||
无参数时返回可读报告;`json=true` 返回结构化报告。核心必需项、当前配置启用后必需的依赖、可选功能分别标记。`fd` 与 Debian/Ubuntu 的 `fdfind` 是同一个首选文件搜索程序;只有传统 `find` 会产生降级警告。浏览器启用时会检查 CLI、可执行路径,并通过隔离的完整 offline doctor 分别验证浏览器安装、真实 headless 启动和环境。该工具只读,与 CLI `picobot health [--json]`、`/health` 斜杠命令以及 WebUI“配置 → 健康检查”复用同一个 `HealthService`。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::HashMap;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
@ -68,6 +70,44 @@ pub struct Config {
|
||||
pub agent_orchestration: AgentOrchestrationConfig,
|
||||
#[serde(default)]
|
||||
pub context_compaction: ContextCompactionConfig,
|
||||
/// Recoverable source problems ignored while constructing this effective
|
||||
/// runtime configuration. These are never serialized back into config.json.
|
||||
#[serde(skip)]
|
||||
pub diagnostics: Vec<ConfigDiagnostic>,
|
||||
/// SHA-256 revision of the raw config.json bytes used for this load.
|
||||
#[serde(skip)]
|
||||
pub source_revision: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||
pub struct ConfigDiagnostic {
|
||||
/// RFC 6901 pointer into the raw config.json document.
|
||||
pub path: String,
|
||||
pub kind: ConfigDiagnosticKind,
|
||||
pub reason: String,
|
||||
pub action: ConfigRecoveryAction,
|
||||
/// Exact raw-document path removed by the cleanup API.
|
||||
pub cleanup_path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ConfigDiagnosticKind {
|
||||
UnknownField,
|
||||
InvalidValue,
|
||||
InvalidEntry,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ConfigRecoveryAction {
|
||||
Ignored,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct ConfigLoadContext {
|
||||
pub process_env: HashMap<String, String>,
|
||||
pub startup_cwd: PathBuf,
|
||||
}
|
||||
|
||||
fn default_workspace_dir() -> String {
|
||||
@ -204,7 +244,7 @@ pub(crate) fn resolve_token_limit(
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
#[serde(default)]
|
||||
pub struct ContextCompactionConfig {
|
||||
/// Controls proactive Turn-boundary compaction only. Manual compaction and
|
||||
/// overflow recovery remain available when this is false.
|
||||
@ -226,7 +266,7 @@ impl Default for ContextCompactionConfig {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
#[serde(default)]
|
||||
pub struct AgentOrchestrationConfig {
|
||||
pub definitions_dir: String,
|
||||
pub max_tree_depth: u16,
|
||||
@ -645,7 +685,6 @@ fn default_mcp_tool_timeout_secs() -> u64 {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct BrowserConfig {
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
@ -676,7 +715,6 @@ pub struct BrowserConfig {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct BrowserPersistenceConfig {
|
||||
#[serde(default = "default_browser_profile_dir")]
|
||||
pub profile_dir: String,
|
||||
@ -808,7 +846,470 @@ pub fn resolve_default_config_path() -> PathBuf {
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_CONFIG_RECOVERIES: usize = 256;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum ConfigPathSegment {
|
||||
Key(String),
|
||||
Index(usize),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct OriginNode {
|
||||
pointer: String,
|
||||
children: OriginChildren,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum OriginChildren {
|
||||
Scalar,
|
||||
Object(HashMap<String, OriginNode>),
|
||||
Array(Vec<OriginNode>),
|
||||
}
|
||||
|
||||
impl OriginNode {
|
||||
fn from_value(value: &Value, pointer: String) -> Self {
|
||||
let children = match value {
|
||||
Value::Object(object) => OriginChildren::Object(
|
||||
object
|
||||
.iter()
|
||||
.map(|(key, value)| {
|
||||
let child_pointer = join_json_pointer(&pointer, key);
|
||||
(key.clone(), Self::from_value(value, child_pointer))
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
Value::Array(array) => OriginChildren::Array(
|
||||
array
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, value)| {
|
||||
Self::from_value(value, join_json_pointer(&pointer, &index.to_string()))
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
_ => OriginChildren::Scalar,
|
||||
};
|
||||
Self { pointer, children }
|
||||
}
|
||||
|
||||
fn at<'a>(&'a self, path: &[ConfigPathSegment]) -> Option<&'a Self> {
|
||||
let mut current = self;
|
||||
for segment in path {
|
||||
current = match (¤t.children, segment) {
|
||||
(OriginChildren::Object(children), ConfigPathSegment::Key(key)) => {
|
||||
children.get(key)?
|
||||
}
|
||||
(OriginChildren::Array(children), ConfigPathSegment::Index(index)) => {
|
||||
children.get(*index)?
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
}
|
||||
Some(current)
|
||||
}
|
||||
}
|
||||
|
||||
fn escape_json_pointer_segment(segment: &str) -> String {
|
||||
segment.replace('~', "~0").replace('/', "~1")
|
||||
}
|
||||
|
||||
fn join_json_pointer(parent: &str, segment: &str) -> String {
|
||||
format!("{parent}/{}", escape_json_pointer_segment(segment))
|
||||
}
|
||||
|
||||
fn error_path_segments(path: &serde_path_to_error::Path) -> Option<Vec<ConfigPathSegment>> {
|
||||
path.iter()
|
||||
.map(|segment| match segment {
|
||||
serde_path_to_error::Segment::Map { key } => Some(ConfigPathSegment::Key(key.clone())),
|
||||
serde_path_to_error::Segment::Seq { index } => Some(ConfigPathSegment::Index(*index)),
|
||||
serde_path_to_error::Segment::Enum { variant } => {
|
||||
Some(ConfigPathSegment::Key(variant.clone()))
|
||||
}
|
||||
serde_path_to_error::Segment::Unknown => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn remove_config_path(
|
||||
value: &mut Value,
|
||||
origin: &mut OriginNode,
|
||||
path: &[ConfigPathSegment],
|
||||
) -> bool {
|
||||
let Some((head, tail)) = path.split_first() else {
|
||||
return false;
|
||||
};
|
||||
if tail.is_empty() {
|
||||
return match (value, &mut origin.children, head) {
|
||||
(
|
||||
Value::Object(object),
|
||||
OriginChildren::Object(origins),
|
||||
ConfigPathSegment::Key(key),
|
||||
) => {
|
||||
let removed = object.remove(key).is_some();
|
||||
origins.remove(key);
|
||||
removed
|
||||
}
|
||||
(
|
||||
Value::Array(array),
|
||||
OriginChildren::Array(origins),
|
||||
ConfigPathSegment::Index(index),
|
||||
) if *index < array.len() && *index < origins.len() => {
|
||||
array.remove(*index);
|
||||
origins.remove(*index);
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
match (value, &mut origin.children, head) {
|
||||
(Value::Object(object), OriginChildren::Object(origins), ConfigPathSegment::Key(key)) => {
|
||||
let Some(child) = object.get_mut(key) else {
|
||||
return false;
|
||||
};
|
||||
let Some(child_origin) = origins.get_mut(key) else {
|
||||
return false;
|
||||
};
|
||||
remove_config_path(child, child_origin, tail)
|
||||
}
|
||||
(Value::Array(array), OriginChildren::Array(origins), ConfigPathSegment::Index(index))
|
||||
if *index < array.len() && *index < origins.len() =>
|
||||
{
|
||||
remove_config_path(&mut array[*index], &mut origins[*index], tail)
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn diagnostic_reason(error: &serde_json::Error) -> (&'static str, ConfigDiagnosticKind) {
|
||||
let message = error.to_string();
|
||||
if message.starts_with("missing field") {
|
||||
(
|
||||
"配置条目缺少必填字段,整个条目已忽略",
|
||||
ConfigDiagnosticKind::InvalidEntry,
|
||||
)
|
||||
} else if message.starts_with("unknown variant") {
|
||||
(
|
||||
"配置值不是受支持的枚举选项,已忽略",
|
||||
ConfigDiagnosticKind::InvalidValue,
|
||||
)
|
||||
} else if message.starts_with("invalid type") {
|
||||
(
|
||||
"配置值类型不匹配,已忽略",
|
||||
ConfigDiagnosticKind::InvalidValue,
|
||||
)
|
||||
} else {
|
||||
("配置值无法解析,已忽略", ConfigDiagnosticKind::InvalidValue)
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_unknown_fields(
|
||||
source: &Value,
|
||||
origin: &OriginNode,
|
||||
effective: &Value,
|
||||
diagnostics: &mut Vec<ConfigDiagnostic>,
|
||||
) {
|
||||
match (source, &origin.children, effective) {
|
||||
(Value::Object(source), OriginChildren::Object(origins), Value::Object(effective)) => {
|
||||
for (key, source_value) in source {
|
||||
let Some(child_origin) = origins.get(key) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(effective_value) = effective.get(key) {
|
||||
collect_unknown_fields(
|
||||
source_value,
|
||||
child_origin,
|
||||
effective_value,
|
||||
diagnostics,
|
||||
);
|
||||
} else {
|
||||
diagnostics.push(ConfigDiagnostic {
|
||||
path: child_origin.pointer.clone(),
|
||||
kind: ConfigDiagnosticKind::UnknownField,
|
||||
reason: "当前版本不识别此配置项,已忽略".to_string(),
|
||||
action: ConfigRecoveryAction::Ignored,
|
||||
cleanup_path: child_origin.pointer.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
(Value::Array(source), OriginChildren::Array(origins), Value::Array(effective)) => {
|
||||
for ((source_value, child_origin), effective_value) in
|
||||
source.iter().zip(origins).zip(effective)
|
||||
{
|
||||
collect_unknown_fields(source_value, child_origin, effective_value, diagnostics);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn source_revision(raw: &str) -> String {
|
||||
let digest: String = Sha256::digest(raw.as_bytes())
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect();
|
||||
format!("sha256:{digest}")
|
||||
}
|
||||
|
||||
fn decode_json_pointer(pointer: &str) -> Option<Vec<String>> {
|
||||
if pointer.is_empty() {
|
||||
return Some(Vec::new());
|
||||
}
|
||||
pointer.strip_prefix('/').map(|rest| {
|
||||
rest.split('/')
|
||||
.map(|segment| segment.replace("~1", "/").replace("~0", "~"))
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
fn is_pointer_ancestor(ancestor: &str, descendant: &str) -> bool {
|
||||
descendant
|
||||
.strip_prefix(ancestor)
|
||||
.is_some_and(|suffix| suffix.starts_with('/'))
|
||||
}
|
||||
|
||||
fn remove_raw_pointer(value: &mut Value, segments: &[String]) -> bool {
|
||||
let Some((head, tail)) = segments.split_first() else {
|
||||
return false;
|
||||
};
|
||||
if tail.is_empty() {
|
||||
return match value {
|
||||
Value::Object(object) => object.remove(head).is_some(),
|
||||
Value::Array(array) => head
|
||||
.parse::<usize>()
|
||||
.ok()
|
||||
.filter(|index| *index < array.len())
|
||||
.map(|index| {
|
||||
array.remove(index);
|
||||
})
|
||||
.is_some(),
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
match value {
|
||||
Value::Object(object) => object
|
||||
.get_mut(head)
|
||||
.is_some_and(|child| remove_raw_pointer(child, tail)),
|
||||
Value::Array(array) => head
|
||||
.parse::<usize>()
|
||||
.ok()
|
||||
.and_then(|index| array.get_mut(index))
|
||||
.is_some_and(|child| remove_raw_pointer(child, tail)),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove only paths previously diagnosed against this exact raw revision.
|
||||
/// Ancestors subsume descendants, and sibling array indexes are deleted from
|
||||
/// highest to lowest so paths remain stable during the operation.
|
||||
pub(crate) fn cleanup_invalid_config(value: &mut Value, diagnostics: &[ConfigDiagnostic]) -> usize {
|
||||
let mut paths = diagnostics
|
||||
.iter()
|
||||
.map(|diagnostic| diagnostic.cleanup_path.clone())
|
||||
.collect::<Vec<_>>();
|
||||
paths.sort_by_key(|path| decode_json_pointer(path).map_or(usize::MAX, |parts| parts.len()));
|
||||
let mut coalesced: Vec<String> = Vec::new();
|
||||
for path in paths {
|
||||
if !coalesced
|
||||
.iter()
|
||||
.any(|ancestor| ancestor == &path || is_pointer_ancestor(ancestor, &path))
|
||||
{
|
||||
coalesced.push(path);
|
||||
}
|
||||
}
|
||||
coalesced.sort_by(|left, right| {
|
||||
let left_parts = decode_json_pointer(left).unwrap_or_default();
|
||||
let right_parts = decode_json_pointer(right).unwrap_or_default();
|
||||
let left_parent = &left_parts[..left_parts.len().saturating_sub(1)];
|
||||
let right_parent = &right_parts[..right_parts.len().saturating_sub(1)];
|
||||
if left_parent == right_parent {
|
||||
let left_index = left_parts
|
||||
.last()
|
||||
.and_then(|part| part.parse::<usize>().ok());
|
||||
let right_index = right_parts
|
||||
.last()
|
||||
.and_then(|part| part.parse::<usize>().ok());
|
||||
if let (Some(left_index), Some(right_index)) = (left_index, right_index) {
|
||||
return right_index.cmp(&left_index);
|
||||
}
|
||||
}
|
||||
right_parts
|
||||
.len()
|
||||
.cmp(&left_parts.len())
|
||||
.then_with(|| right.cmp(left))
|
||||
});
|
||||
coalesced
|
||||
.iter()
|
||||
.filter_map(|path| decode_json_pointer(path))
|
||||
.filter(|segments| remove_raw_pointer(value, segments))
|
||||
.count()
|
||||
}
|
||||
|
||||
fn parse_config_tolerant(content: &str) -> Result<Config, Box<dyn std::error::Error>> {
|
||||
let mut candidate: Value = serde_json::from_str(content)?;
|
||||
let mut origins = OriginNode::from_value(&candidate, String::new());
|
||||
let mut diagnostics = Vec::new();
|
||||
|
||||
let mut config = None;
|
||||
for _ in 0..=MAX_CONFIG_RECOVERIES {
|
||||
let serialized = serde_json::to_string(&candidate)?;
|
||||
let mut deserializer = serde_json::Deserializer::from_str(&serialized);
|
||||
match serde_path_to_error::deserialize::<_, Config>(&mut deserializer) {
|
||||
Ok(parsed) => {
|
||||
config = Some(parsed);
|
||||
break;
|
||||
}
|
||||
Err(error) => {
|
||||
let Some(path) = error_path_segments(error.path()) else {
|
||||
return Err(error.into());
|
||||
};
|
||||
if path.is_empty() {
|
||||
return Err(error.into());
|
||||
}
|
||||
let Some(original) = origins.at(&path).map(|node| node.pointer.clone()) else {
|
||||
return Err(error.into());
|
||||
};
|
||||
let (reason, kind) = diagnostic_reason(error.inner());
|
||||
if !remove_config_path(&mut candidate, &mut origins, &path) {
|
||||
return Err(error.into());
|
||||
}
|
||||
diagnostics.push(ConfigDiagnostic {
|
||||
path: original.clone(),
|
||||
kind,
|
||||
reason: reason.to_string(),
|
||||
action: ConfigRecoveryAction::Ignored,
|
||||
cleanup_path: original,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let Some(mut config) = config else {
|
||||
return Err(format!(
|
||||
"configuration contains more than {MAX_CONFIG_RECOVERIES} recoverable errors"
|
||||
)
|
||||
.into());
|
||||
};
|
||||
let effective = serde_json::to_value(&config)?;
|
||||
collect_unknown_fields(&candidate, &origins, &effective, &mut diagnostics);
|
||||
|
||||
let unsupported_channels = config
|
||||
.channels
|
||||
.keys()
|
||||
.filter(|name| name.as_str() != "feishu")
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
for name in unsupported_channels {
|
||||
config.channels.remove(&name);
|
||||
let pointer = join_json_pointer("/channels", &name);
|
||||
diagnostics.push(ConfigDiagnostic {
|
||||
path: pointer.clone(),
|
||||
kind: ConfigDiagnosticKind::InvalidEntry,
|
||||
reason: "当前版本不支持此渠道配置,整个条目已忽略".to_string(),
|
||||
action: ConfigRecoveryAction::Ignored,
|
||||
cleanup_path: pointer,
|
||||
});
|
||||
}
|
||||
|
||||
let invalid_agents = config
|
||||
.agents
|
||||
.iter()
|
||||
.filter(|(name, agent)| {
|
||||
name.as_str() != "default"
|
||||
&& (!config.providers.contains_key(&agent.provider)
|
||||
|| !config.models.contains_key(&agent.model))
|
||||
})
|
||||
.map(|(name, _)| name.clone())
|
||||
.collect::<Vec<_>>();
|
||||
for name in invalid_agents {
|
||||
config.agents.remove(&name);
|
||||
let pointer = join_json_pointer("/agents", &name);
|
||||
diagnostics.push(ConfigDiagnostic {
|
||||
path: pointer.clone(),
|
||||
kind: ConfigDiagnosticKind::InvalidEntry,
|
||||
reason: "Agent 引用了不存在的 Provider 或 Model,整个条目已忽略".to_string(),
|
||||
action: ConfigRecoveryAction::Ignored,
|
||||
cleanup_path: pointer,
|
||||
});
|
||||
}
|
||||
|
||||
for channel in config.channels.values_mut() {
|
||||
if !channel.agent.is_empty() && !config.agents.contains_key(&channel.agent) {
|
||||
channel.agent.clear();
|
||||
let pointer = "/channels/feishu/agent".to_string();
|
||||
diagnostics.push(ConfigDiagnostic {
|
||||
path: pointer.clone(),
|
||||
kind: ConfigDiagnosticKind::InvalidValue,
|
||||
reason: "渠道引用了不存在的 Agent,已回退到 default".to_string(),
|
||||
action: ConfigRecoveryAction::Ignored,
|
||||
cleanup_path: pointer,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if config
|
||||
.memory
|
||||
.consolidation_provider
|
||||
.as_ref()
|
||||
.is_some_and(|provider| !config.providers.contains_key(provider))
|
||||
{
|
||||
config.memory.consolidation_provider = None;
|
||||
let pointer = "/memory/consolidation_provider".to_string();
|
||||
diagnostics.push(ConfigDiagnostic {
|
||||
path: pointer.clone(),
|
||||
kind: ConfigDiagnosticKind::InvalidValue,
|
||||
reason: "记忆配置引用了不存在的 Provider,已回退到 default".to_string(),
|
||||
action: ConfigRecoveryAction::Ignored,
|
||||
cleanup_path: pointer,
|
||||
});
|
||||
}
|
||||
if config
|
||||
.memory
|
||||
.consolidation_model
|
||||
.as_ref()
|
||||
.is_some_and(|model| !config.models.contains_key(model))
|
||||
{
|
||||
config.memory.consolidation_model = None;
|
||||
let pointer = "/memory/consolidation_model".to_string();
|
||||
diagnostics.push(ConfigDiagnostic {
|
||||
path: pointer.clone(),
|
||||
kind: ConfigDiagnosticKind::InvalidValue,
|
||||
reason: "记忆配置引用了不存在的 Model,已回退到 default".to_string(),
|
||||
action: ConfigRecoveryAction::Ignored,
|
||||
cleanup_path: pointer,
|
||||
});
|
||||
}
|
||||
|
||||
diagnostics.sort_by(|left, right| {
|
||||
let left_depth = decode_json_pointer(&left.path).map_or(usize::MAX, |parts| parts.len());
|
||||
let right_depth = decode_json_pointer(&right.path).map_or(usize::MAX, |parts| parts.len());
|
||||
left_depth
|
||||
.cmp(&right_depth)
|
||||
.then_with(|| left.path.cmp(&right.path))
|
||||
});
|
||||
let mut coalesced: Vec<ConfigDiagnostic> = Vec::new();
|
||||
for diagnostic in diagnostics {
|
||||
if !coalesced.iter().any(|existing| {
|
||||
existing.path == diagnostic.path
|
||||
|| is_pointer_ancestor(&existing.path, &diagnostic.path)
|
||||
}) {
|
||||
coalesced.push(diagnostic);
|
||||
}
|
||||
}
|
||||
coalesced.sort_by(|left, right| left.path.cmp(&right.path));
|
||||
config.diagnostics = coalesced;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub(crate) fn load_context() -> ConfigLoadContext {
|
||||
ConfigLoadContext {
|
||||
process_env: collect_process_env(),
|
||||
startup_cwd: env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load(path: &str) -> Result<Self, Box<dyn std::error::Error>> {
|
||||
Self::load_from(Path::new(path))
|
||||
}
|
||||
@ -819,8 +1320,20 @@ impl Config {
|
||||
}
|
||||
|
||||
pub(crate) fn load_from(path: &Path) -> Result<Self, Box<dyn std::error::Error>> {
|
||||
let process_env = collect_process_env();
|
||||
Self::load_from_with_process_env(path, &process_env, true, None)
|
||||
let context = Self::load_context();
|
||||
Self::load_for_startup(path, &context)
|
||||
}
|
||||
|
||||
pub(crate) fn load_for_startup(
|
||||
path: &Path,
|
||||
context: &ConfigLoadContext,
|
||||
) -> Result<Self, Box<dyn std::error::Error>> {
|
||||
Self::load_from_with_process_env(
|
||||
path,
|
||||
&context.process_env,
|
||||
true,
|
||||
Some(&context.startup_cwd),
|
||||
)
|
||||
}
|
||||
|
||||
/// Reload configuration without mutating the process environment. The
|
||||
@ -835,8 +1348,16 @@ impl Config {
|
||||
Self::load_from_with_process_env(path, startup_process_env, false, Some(startup_cwd))
|
||||
}
|
||||
|
||||
pub(crate) fn startup_process_env() -> HashMap<String, String> {
|
||||
collect_process_env()
|
||||
/// Strict management-API validation. Runtime loading is deliberately
|
||||
/// tolerant of recoverable historical fields, while WebUI writes must not
|
||||
/// introduce new ignored configuration.
|
||||
pub(crate) fn from_value_strict(value: Value) -> Result<Self, String> {
|
||||
let content = serde_json::to_string(&value).map_err(|error| error.to_string())?;
|
||||
let config = parse_config_tolerant(&content).map_err(|error| error.to_string())?;
|
||||
if let Some(diagnostic) = config.diagnostics.first() {
|
||||
return Err(format!("{}: {}", diagnostic.path, diagnostic.reason));
|
||||
}
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
fn load_from_with_process_env(
|
||||
@ -871,7 +1392,7 @@ impl Config {
|
||||
// layer first would be circular because its location comes from config.json.
|
||||
let initial_env = merge_env_layers(&config_env, &HashMap::new(), process_env);
|
||||
let initial_content = resolve_env_placeholders(&content, &initial_env);
|
||||
let initial_config: Config = serde_json::from_str(&initial_content)?;
|
||||
let initial_config = parse_config_tolerant(&initial_content)?;
|
||||
let mut workspace_path = expand_path(&initial_config.workspace_dir);
|
||||
if workspace_path.is_relative()
|
||||
&& let Some(base) = workspace_base
|
||||
@ -883,7 +1404,7 @@ impl Config {
|
||||
|
||||
let effective_env = merge_env_layers(&config_env, &workspace_env, process_env);
|
||||
let resolved_content = resolve_env_placeholders(&content, &effective_env);
|
||||
let config: Config = serde_json::from_str(&resolved_content)?;
|
||||
let mut config = parse_config_tolerant(&resolved_content)?;
|
||||
if config.workspace_dir != initial_config.workspace_dir {
|
||||
return Err(format!(
|
||||
"workspace .env cannot change workspace_dir (selected {}, resolved {})",
|
||||
@ -895,6 +1416,21 @@ impl Config {
|
||||
if apply_to_process {
|
||||
apply_env_layers(&config_env, &workspace_env, process_env);
|
||||
}
|
||||
config.source_revision = source_revision(&content);
|
||||
if !config.diagnostics.is_empty() {
|
||||
let paths = config
|
||||
.diagnostics
|
||||
.iter()
|
||||
.take(8)
|
||||
.map(|diagnostic| diagnostic.path.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
tracing::warn!(
|
||||
count = config.diagnostics.len(),
|
||||
paths,
|
||||
"Recoverable configuration entries were ignored"
|
||||
);
|
||||
}
|
||||
tracing::info!(
|
||||
path = %config_path.display(),
|
||||
config_env = %config_env_path.display(),
|
||||
@ -1271,12 +1807,12 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_compaction_config_is_strict_and_defaults_are_stable() {
|
||||
fn context_compaction_defaults_are_stable() {
|
||||
let config: ContextCompactionConfig = serde_json::from_str("{}").unwrap();
|
||||
assert!(config.enabled);
|
||||
assert_eq!(config.reserve_tokens, 16_384);
|
||||
assert_eq!(config.keep_recent_tokens, 20_000);
|
||||
assert!(serde_json::from_str::<ContextCompactionConfig>(r#"{"unknown":1}"#).is_err());
|
||||
assert!(serde_json::from_str::<ContextCompactionConfig>(r#"{"unknown":1}"#).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -1295,7 +1831,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browser_persistence_config_is_strict_and_explicit() {
|
||||
fn browser_persistence_config_is_explicit() {
|
||||
let browser: BrowserConfig = serde_json::from_str(
|
||||
r#"{
|
||||
"persistence": {
|
||||
@ -1312,10 +1848,129 @@ mod tests {
|
||||
serde_json::from_str::<BrowserConfig>(
|
||||
r#"{"persistence":{"profile_dir":"/tmp/profiles","unknown":1}}"#
|
||||
)
|
||||
.is_err()
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tolerant_load_reports_unknown_and_invalid_optional_fields() {
|
||||
let file = write_test_config();
|
||||
let mut value: Value =
|
||||
serde_json::from_str(&fs::read_to_string(file.path()).unwrap()).unwrap();
|
||||
value["browser"] = serde_json::json!({
|
||||
"idle_timeout_secs": "old-value",
|
||||
"legacy_auto_close": true
|
||||
});
|
||||
fs::write(file.path(), serde_json::to_vec_pretty(&value).unwrap()).unwrap();
|
||||
|
||||
let config = Config::load(file.path().to_str().unwrap()).unwrap();
|
||||
|
||||
assert_eq!(config.browser.idle_timeout_secs, 60 * 60);
|
||||
assert_eq!(
|
||||
config
|
||||
.diagnostics
|
||||
.iter()
|
||||
.map(|diagnostic| diagnostic.path.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["/browser/idle_timeout_secs", "/browser/legacy_auto_close"]
|
||||
);
|
||||
assert!(Config::from_value_strict(value).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tolerant_load_drops_broken_unused_named_entry_but_not_default_chain() {
|
||||
let file = write_test_config();
|
||||
let mut value: Value =
|
||||
serde_json::from_str(&fs::read_to_string(file.path()).unwrap()).unwrap();
|
||||
value["models"]["broken"] = serde_json::json!({ "model_id": 42 });
|
||||
fs::write(file.path(), serde_json::to_vec_pretty(&value).unwrap()).unwrap();
|
||||
|
||||
let config = Config::load(file.path().to_str().unwrap()).unwrap();
|
||||
assert!(!config.models.contains_key("broken"));
|
||||
assert!(config.get_provider_config("default").is_ok());
|
||||
assert!(
|
||||
config
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(|diagnostic| diagnostic.path == "/models/broken")
|
||||
);
|
||||
|
||||
value["models"]["qwen-plus"]["model_id"] = serde_json::json!(42);
|
||||
fs::write(file.path(), serde_json::to_vec_pretty(&value).unwrap()).unwrap();
|
||||
let config = Config::load(file.path().to_str().unwrap()).unwrap();
|
||||
assert!(config.get_provider_config("default").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_uses_original_array_indexes_and_preserves_valid_entries() {
|
||||
let file = write_test_config();
|
||||
let mut value: Value =
|
||||
serde_json::from_str(&fs::read_to_string(file.path()).unwrap()).unwrap();
|
||||
value["mcp"] = serde_json::json!({
|
||||
"servers": [
|
||||
{ "name": 42 },
|
||||
{ "name": "kept-one", "legacy": true },
|
||||
{ "name": "kept-two", "transport": "retired-transport" },
|
||||
{ "name": false }
|
||||
]
|
||||
});
|
||||
fs::write(file.path(), serde_json::to_vec_pretty(&value).unwrap()).unwrap();
|
||||
|
||||
let config = Config::load(file.path().to_str().unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
config
|
||||
.mcp
|
||||
.servers
|
||||
.iter()
|
||||
.map(|server| server.name.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["kept-one", "kept-two"]
|
||||
);
|
||||
assert!(
|
||||
config
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(|diagnostic| diagnostic.path == "/mcp/servers/1/legacy")
|
||||
);
|
||||
assert!(
|
||||
config
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(|diagnostic| diagnostic.path == "/mcp/servers/2/transport")
|
||||
);
|
||||
|
||||
assert_eq!(cleanup_invalid_config(&mut value, &config.diagnostics), 4);
|
||||
let servers = value["mcp"]["servers"].as_array().unwrap();
|
||||
assert_eq!(servers.len(), 2);
|
||||
assert_eq!(servers[0]["name"], "kept-one");
|
||||
assert!(servers[0].get("legacy").is_none());
|
||||
assert_eq!(servers[1]["name"], "kept-two");
|
||||
assert!(servers[1].get("transport").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diagnostic_and_cleanup_paths_escape_dynamic_map_keys() {
|
||||
let file = write_test_config();
|
||||
let mut value: Value =
|
||||
serde_json::from_str(&fs::read_to_string(file.path()).unwrap()).unwrap();
|
||||
value["agents"]["retired/name~old"] = serde_json::json!({
|
||||
"provider": "missing",
|
||||
"model": "qwen-plus"
|
||||
});
|
||||
fs::write(file.path(), serde_json::to_vec_pretty(&value).unwrap()).unwrap();
|
||||
|
||||
let config = Config::load(file.path().to_str().unwrap()).unwrap();
|
||||
let pointer = "/agents/retired~1name~0old";
|
||||
assert!(
|
||||
config
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(|diagnostic| diagnostic.path == pointer)
|
||||
);
|
||||
assert_eq!(cleanup_invalid_config(&mut value, &config.diagnostics), 1);
|
||||
assert!(value["agents"].get("retired/name~old").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_tool_settings_default_to_conservative_and_derive_concurrency() {
|
||||
let server: McpServerConfig = serde_json::from_str(
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
use super::GatewayState;
|
||||
use crate::config::Config;
|
||||
use crate::config::{Config, ConfigDiagnostic, cleanup_invalid_config, source_revision};
|
||||
use crate::memory::MemoryCategory;
|
||||
use axum::Json;
|
||||
use axum::body::Body;
|
||||
@ -44,6 +44,12 @@ pub async fn health() -> Json<HealthResponse> {
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn health_report(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
) -> Json<crate::health::HealthReport> {
|
||||
Json(state.health.check().await)
|
||||
}
|
||||
|
||||
fn static_response(content_type: &'static str, content: &'static str) -> Response {
|
||||
Response::builder()
|
||||
.header(header::CONTENT_TYPE, content_type)
|
||||
@ -407,9 +413,16 @@ impl IntoResponse for ApiError {
|
||||
pub struct ConfigResponse {
|
||||
config: Value,
|
||||
path: String,
|
||||
revision: String,
|
||||
diagnostics: Vec<ConfigDiagnostic>,
|
||||
restart_required: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CleanupInvalidConfigRequest {
|
||||
revision: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ReloadResponse {
|
||||
generation: u64,
|
||||
@ -443,14 +456,32 @@ pub async fn reload_status(
|
||||
pub async fn get_config(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
) -> Result<Json<ConfigResponse>, ApiError> {
|
||||
let _write_guard = state.config_write_lock.lock().await;
|
||||
let raw = tokio::fs::read_to_string(&state.config_path)
|
||||
.await
|
||||
.map_err(ApiError::internal)?;
|
||||
let revision = source_revision(&raw);
|
||||
let path = state.config_path.clone();
|
||||
let context = state.config_load_context.clone();
|
||||
let inspected = tokio::task::spawn_blocking(move || {
|
||||
Config::load_for_reload(&path, &context.process_env, &context.startup_cwd)
|
||||
.map_err(|error| error.to_string())
|
||||
})
|
||||
.await
|
||||
.map_err(ApiError::internal)?
|
||||
.map_err(ApiError::bad_request)?;
|
||||
if inspected.source_revision != revision {
|
||||
return Err(ApiError::conflict(
|
||||
"config.json changed while it was being inspected; retry",
|
||||
));
|
||||
}
|
||||
let mut value: Value = serde_json::from_str(&raw).map_err(ApiError::internal)?;
|
||||
redact_secrets(&mut value);
|
||||
Ok(Json(ConfigResponse {
|
||||
config: value,
|
||||
path: state.config_path.display().to_string(),
|
||||
revision,
|
||||
diagnostics: inspected.diagnostics,
|
||||
restart_required: false,
|
||||
}))
|
||||
}
|
||||
@ -459,6 +490,7 @@ pub async fn put_config(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
Json(mut incoming): Json<Value>,
|
||||
) -> Result<Json<ConfigResponse>, ApiError> {
|
||||
let _write_guard = state.config_write_lock.lock().await;
|
||||
if incoming.get("config").is_some() {
|
||||
incoming = incoming
|
||||
.get_mut("config")
|
||||
@ -477,7 +509,7 @@ pub async fn put_config(
|
||||
.map_err(ApiError::internal)?;
|
||||
let current: Value = serde_json::from_str(¤t_raw).map_err(ApiError::internal)?;
|
||||
restore_redacted_secrets(&mut incoming, ¤t);
|
||||
let parsed: Config = serde_json::from_value(incoming.clone())
|
||||
let parsed = Config::from_value_strict(incoming.clone())
|
||||
.map_err(|error| ApiError::bad_request(format!("invalid config: {error}")))?;
|
||||
parsed
|
||||
.get_provider_config("default")
|
||||
@ -492,10 +524,78 @@ pub async fn put_config(
|
||||
Ok(Json(ConfigResponse {
|
||||
config: response,
|
||||
path: state.config_path.display().to_string(),
|
||||
revision: source_revision(&pretty),
|
||||
diagnostics: Vec::new(),
|
||||
restart_required: true,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn cleanup_invalid_config_entries(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
Json(request): Json<CleanupInvalidConfigRequest>,
|
||||
) -> Result<Json<ConfigResponse>, ApiError> {
|
||||
let _write_guard = state.config_write_lock.lock().await;
|
||||
let raw = tokio::fs::read_to_string(&state.config_path)
|
||||
.await
|
||||
.map_err(ApiError::internal)?;
|
||||
let current_revision = source_revision(&raw);
|
||||
if request.revision != current_revision {
|
||||
return Err(ApiError::conflict(
|
||||
"config.json changed after it was displayed; reload the page before cleaning",
|
||||
));
|
||||
}
|
||||
|
||||
let path = state.config_path.clone();
|
||||
let context = state.config_load_context.clone();
|
||||
let inspected = tokio::task::spawn_blocking(move || {
|
||||
Config::load_for_reload(&path, &context.process_env, &context.startup_cwd)
|
||||
.map_err(|error| error.to_string())
|
||||
})
|
||||
.await
|
||||
.map_err(ApiError::internal)?
|
||||
.map_err(ApiError::bad_request)?;
|
||||
if inspected.source_revision != current_revision {
|
||||
return Err(ApiError::conflict(
|
||||
"config.json changed while it was being inspected; retry",
|
||||
));
|
||||
}
|
||||
|
||||
let mut value: Value = serde_json::from_str(&raw).map_err(ApiError::internal)?;
|
||||
let removed = cleanup_invalid_config(&mut value, &inspected.diagnostics);
|
||||
let pretty = serde_json::to_string_pretty(&value).map_err(ApiError::internal)? + "\n";
|
||||
if removed > 0 {
|
||||
atomic_write(&state.config_path, pretty.as_bytes()).await?;
|
||||
tracing::info!(
|
||||
path = %state.config_path.display(),
|
||||
removed,
|
||||
"Invalid configuration entries removed from WebUI"
|
||||
);
|
||||
}
|
||||
|
||||
let post_cleanup = if removed > 0 {
|
||||
let path = state.config_path.clone();
|
||||
let context = state.config_load_context.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
Config::load_for_reload(&path, &context.process_env, &context.startup_cwd)
|
||||
.map_err(|error| error.to_string())
|
||||
})
|
||||
.await
|
||||
.map_err(ApiError::internal)?
|
||||
.map_err(ApiError::bad_request)?
|
||||
} else {
|
||||
inspected
|
||||
};
|
||||
let mut response = value;
|
||||
redact_secrets(&mut response);
|
||||
Ok(Json(ConfigResponse {
|
||||
config: response,
|
||||
path: state.config_path.display().to_string(),
|
||||
revision: post_cleanup.source_revision,
|
||||
diagnostics: post_cleanup.diagnostics,
|
||||
restart_required: removed > 0,
|
||||
}))
|
||||
}
|
||||
|
||||
fn is_secret_key(key: &str) -> bool {
|
||||
let key = key.to_ascii_lowercase();
|
||||
key.contains("api_key")
|
||||
|
||||
@ -13,7 +13,7 @@ use tokio::net::TcpListener;
|
||||
|
||||
use crate::bus::{MessageBus, OutboundDispatcher};
|
||||
use crate::channels::{ChannelManager, CliChatChannel};
|
||||
use crate::config::{Config, ensure_workspace_dir, expand_path};
|
||||
use crate::config::{Config, ConfigLoadContext, ensure_workspace_dir, expand_path};
|
||||
use crate::delivery::{ConversationWriteLocks, DeliveryCoordinator, TurnDeliveryService};
|
||||
use crate::logging;
|
||||
use crate::mcp;
|
||||
@ -37,7 +37,10 @@ pub fn process_uptime_secs() -> u64 {
|
||||
pub struct GatewayState {
|
||||
pub config: Config,
|
||||
pub config_path: std::path::PathBuf,
|
||||
pub(crate) config_load_context: Arc<ConfigLoadContext>,
|
||||
pub(crate) config_write_lock: Arc<tokio::sync::Mutex<()>>,
|
||||
pub workspace_dir: std::path::PathBuf,
|
||||
pub(crate) health: Arc<crate::health::HealthService>,
|
||||
pub session_manager: Arc<SessionManager>,
|
||||
pub channel_manager: ChannelManager,
|
||||
pub storage: Arc<crate::storage::Storage>,
|
||||
@ -62,10 +65,13 @@ impl GatewayState {
|
||||
/// when the state is owned by [`run`], which owns the generation loop.
|
||||
pub async fn new() -> Result<Self, Box<dyn std::error::Error>> {
|
||||
let config_path = crate::config::resolve_default_config_path();
|
||||
let config = Config::load_from(&config_path)?;
|
||||
let config_load_context = Arc::new(Config::load_context());
|
||||
let config = Config::load_for_startup(&config_path, &config_load_context)?;
|
||||
Self::from_config(
|
||||
config,
|
||||
config_path,
|
||||
config_load_context,
|
||||
Arc::new(tokio::sync::Mutex::new(())),
|
||||
reload::ReloadHandle::unavailable(),
|
||||
true,
|
||||
1,
|
||||
@ -76,6 +82,8 @@ impl GatewayState {
|
||||
async fn from_config(
|
||||
config: Config,
|
||||
config_path: std::path::PathBuf,
|
||||
config_load_context: Arc<ConfigLoadContext>,
|
||||
config_write_lock: Arc<tokio::sync::Mutex<()>>,
|
||||
reload: reload::ReloadHandle,
|
||||
initialize_process: bool,
|
||||
runtime_generation: u64,
|
||||
@ -232,7 +240,7 @@ impl GatewayState {
|
||||
)
|
||||
.with_admission(admission.clone()),
|
||||
browser_config,
|
||||
health,
|
||||
health.clone(),
|
||||
)?;
|
||||
let session_manager = Arc::new(session_manager);
|
||||
session_manager.bind_inbox_wake();
|
||||
@ -292,7 +300,10 @@ impl GatewayState {
|
||||
Ok(Self {
|
||||
config,
|
||||
config_path,
|
||||
config_load_context,
|
||||
config_write_lock,
|
||||
workspace_dir: workspace_path,
|
||||
health,
|
||||
session_manager: session_manager.clone(),
|
||||
channel_manager,
|
||||
storage,
|
||||
@ -459,19 +470,38 @@ pub async fn run(
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
STARTED.get_or_init(std::time::Instant::now);
|
||||
let config_path = crate::config::resolve_default_config_path();
|
||||
let startup_process_env = Config::startup_process_env();
|
||||
let startup_cwd = std::env::current_dir()?;
|
||||
let config = Config::load_from(&config_path)?;
|
||||
let config_load_context = Arc::new(Config::load_context());
|
||||
let config = Config::load_for_startup(&config_path, &config_load_context)?;
|
||||
|
||||
// Initialize logging
|
||||
logging::init_logging();
|
||||
tracing::info!(config_path = %config_path.display(), "Starting PicoBot Gateway");
|
||||
if !config.diagnostics.is_empty() {
|
||||
let paths = config
|
||||
.diagnostics
|
||||
.iter()
|
||||
.take(8)
|
||||
.map(|diagnostic| diagnostic.path.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
tracing::warn!(
|
||||
count = config.diagnostics.len(),
|
||||
paths,
|
||||
"Gateway started with recoverable configuration entries ignored"
|
||||
);
|
||||
}
|
||||
|
||||
let mut reload_controller = reload::ReloadController::new(startup_process_env, startup_cwd);
|
||||
let mut reload_controller = reload::ReloadController::new(
|
||||
config_load_context.process_env.clone(),
|
||||
config_load_context.startup_cwd.clone(),
|
||||
);
|
||||
let config_write_lock = Arc::new(tokio::sync::Mutex::new(()));
|
||||
let mut state = Arc::new(
|
||||
GatewayState::from_config(
|
||||
config,
|
||||
config_path.clone(),
|
||||
config_load_context.clone(),
|
||||
config_write_lock.clone(),
|
||||
reload_controller.handle.clone(),
|
||||
true,
|
||||
1,
|
||||
@ -536,13 +566,17 @@ pub async fn run(
|
||||
};
|
||||
let requested_generation = request.generation;
|
||||
reload_controller.set_phase(requested_generation, reload::ReloadPhase::Preparing);
|
||||
let candidate = match reload::load_candidate(
|
||||
&config_path,
|
||||
&reload_controller.startup_process_env,
|
||||
&reload_controller.startup_cwd,
|
||||
&state.config,
|
||||
&state.workspace_dir,
|
||||
) {
|
||||
let candidate_result = {
|
||||
let _write_guard = state.config_write_lock.lock().await;
|
||||
reload::load_candidate(
|
||||
&config_path,
|
||||
&reload_controller.startup_process_env,
|
||||
&reload_controller.startup_cwd,
|
||||
&state.config,
|
||||
&state.workspace_dir,
|
||||
)
|
||||
};
|
||||
let candidate = match candidate_result {
|
||||
Ok(candidate) => candidate,
|
||||
Err(error) => {
|
||||
reload_controller.set_failed(requested_generation, error.to_string());
|
||||
@ -553,6 +587,8 @@ pub async fn run(
|
||||
let preparation = GatewayState::from_config(
|
||||
candidate,
|
||||
config_path.clone(),
|
||||
config_load_context.clone(),
|
||||
config_write_lock.clone(),
|
||||
reload_controller.handle.clone(),
|
||||
false,
|
||||
requested_generation,
|
||||
@ -675,11 +711,15 @@ pub async fn run(
|
||||
|
||||
fn build_router(state: Arc<GatewayState>) -> Router {
|
||||
let protected = Router::new()
|
||||
.route("/api/health", routing::get(http::health))
|
||||
.route("/api/health", routing::get(http::health_report))
|
||||
.route(
|
||||
"/api/config",
|
||||
routing::get(http::get_config).put(http::put_config),
|
||||
)
|
||||
.route(
|
||||
"/api/config/cleanup-invalid",
|
||||
routing::post(http::cleanup_invalid_config_entries),
|
||||
)
|
||||
.route("/api/config/reload", routing::post(http::reload_config))
|
||||
.route(
|
||||
"/api/config/reload/status",
|
||||
|
||||
480
src/health.rs
480
src/health.rs
@ -1,9 +1,9 @@
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
use std::process::Stdio;
|
||||
use std::process::{Output, Stdio};
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::Serialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::process::Command;
|
||||
|
||||
use crate::config::{Config, McpTransport, expand_path};
|
||||
@ -126,14 +126,15 @@ impl HealthService {
|
||||
|
||||
pub async fn check(&self) -> HealthReport {
|
||||
let mut checks = vec![
|
||||
check_configuration_recovery(&self.config),
|
||||
check_workspace(&self.config),
|
||||
check_required_binary(
|
||||
"bash",
|
||||
"core",
|
||||
"Install Bash and make it available on PATH.",
|
||||
),
|
||||
check_search_backend("content search", &["rg", "grep"], "rg"),
|
||||
check_search_backend("file search", &["fd", "fdfind", "find"], "fd"),
|
||||
check_search_backend("content search", &["rg", "grep"], &["rg"]),
|
||||
check_search_backend("file search", &["fd", "fdfind", "find"], &["fd", "fdfind"]),
|
||||
check_optional_binary("systemd service management", "systemctl", "service"),
|
||||
];
|
||||
checks.extend(self.check_mcp_commands());
|
||||
@ -241,7 +242,7 @@ impl HealthService {
|
||||
let version = command_output(
|
||||
&browser.command,
|
||||
&["--version"],
|
||||
None,
|
||||
&[],
|
||||
Duration::from_secs(5),
|
||||
)
|
||||
.await;
|
||||
@ -287,7 +288,7 @@ impl HealthService {
|
||||
} else {
|
||||
expand_path(&self.config.workspace_dir).join(path)
|
||||
};
|
||||
let installed = path.is_file();
|
||||
let installed = executable_file(&path);
|
||||
checks.push(HealthCheck {
|
||||
name: "configured browser executable".to_string(),
|
||||
category: "configured".to_string(),
|
||||
@ -298,13 +299,12 @@ impl HealthService {
|
||||
HealthStatus::Fail
|
||||
},
|
||||
detail: if installed {
|
||||
format!("found at {}", path.display())
|
||||
format!("executable file found at {}", path.display())
|
||||
} else {
|
||||
format!("not found at {}", path.display())
|
||||
format!("missing or not executable at {}", path.display())
|
||||
},
|
||||
remediation: (!installed).then(|| {
|
||||
"Fix browser.browser_executable_path or run `agent-browser install`."
|
||||
.to_string()
|
||||
"Fix browser.browser_executable_path and its execute permissions, or run `agent-browser install`.".to_string()
|
||||
}),
|
||||
});
|
||||
}
|
||||
@ -321,41 +321,83 @@ impl HealthService {
|
||||
}
|
||||
})
|
||||
.map(|path| path.to_string_lossy().into_owned());
|
||||
let doctor_env = doctor_executable
|
||||
let doctor_executable_env = doctor_executable
|
||||
.as_deref()
|
||||
.map(|path| ("AGENT_BROWSER_EXECUTABLE_PATH", path));
|
||||
let doctor = command_output(
|
||||
let doctor_socket_dir = match tempfile::Builder::new()
|
||||
.prefix("picobot-health-agent-browser-")
|
||||
.tempdir()
|
||||
{
|
||||
Ok(directory) => directory,
|
||||
Err(error) => {
|
||||
checks.push(agent_browser_doctor_failure(format!(
|
||||
"failed to create isolated doctor directory: {error}"
|
||||
)));
|
||||
return checks;
|
||||
}
|
||||
};
|
||||
let socket_dir = doctor_socket_dir.path().to_string_lossy().into_owned();
|
||||
let mut doctor_env = vec![("AGENT_BROWSER_SOCKET_DIR", socket_dir.as_str())];
|
||||
if let Some(env) = doctor_executable_env {
|
||||
doctor_env.push(env);
|
||||
}
|
||||
let doctor = capture_command(
|
||||
&browser.command,
|
||||
&["doctor", "--offline", "--quick", "--json"],
|
||||
doctor_env,
|
||||
Duration::from_secs(15),
|
||||
&[
|
||||
"--namespace",
|
||||
"picobot-health",
|
||||
"doctor",
|
||||
"--offline",
|
||||
"--json",
|
||||
],
|
||||
&doctor_env,
|
||||
Duration::from_secs(30),
|
||||
)
|
||||
.await;
|
||||
checks.push(match doctor {
|
||||
Ok(output) => HealthCheck {
|
||||
name: "agent-browser runtime".to_string(),
|
||||
category: "configured".to_string(),
|
||||
required: true,
|
||||
status: HealthStatus::Pass,
|
||||
detail: summarize_output(&output),
|
||||
remediation: None,
|
||||
match doctor {
|
||||
Ok(output) => match parse_agent_browser_doctor(&output) {
|
||||
Ok(report) => checks.extend(agent_browser_doctor_checks(&report)),
|
||||
Err(error) => checks.push(agent_browser_doctor_failure(error)),
|
||||
},
|
||||
Err(error) => HealthCheck {
|
||||
name: "agent-browser runtime".to_string(),
|
||||
category: "configured".to_string(),
|
||||
required: true,
|
||||
status: HealthStatus::Fail,
|
||||
detail: error,
|
||||
remediation: Some(
|
||||
"Run `agent-browser doctor`, then `agent-browser install --with-deps` on Linux or `agent-browser install` on other platforms."
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
});
|
||||
Err(error) => checks.push(agent_browser_doctor_failure(error)),
|
||||
}
|
||||
checks
|
||||
}
|
||||
}
|
||||
|
||||
fn check_configuration_recovery(config: &Config) -> HealthCheck {
|
||||
if config.diagnostics.is_empty() {
|
||||
return HealthCheck {
|
||||
name: "configuration compatibility".to_string(),
|
||||
category: "core".to_string(),
|
||||
required: false,
|
||||
status: HealthStatus::Pass,
|
||||
detail: "no recoverable configuration problems detected".to_string(),
|
||||
remediation: None,
|
||||
};
|
||||
}
|
||||
let paths = config
|
||||
.diagnostics
|
||||
.iter()
|
||||
.take(5)
|
||||
.map(|diagnostic| diagnostic.path.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
HealthCheck {
|
||||
name: "configuration compatibility".to_string(),
|
||||
category: "core".to_string(),
|
||||
required: false,
|
||||
status: HealthStatus::Warning,
|
||||
detail: format!(
|
||||
"ignored {} recoverable configuration item(s): {paths}",
|
||||
config.diagnostics.len()
|
||||
),
|
||||
remediation: Some(
|
||||
"Review and clean invalid entries in WebUI Settings → config.json.".to_string(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn check_workspace(config: &Config) -> HealthCheck {
|
||||
let workspace = expand_path(&config.workspace_dir);
|
||||
let exists = workspace.is_dir();
|
||||
@ -398,25 +440,9 @@ fn check_required_binary(name: &str, category: &str, remediation: &str) -> Healt
|
||||
}
|
||||
}
|
||||
|
||||
fn check_search_backend(name: &str, candidates: &[&str], preferred: &str) -> HealthCheck {
|
||||
fn check_search_backend(name: &str, candidates: &[&str], preferred: &[&str]) -> HealthCheck {
|
||||
let found = candidates.iter().copied().find(|name| command_exists(name));
|
||||
let (status, detail, remediation) = match found {
|
||||
Some(found) if found == preferred => (
|
||||
HealthStatus::Pass,
|
||||
format!("using preferred backend {found}"),
|
||||
None,
|
||||
),
|
||||
Some(found) => (
|
||||
HealthStatus::Warning,
|
||||
format!("using fallback backend {found}"),
|
||||
Some(format!("Install {preferred} for faster searches.")),
|
||||
),
|
||||
None => (
|
||||
HealthStatus::Fail,
|
||||
"no supported backend found".to_string(),
|
||||
Some(format!("Install one of: {}.", candidates.join(", "))),
|
||||
),
|
||||
};
|
||||
let (status, detail, remediation) = search_backend_result(found, candidates, preferred);
|
||||
HealthCheck {
|
||||
name: name.to_string(),
|
||||
category: "core".to_string(),
|
||||
@ -427,6 +453,33 @@ fn check_search_backend(name: &str, candidates: &[&str], preferred: &str) -> Hea
|
||||
}
|
||||
}
|
||||
|
||||
fn search_backend_result(
|
||||
found: Option<&str>,
|
||||
candidates: &[&str],
|
||||
preferred: &[&str],
|
||||
) -> (HealthStatus, String, Option<String>) {
|
||||
match found {
|
||||
Some(found) if preferred.contains(&found) => (
|
||||
HealthStatus::Pass,
|
||||
format!("using preferred backend {found}"),
|
||||
None,
|
||||
),
|
||||
Some(found) => (
|
||||
HealthStatus::Warning,
|
||||
format!("using fallback backend {found}"),
|
||||
Some(format!(
|
||||
"Install {} for faster searches.",
|
||||
preferred.join(" or ")
|
||||
)),
|
||||
),
|
||||
None => (
|
||||
HealthStatus::Fail,
|
||||
"no supported backend found".to_string(),
|
||||
Some(format!("Install one of: {}.", candidates.join(", "))),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn check_optional_binary(name: &str, binary: &str, category: &str) -> HealthCheck {
|
||||
let installed = command_exists(binary);
|
||||
HealthCheck {
|
||||
@ -445,32 +498,57 @@ fn check_optional_binary(name: &str, binary: &str, category: &str) -> HealthChec
|
||||
|
||||
fn command_exists(command: &str) -> bool {
|
||||
if command.contains(std::path::MAIN_SEPARATOR) {
|
||||
Path::new(command).is_file()
|
||||
executable_file(Path::new(command))
|
||||
} else {
|
||||
which::which(command).is_ok()
|
||||
}
|
||||
}
|
||||
|
||||
async fn command_output(
|
||||
fn executable_file(path: &Path) -> bool {
|
||||
let Ok(metadata) = path.metadata() else {
|
||||
return false;
|
||||
};
|
||||
if !metadata.is_file() {
|
||||
return false;
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
metadata.permissions().mode() & 0o111 != 0
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
async fn capture_command(
|
||||
command: &str,
|
||||
args: &[&str],
|
||||
env: Option<(&str, &str)>,
|
||||
env: &[(&str, &str)],
|
||||
timeout: Duration,
|
||||
) -> Result<String, String> {
|
||||
) -> Result<Output, String> {
|
||||
let mut process = Command::new(command);
|
||||
process
|
||||
.args(args)
|
||||
.envs(env.iter().copied())
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true);
|
||||
if let Some((key, value)) = env {
|
||||
process.env(key, value);
|
||||
}
|
||||
let output = tokio::time::timeout(timeout, process.output())
|
||||
tokio::time::timeout(timeout, process.output())
|
||||
.await
|
||||
.map_err(|_| format!("command timed out after {} seconds", timeout.as_secs()))?
|
||||
.map_err(|error| format!("failed to start: {error}"))?;
|
||||
.map_err(|error| format!("failed to start: {error}"))
|
||||
}
|
||||
|
||||
async fn command_output(
|
||||
command: &str,
|
||||
args: &[&str],
|
||||
env: &[(&str, &str)],
|
||||
timeout: Duration,
|
||||
) -> Result<String, String> {
|
||||
let output = capture_command(command, args, env, timeout).await?;
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
if !output.status.success() {
|
||||
@ -493,6 +571,171 @@ async fn command_output(
|
||||
Ok(truncate(combined, 4_000))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AgentBrowserDoctorReport {
|
||||
#[serde(default)]
|
||||
checks: Vec<AgentBrowserDoctorCheck>,
|
||||
#[serde(default)]
|
||||
success: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AgentBrowserDoctorCheck {
|
||||
id: String,
|
||||
message: String,
|
||||
status: String,
|
||||
}
|
||||
|
||||
fn parse_agent_browser_doctor(output: &Output) -> Result<AgentBrowserDoctorReport, String> {
|
||||
const MAX_DOCTOR_OUTPUT_BYTES: usize = 64 * 1024;
|
||||
if output.stdout.len() > MAX_DOCTOR_OUTPUT_BYTES {
|
||||
return Err(format!(
|
||||
"agent-browser doctor returned more than {MAX_DOCTOR_OUTPUT_BYTES} bytes"
|
||||
));
|
||||
}
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
match serde_json::from_str::<AgentBrowserDoctorReport>(stdout.trim()) {
|
||||
Ok(report) => Ok(report),
|
||||
Err(error) => {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let detail = if stderr.trim().is_empty() {
|
||||
stdout.trim()
|
||||
} else {
|
||||
stderr.trim()
|
||||
};
|
||||
Err(format!(
|
||||
"agent-browser doctor returned invalid JSON ({error}): {}",
|
||||
truncate(detail, 1_000)
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn agent_browser_doctor_checks(report: &AgentBrowserDoctorReport) -> Vec<HealthCheck> {
|
||||
let mut installation = doctor_named_check(
|
||||
report,
|
||||
"browser installation",
|
||||
|check| check.id == "chrome.installed",
|
||||
"Run `agent-browser install`, or configure browser.browser_executable_path.",
|
||||
);
|
||||
let launch = doctor_named_check(
|
||||
report,
|
||||
"browser headless launch",
|
||||
|check| check.id.starts_with("launch."),
|
||||
"Run `agent-browser doctor --debug`, then `agent-browser install --with-deps` on Linux or `agent-browser install` on other platforms.",
|
||||
);
|
||||
if installation.status == HealthStatus::Fail && launch.status == HealthStatus::Pass {
|
||||
installation.status = HealthStatus::Pass;
|
||||
installation.detail =
|
||||
"headless launch confirmed an available configured or system browser".to_string();
|
||||
installation.remediation = None;
|
||||
}
|
||||
|
||||
let other_issues = report
|
||||
.checks
|
||||
.iter()
|
||||
.filter(|check| {
|
||||
check.id != "chrome.installed"
|
||||
&& !check.id.starts_with("launch.")
|
||||
&& matches!(check.status.as_str(), "warn" | "warning" | "fail")
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let unexplained_failure = !report.success
|
||||
&& !report
|
||||
.checks
|
||||
.iter()
|
||||
.any(|check| check.status.as_str() == "fail");
|
||||
let runtime_status = if other_issues
|
||||
.iter()
|
||||
.any(|check| check.status.as_str() == "fail")
|
||||
|| unexplained_failure
|
||||
{
|
||||
HealthStatus::Fail
|
||||
} else if other_issues.is_empty() {
|
||||
HealthStatus::Pass
|
||||
} else {
|
||||
HealthStatus::Warning
|
||||
};
|
||||
let runtime_detail = if other_issues.is_empty() {
|
||||
if runtime_status == HealthStatus::Pass {
|
||||
"isolated offline doctor completed without environment warnings".to_string()
|
||||
} else {
|
||||
"doctor reported failure without a structured failing check".to_string()
|
||||
}
|
||||
} else {
|
||||
let details = other_issues
|
||||
.iter()
|
||||
.take(3)
|
||||
.map(|check| format!("{}: {}", check.id, check.message))
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ");
|
||||
truncate(
|
||||
&format!("{} environment issue(s): {details}", other_issues.len()),
|
||||
1_000,
|
||||
)
|
||||
};
|
||||
let runtime = HealthCheck {
|
||||
name: "agent-browser environment".to_string(),
|
||||
category: "configured".to_string(),
|
||||
required: true,
|
||||
status: runtime_status,
|
||||
detail: runtime_detail,
|
||||
remediation: (runtime_status != HealthStatus::Pass).then(|| {
|
||||
"Run `agent-browser doctor --debug` to inspect the reported environment checks."
|
||||
.to_string()
|
||||
}),
|
||||
};
|
||||
|
||||
vec![installation, launch, runtime]
|
||||
}
|
||||
|
||||
fn doctor_named_check(
|
||||
report: &AgentBrowserDoctorReport,
|
||||
name: &str,
|
||||
predicate: impl Fn(&AgentBrowserDoctorCheck) -> bool,
|
||||
remediation: &str,
|
||||
) -> HealthCheck {
|
||||
let found = report.checks.iter().find(|check| predicate(check));
|
||||
let (status, detail) = match found {
|
||||
Some(check) => (doctor_status(&check.status), check.message.clone()),
|
||||
None => (
|
||||
HealthStatus::Fail,
|
||||
format!("agent-browser doctor did not report {name}"),
|
||||
),
|
||||
};
|
||||
HealthCheck {
|
||||
name: name.to_string(),
|
||||
category: "configured".to_string(),
|
||||
required: true,
|
||||
status,
|
||||
detail,
|
||||
remediation: (status != HealthStatus::Pass).then(|| remediation.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn doctor_status(status: &str) -> HealthStatus {
|
||||
match status {
|
||||
"pass" | "info" => HealthStatus::Pass,
|
||||
"warn" | "warning" => HealthStatus::Warning,
|
||||
"fail" => HealthStatus::Fail,
|
||||
_ => HealthStatus::Warning,
|
||||
}
|
||||
}
|
||||
|
||||
fn agent_browser_doctor_failure(detail: String) -> HealthCheck {
|
||||
HealthCheck {
|
||||
name: "agent-browser runtime".to_string(),
|
||||
category: "configured".to_string(),
|
||||
required: true,
|
||||
status: HealthStatus::Fail,
|
||||
detail,
|
||||
remediation: Some(
|
||||
"Run `agent-browser doctor --debug`, then `agent-browser install --with-deps` on Linux or `agent-browser install` on other platforms."
|
||||
.to_string(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_version(output: &str) -> Option<String> {
|
||||
output
|
||||
.split_whitespace()
|
||||
@ -508,22 +751,6 @@ fn extract_version(output: &str) -> Option<String> {
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
fn summarize_output(output: &str) -> String {
|
||||
if let Ok(json) = serde_json::from_str::<serde_json::Value>(output)
|
||||
&& let Some(summary) = json
|
||||
.get("summary")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.or_else(|| json.get("message").and_then(serde_json::Value::as_str))
|
||||
{
|
||||
return truncate(summary, 500);
|
||||
}
|
||||
let first_line = output
|
||||
.lines()
|
||||
.find(|line| !line.trim().is_empty())
|
||||
.unwrap_or("ok");
|
||||
truncate(first_line, 500)
|
||||
}
|
||||
|
||||
fn truncate(value: &str, max: usize) -> String {
|
||||
if value.len() <= max {
|
||||
value.to_string()
|
||||
@ -550,6 +777,103 @@ mod tests {
|
||||
assert!(!report.is_usable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_serializes_the_management_api_contract() {
|
||||
let report = HealthReport::from_checks(vec![HealthCheck {
|
||||
name: "content search".into(),
|
||||
category: "core".into(),
|
||||
required: true,
|
||||
status: HealthStatus::Warning,
|
||||
detail: "using fallback backend".into(),
|
||||
remediation: Some("Install rg.".into()),
|
||||
}]);
|
||||
|
||||
let value = serde_json::to_value(report).unwrap();
|
||||
assert_eq!(value["version"], env!("CARGO_PKG_VERSION"));
|
||||
assert_eq!(value["overall"], "degraded");
|
||||
assert_eq!(value["checks"][0]["status"], "warning");
|
||||
assert_eq!(value["checks"][0]["required"], true);
|
||||
assert_eq!(value["checks"][0]["remediation"], "Install rg.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fdfind_is_a_preferred_file_search_backend() {
|
||||
let (status, detail, remediation) =
|
||||
search_backend_result(Some("fdfind"), &["fd", "fdfind", "find"], &["fd", "fdfind"]);
|
||||
|
||||
assert_eq!(status, HealthStatus::Pass);
|
||||
assert_eq!(detail, "using preferred backend fdfind");
|
||||
assert_eq!(remediation, None);
|
||||
|
||||
let (status, _, remediation) =
|
||||
search_backend_result(Some("find"), &["fd", "fdfind", "find"], &["fd", "fdfind"]);
|
||||
assert_eq!(status, HealthStatus::Warning);
|
||||
assert_eq!(
|
||||
remediation.as_deref(),
|
||||
Some("Install fd or fdfind for faster searches.")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doctor_report_exposes_install_launch_and_environment_checks() {
|
||||
let report: AgentBrowserDoctorReport = serde_json::from_value(serde_json::json!({
|
||||
"success": true,
|
||||
"checks": [
|
||||
{"id": "env.version", "message": "CLI version 0.33.0", "status": "pass"},
|
||||
{"id": "chrome.installed", "message": "Chromium found", "status": "pass"},
|
||||
{"id": "launch.elapsed", "message": "Headless launch in 1.2s", "status": "pass"}
|
||||
]
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
let checks = agent_browser_doctor_checks(&report);
|
||||
assert_eq!(checks.len(), 3);
|
||||
assert_eq!(checks[0].name, "browser installation");
|
||||
assert_eq!(checks[0].status, HealthStatus::Pass);
|
||||
assert_eq!(checks[1].name, "browser headless launch");
|
||||
assert_eq!(checks[1].status, HealthStatus::Pass);
|
||||
assert_eq!(checks[2].name, "agent-browser environment");
|
||||
assert_eq!(checks[2].status, HealthStatus::Pass);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doctor_launch_failure_is_required_and_actionable() {
|
||||
let report: AgentBrowserDoctorReport = serde_json::from_value(serde_json::json!({
|
||||
"success": false,
|
||||
"checks": [
|
||||
{"id": "env.disk_free", "message": "low disk", "status": "warn"},
|
||||
{"id": "chrome.installed", "message": "Chromium found", "status": "pass"},
|
||||
{"id": "launch.daemon", "message": "shared library missing", "status": "fail"}
|
||||
]
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
let checks = agent_browser_doctor_checks(&report);
|
||||
assert_eq!(checks[1].status, HealthStatus::Fail);
|
||||
assert!(checks[1].required);
|
||||
assert!(checks[1].detail.contains("shared library missing"));
|
||||
assert!(checks[1].remediation.is_some());
|
||||
assert_eq!(checks[2].status, HealthStatus::Warning);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successful_launch_accepts_a_configured_browser_without_bundled_chrome() {
|
||||
let report: AgentBrowserDoctorReport = serde_json::from_value(serde_json::json!({
|
||||
"success": false,
|
||||
"checks": [
|
||||
{"id": "chrome.installed", "message": "Chrome for Testing missing", "status": "fail"},
|
||||
{"id": "launch.elapsed", "message": "Headless launch in 0.8s", "status": "pass"}
|
||||
]
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
let checks = agent_browser_doctor_checks(&report);
|
||||
assert_eq!(checks[0].status, HealthStatus::Pass);
|
||||
assert!(checks[0].detail.contains("launch confirmed"));
|
||||
assert_eq!(checks[1].status, HealthStatus::Pass);
|
||||
assert_eq!(checks[2].status, HealthStatus::Pass);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_agent_browser_version() {
|
||||
assert_eq!(
|
||||
|
||||
4
webui/package-lock.json
generated
4
webui/package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "picobot-webui",
|
||||
"version": "1.20.0",
|
||||
"version": "1.21.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "picobot-webui",
|
||||
"version": "1.20.0",
|
||||
"version": "1.21.0",
|
||||
"dependencies": {
|
||||
"bits-ui": "^2.0.0",
|
||||
"dompurify": "^3.4.12",
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "picobot-webui",
|
||||
"private": true,
|
||||
"version": "1.20.0",
|
||||
"version": "1.21.0",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
|
||||
@ -49,7 +49,7 @@
|
||||
|
||||
async function health() {
|
||||
try {
|
||||
const result = await api("/api/health");
|
||||
const result = await api("/health");
|
||||
online = true;
|
||||
version = `Gateway v${result.version}`;
|
||||
} catch {
|
||||
|
||||
146
webui/src/lib/components/HealthChecks.svelte
Normal file
146
webui/src/lib/components/HealthChecks.svelte
Normal file
@ -0,0 +1,146 @@
|
||||
<script>
|
||||
import { onMount } from "svelte";
|
||||
import { api, formatTime } from "../api.js";
|
||||
|
||||
let report = $state(null);
|
||||
let error = $state("");
|
||||
let loading = $state(false);
|
||||
let checkedAt = $state(null);
|
||||
|
||||
const counts = $derived.by(() => {
|
||||
const result = { pass: 0, warning: 0, fail: 0 };
|
||||
for (const check of report?.checks || []) {
|
||||
if (check.status in result) result[check.status] += 1;
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
const overallLabel = $derived(
|
||||
report?.overall === "healthy" ? "健康" : report?.overall === "degraded" ? "存在警告" : "检查失败"
|
||||
);
|
||||
|
||||
const statusLabels = {
|
||||
pass: "通过",
|
||||
warning: "警告",
|
||||
fail: "失败"
|
||||
};
|
||||
|
||||
const categoryLabels = {
|
||||
core: "核心",
|
||||
configured: "已配置功能",
|
||||
service: "系统服务"
|
||||
};
|
||||
|
||||
async function runCheck() {
|
||||
if (loading) return;
|
||||
loading = true;
|
||||
error = "";
|
||||
try {
|
||||
report = await api("/api/health");
|
||||
checkedAt = Date.now();
|
||||
} catch (caught) {
|
||||
error = caught.message;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(runCheck);
|
||||
</script>
|
||||
|
||||
<div class="health-pane" aria-busy={loading}>
|
||||
<div class="panel health-summary" class:healthy={report?.overall === "healthy"} class:degraded={report?.overall === "degraded"} class:unhealthy={report?.overall === "unhealthy"}>
|
||||
<div class="summary-main">
|
||||
<span class="summary-mark" aria-hidden="true">{report?.overall === "healthy" ? "✓" : report?.overall === "degraded" ? "!" : report ? "×" : "…"}</span>
|
||||
<div>
|
||||
<span class="label-caps">Gateway 健康状态</span>
|
||||
<strong>{loading && !report ? "正在检查…" : report ? overallLabel : "尚未完成检查"}</strong>
|
||||
<small>
|
||||
{#if report}PicoBot v{report.version} · {report.checks?.length || 0} 项检查{:else}复用 picobot health 的只读诊断逻辑{/if}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="summary-actions">
|
||||
{#if report}
|
||||
<div class="count-row" aria-label="检查结果统计">
|
||||
<span class="count pass">{counts.pass} 通过</span>
|
||||
<span class="count warning">{counts.warning} 警告</span>
|
||||
<span class="count fail">{counts.fail} 失败</span>
|
||||
</div>
|
||||
{/if}
|
||||
<button class="primary" onclick={runCheck} disabled={loading}>{loading ? "检查中…" : "重新检查"}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="health-error" role="alert">无法完成健康检查:{error}</div>
|
||||
{/if}
|
||||
|
||||
{#if report}
|
||||
<div class="check-list" aria-live="polite">
|
||||
{#each report.checks || [] as check}
|
||||
<article class="panel check-card {check.status}">
|
||||
<span class="check-mark" aria-hidden="true">{check.status === "pass" ? "✓" : check.status === "warning" ? "!" : "×"}</span>
|
||||
<div class="check-content">
|
||||
<div class="check-heading">
|
||||
<strong>{check.name}</strong>
|
||||
<span class="badge {check.status === "pass" ? "ok" : check.status === "warning" ? "run" : "fail"}">{statusLabels[check.status] || check.status}</span>
|
||||
<span class="badge">{categoryLabels[check.category] || check.category}</span>
|
||||
<span class="requirement">{check.required ? "必需" : "可选"}</span>
|
||||
</div>
|
||||
<p>{check.detail}</p>
|
||||
{#if check.remediation}
|
||||
<div class="remediation"><b>建议</b><span>{check.remediation}</span></div>
|
||||
{/if}
|
||||
</div>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="health-footnote">上次检查:{formatTime(checkedAt)}。检查不会安装依赖、修改配置或连接模型服务。</p>
|
||||
{:else if !loading && !error}
|
||||
<div class="panel empty-health">点击“重新检查”获取当前运行环境的诊断结果。</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.health-pane { min-width: 0; display: grid; gap: 14px; }
|
||||
.health-summary { min-height: 112px; display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 18px 20px; border-left: 4px solid var(--line-strong); }
|
||||
.health-summary.healthy { border-left-color: var(--signal); }
|
||||
.health-summary.degraded { border-left-color: var(--warning); }
|
||||
.health-summary.unhealthy { border-left-color: var(--danger); }
|
||||
.summary-main { min-width: 0; display: flex; align-items: center; gap: 14px; }
|
||||
.summary-mark { width: 42px; height: 42px; flex: 0 0 42px; display: grid; place-items: center; border-radius: 50%; color: var(--text-soft); background: var(--panel-2); font-size: 23px; font-weight: 700; }
|
||||
.healthy .summary-mark { color: var(--signal); background: var(--signal-soft); }
|
||||
.degraded .summary-mark { color: var(--warning); background: var(--warning-soft); }
|
||||
.unhealthy .summary-mark { color: var(--danger); background: var(--danger-soft); }
|
||||
.summary-main strong, .summary-main small { display: block; }
|
||||
.summary-main strong { margin-top: 4px; font-size: 20px; line-height: 28px; }
|
||||
.summary-main small { margin-top: 2px; color: var(--muted); font-size: 12px; }
|
||||
.summary-actions { display: flex; align-items: flex-end; flex-direction: column; gap: 12px; }
|
||||
.count-row { display: flex; gap: 12px; color: var(--muted); font-size: 11px; font-variant-numeric: tabular-nums; }
|
||||
.count.pass { color: var(--signal); }
|
||||
.count.warning { color: var(--warning); }
|
||||
.count.fail { color: var(--danger); }
|
||||
.health-error { padding: 11px 13px; border: 1px solid var(--danger-border); border-radius: var(--radius); color: var(--danger); background: var(--danger-soft); font-size: 13px; }
|
||||
.check-list { display: grid; gap: 9px; }
|
||||
.check-card { display: grid; grid-template-columns: 30px minmax(0, 1fr); gap: 11px; padding: 14px 16px; border-left: 3px solid var(--line-strong); }
|
||||
.check-card.warning { border-left-color: var(--warning); }
|
||||
.check-card.fail { border-left-color: var(--danger); }
|
||||
.check-mark { width: 28px; height: 28px; display: grid; place-items: center; border-radius: 50%; color: var(--signal); background: var(--signal-soft); font-weight: 700; }
|
||||
.warning .check-mark { color: var(--warning); background: var(--warning-soft); }
|
||||
.fail .check-mark { color: var(--danger); background: var(--danger-soft); }
|
||||
.check-content { min-width: 0; }
|
||||
.check-heading { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; }
|
||||
.check-heading strong { margin-right: 3px; font-size: 14px; overflow-wrap: anywhere; }
|
||||
.requirement { color: var(--muted); font-size: 11px; }
|
||||
.check-content p { margin: 7px 0 0; color: var(--text-soft); font-size: 13px; line-height: 20px; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.remediation { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 8px; margin-top: 9px; padding: 8px 10px; border: 1px solid var(--line); border-radius: 6px; color: var(--text-soft); background: var(--panel-2); font-size: 12px; line-height: 18px; }
|
||||
.remediation b { color: var(--accent); }
|
||||
.health-footnote { margin: 0; color: var(--muted); font-size: 11px; text-align: right; }
|
||||
.empty-health { padding: 28px; color: var(--muted); text-align: center; }
|
||||
@media (max-width: 760px) {
|
||||
.health-summary { align-items: stretch; flex-direction: column; }
|
||||
.summary-actions { align-items: stretch; }
|
||||
.count-row { justify-content: space-between; }
|
||||
}
|
||||
</style>
|
||||
@ -3,6 +3,7 @@
|
||||
import { Tabs } from "bits-ui";
|
||||
import { api } from "../lib/api.js";
|
||||
import AppearanceSettings from "../lib/components/AppearanceSettings.svelte";
|
||||
import HealthChecks from "../lib/components/HealthChecks.svelte";
|
||||
import SubAgentDefinitions from "../lib/components/SubAgentDefinitions.svelte";
|
||||
|
||||
let { notify } = $props();
|
||||
@ -12,11 +13,15 @@
|
||||
let path = $state("");
|
||||
let loading = $state(true);
|
||||
let saving = $state(false);
|
||||
let cleaning = $state(false);
|
||||
let reloadStatus = $state(null);
|
||||
let diagnostics = $state([]);
|
||||
let revision = $state("");
|
||||
let pollTimer = null;
|
||||
|
||||
const isConfig = $derived(tab === "config");
|
||||
const isAppearance = $derived(tab === "appearance");
|
||||
const isHealth = $derived(tab === "health");
|
||||
const isSubagents = $derived(tab === "subagents");
|
||||
const title = $derived(tab === "config" ? "运行配置" : tab === "user" ? "用户档案" : tab === "agents" ? "Agent 行为准则" : "页面外观");
|
||||
const isDirty = $derived(content !== original);
|
||||
@ -36,13 +41,15 @@
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
if (isAppearance || isSubagents) { loading = false; return; }
|
||||
if (isAppearance || isHealth || isSubagents) { loading = false; return; }
|
||||
try {
|
||||
if (isConfig) {
|
||||
const result = await api("/api/config");
|
||||
content = JSON.stringify(result.config, null, 2);
|
||||
original = content;
|
||||
path = result.path;
|
||||
diagnostics = result.diagnostics || [];
|
||||
revision = result.revision || "";
|
||||
} else {
|
||||
const result = await api(`/api/profiles/${tab}`);
|
||||
content = result.content;
|
||||
@ -62,6 +69,8 @@
|
||||
const result = await api("/api/config", { method: "PUT", body: JSON.stringify({ config }) });
|
||||
content = JSON.stringify(result.config, null, 2);
|
||||
original = content;
|
||||
diagnostics = result.diagnostics || [];
|
||||
revision = result.revision || revision;
|
||||
notify("配置已保存");
|
||||
} else {
|
||||
await api(`/api/profiles/${tab}`, { method: "PUT", body: JSON.stringify({ content }) });
|
||||
@ -80,6 +89,8 @@
|
||||
const result = await api("/api/config", { method: "PUT", body: JSON.stringify({ config }) });
|
||||
content = JSON.stringify(result.config, null, 2);
|
||||
original = content;
|
||||
diagnostics = result.diagnostics || [];
|
||||
revision = result.revision || revision;
|
||||
const reload = await api("/api/config/reload", { method: "POST" });
|
||||
notify(reload.message || "配置已保存并触发热重载");
|
||||
pollReloadStatus();
|
||||
@ -89,6 +100,24 @@
|
||||
|
||||
function discard() { content = original; }
|
||||
|
||||
async function cleanupInvalid() {
|
||||
if (isDirty || !revision || !diagnostics.length) return;
|
||||
cleaning = true;
|
||||
const count = diagnostics.length;
|
||||
try {
|
||||
const result = await api("/api/config/cleanup-invalid", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ revision })
|
||||
});
|
||||
content = JSON.stringify(result.config, null, 2);
|
||||
original = content;
|
||||
diagnostics = result.diagnostics || [];
|
||||
revision = result.revision || revision;
|
||||
notify(`已清除 ${count} 个无效配置项;请热重载以同步磁盘配置`);
|
||||
} catch (caught) { notify(caught.message, true); }
|
||||
finally { cleaning = false; }
|
||||
}
|
||||
|
||||
async function pollReloadStatus() {
|
||||
try { reloadStatus = await api("/api/config/reload/status"); } catch {}
|
||||
}
|
||||
@ -121,12 +150,14 @@
|
||||
<div class="settings-grid">
|
||||
<Tabs.Root value={tab} onValueChange={changeTab} orientation="vertical">
|
||||
<Tabs.List class="settings-nav" aria-label="设置分类">
|
||||
<Tabs.Trigger value="appearance">外观</Tabs.Trigger><Tabs.Trigger value="config">config.json</Tabs.Trigger><Tabs.Trigger value="user">USER.md</Tabs.Trigger><Tabs.Trigger value="agents">AGENTS.md</Tabs.Trigger><Tabs.Trigger value="subagents">子代理</Tabs.Trigger>
|
||||
<Tabs.Trigger value="appearance">外观</Tabs.Trigger><Tabs.Trigger value="config">config.json</Tabs.Trigger><Tabs.Trigger value="health">健康检查</Tabs.Trigger><Tabs.Trigger value="user">USER.md</Tabs.Trigger><Tabs.Trigger value="agents">AGENTS.md</Tabs.Trigger><Tabs.Trigger value="subagents">子代理</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
</Tabs.Root>
|
||||
|
||||
{#if isAppearance}
|
||||
<AppearanceSettings />
|
||||
{:else if isHealth}
|
||||
<HealthChecks />
|
||||
{:else if isSubagents}
|
||||
<div class="subagents-pane">
|
||||
<SubAgentDefinitions {notify} />
|
||||
@ -139,8 +170,8 @@
|
||||
<div class="editor-actions">
|
||||
{#if isDirty}<span class="dirty-badge">未保存</span>{/if}
|
||||
<button class="secondary" onclick={discard} disabled={!isDirty || loading || saving}>放弃修改</button>
|
||||
<button class="secondary" onclick={saveAndReload} disabled={loading || saving || !!jsonError}>保存并热重载</button>
|
||||
<button class="primary" onclick={save} disabled={loading || saving || !!jsonError}>保存</button>
|
||||
<button class="secondary" onclick={saveAndReload} disabled={loading || saving || cleaning || !!jsonError}>保存并热重载</button>
|
||||
<button class="primary" onclick={save} disabled={loading || saving || cleaning || !!jsonError}>保存</button>
|
||||
</div>
|
||||
</div>
|
||||
<textarea bind:value={content} spellcheck="false" disabled={loading}></textarea>
|
||||
@ -165,6 +196,32 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="panel sidebar-panel">
|
||||
<div class="diagnostic-head">
|
||||
<span class="label-caps">无效配置项</span>
|
||||
{#if diagnostics.length}<span class="badge fail">{diagnostics.length}</span>{/if}
|
||||
</div>
|
||||
{#if diagnostics.length}
|
||||
<ul class="diagnostic-list">
|
||||
{#each diagnostics as diagnostic}
|
||||
<li>
|
||||
<code>{diagnostic.path}</code>
|
||||
<span>{diagnostic.reason}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<button
|
||||
class="secondary cleanup-button"
|
||||
onclick={cleanupInvalid}
|
||||
disabled={isDirty || loading || saving || cleaning}
|
||||
title={isDirty ? "请先保存或放弃编辑器中的修改" : "从 config.json 删除所有已被运行时忽略的配置项"}
|
||||
>{cleaning ? "清理中…" : "一键清除"}</button>
|
||||
{#if isDirty}<p class="cleanup-hint">存在未保存修改,暂不能清理。</p>{/if}
|
||||
{:else}
|
||||
<p class="sidebar-empty">未发现被忽略的配置项</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="panel sidebar-panel">
|
||||
<span class="label-caps">热重载状态</span>
|
||||
{#if reloadStatus}
|
||||
@ -202,6 +259,13 @@
|
||||
.outline-list { list-style: none; margin: 10px 0 0; padding: 0; display: grid; gap: 7px; }
|
||||
.outline-list li { display: flex; align-items: center; justify-content: space-between; gap: 8px; font-size: 13px; color: var(--text-soft); }
|
||||
.outline-note { font-size: 11px; color: var(--muted); white-space: nowrap; }
|
||||
.diagnostic-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.diagnostic-list { list-style: none; margin: 10px 0 12px; padding: 0; display: grid; gap: 9px; max-height: 280px; overflow: auto; }
|
||||
.diagnostic-list li { display: grid; gap: 3px; padding-bottom: 9px; border-bottom: 1px solid var(--line); }
|
||||
.diagnostic-list code { overflow-wrap: anywhere; color: var(--danger); font-size: 11px; }
|
||||
.diagnostic-list span { color: var(--text-soft); font-size: 12px; line-height: 1.45; }
|
||||
.cleanup-button { width: 100%; justify-content: center; }
|
||||
.cleanup-hint { margin: 7px 0 0; color: var(--muted); font-size: 11px; }
|
||||
.reload-row { display: flex; justify-content: space-between; align-items: center; padding: 7px 0; font-size: 13px; color: var(--text-soft); }
|
||||
.reload-row + .reload-row { border-top: 1px solid var(--line); }
|
||||
.reload-error { margin-top: 8px; padding: 8px 10px; border-radius: 8px; background: var(--danger-soft); color: var(--danger); font-size: 12px; line-height: 1.5; }
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user