release: v1.34.0

This commit is contained in:
coso
2026-05-11 11:51:00 +08:00
parent 50b914b03b
commit 03378c9aba
297 changed files with 27854 additions and 19757 deletions
-50
View File
@@ -1,50 +0,0 @@
name: CI
on:
pull_request:
paths:
- ".github/workflows/ci.yml"
- "src-tauri/**"
- "src-tauri/Cargo.lock"
push:
branches:
- main
paths:
- ".github/workflows/ci.yml"
- "src-tauri/**"
- "src-tauri/Cargo.lock"
workflow_dispatch:
permissions:
contents: read
env:
CARGO_NET_RETRY: 10
RUSTUP_MAX_RETRIES: 10
CARGO_TERM_COLOR: always
jobs:
windows-openclaw-regression:
name: Windows OpenClaw Regression
runs-on: windows-2022
timeout-minutes: 45
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-pc-windows-msvc
- name: Setup Rust cache
uses: Swatinem/rust-cache@v2
with:
workspaces: src-tauri
shared-key: ci-windows-openclaw
cache-on-failure: true
- name: Run OpenClaw install regression tests
shell: pwsh
run: cargo test -p lime-core openclaw_install --manifest-path "src-tauri/Cargo.toml"
+2 -3
View File
@@ -53,9 +53,8 @@ jobs:
fi
node scripts/quality-task-selector.mjs "${args[@]}" >> "$GITHUB_OUTPUT"
# 当前仓库的 Rust 回归验证仍以既有 `.github/workflows/ci.yml`
# 中的 Windows OpenClaw regression 为主。这里先收口前端/契约/一致性
# 质量信号,后续再逐步补齐可稳定运行的 Rust 质量矩阵。
# 这里先收口前端/契约/一致性质量信号,后续再逐步补齐可稳定运行的
# Rust 质量矩阵。
integrity:
name: Integrity
+5 -5
View File
@@ -1,13 +1,13 @@
## Lime v1.33.0
## Lime v1.34.0
发布日期:`2026-05-11`
递交范围:完整 dirty worktree,包含 tracked、deleted 与新增文件;`.lime/` 等本地运行态忽略目录不纳入发布提交。
> 发布说明:上一版 release tag 为 `v1.32.0`。本版按用户要求升级到 `v1.33.0`,并把本轮待递交的全部源码、文档、脚本、测试与发布证据索引纳入 release notes。
> 发布说明:上一版 release tag 为 `v1.33.0`。本版按用户要求升级到 `v1.34.0`,并把当前完整 dirty worktree 的源码、文档、脚本、测试与发布说明索引纳入 release notes。
### 发布概览
- 应用版本从 `1.32.0` 升级到 `1.33.0`,同步 `package.json`、`package-lock.json`、`src-tauri/Cargo.toml`、`src-tauri/Cargo.lock`、`src-tauri/tauri.conf.json`、`src-tauri/tauri.conf.headless.json`、`packages/lime-cli-npm/package.json` 与 `@limecloud/lime-cli` 发布示例。
- 应用版本从 `1.33.0` 升级到 `1.34.0`,同步 `package.json`、`package-lock.json`、`src-tauri/Cargo.toml`、`src-tauri/Cargo.lock`、`src-tauri/tauri.conf.json`、`src-tauri/tauri.conf.headless.json`、`packages/lime-cli-npm/package.json` 与 `@limecloud/lime-cli` wrapper 版本。
- Agent QC 保留 scenario / GUI flow / Evidence Pack schema、qcloop job / status / preflight / export / release-summary / audit 脚本作为本地与人工证据工具;按本版要求从 GitHub Actions 发布/夜间/合同验证链路移除。
- Agent Runtime 与 Team / Workspace 主链补齐 approval sandbox、Claw chat ready streaming、tool surface、service skill entry、browser runtime、knowledge GUI 与 GUI owner 检查脚本,降低只靠浅层 smoke 误判的风险。
- i18n 主链从 legacy DOM Patch 迁到 key-based resources:新增 `createI18n`、namespace loader、locale resources、格式化工具与覆盖测试,旧 patch 文件移入 `src/i18n/legacy-patch/` 并从 current 入口退场。
@@ -52,7 +52,7 @@
### 校验状态
- `npm run verify:app-version`:通过,版本一致性为 `1.33.0`。
- `npm run verify:app-version`:待本轮版本更新后复跑确认版本一致性为 `1.34.0`。
- `cargo fmt --manifest-path "src-tauri/Cargo.toml" --all`:通过。
- `cargo test --manifest-path "src-tauri/Cargo.toml" --workspace --locked`:通过;使用 `CARGO_HOME=/tmp/lime-cargo-home-v133` 与 `CARGO_TARGET_DIR=src-tauri/target-release-v133-temp-home` 避开本机全局 Cargo 缓存损坏。
- `npm run lint:rust`:通过;已修复 `lime-media-runtime` 大 Err variant clippy warning,并在后续 MCP / server / agent 测试稳定性修复后复跑通过。
@@ -64,7 +64,7 @@
---
**完整变更**: `v1.32.0` -> `v1.33.0`
**完整变更**: `v1.33.0` -> `v1.34.0`
## Lime v1.32.0
+17
View File
@@ -0,0 +1,17 @@
{
"generatedAt": "2026-05-11T01:42:37.680Z",
"url": "http://127.0.0.1:1420/",
"title": "Lime",
"groupCount": 0,
"firstGroupIndex": null,
"secondGroupIndex": null,
"checks": {
"distinctGroups": false,
"firstGroupDoesNotContainSecondPrompt": false,
"secondGroupDoesNotContainFirstPrompt": false,
"secondGroupHasRuntimeFlowOrMeta": false,
"noOldPendingVisible": true
},
"groups": [],
"bodyPreview": "Lime\n邀请好友\n搜索任务\n新建任务\nSkills\n灵感\n项目资料\n最近对话\nPPT大纲需求沟通\n现在\n新对话\n13分钟前\n新对话\n15分钟前\n新对话\n17分钟前\n新对话\n17分钟前\nAgent QC approval resolved 1778461404847\n39分钟前\nAgent QC approval denied 1778461394810\n39分钟前\n新对话\n42分钟前\n新对话\n43分钟前\n新对话\n49分钟前\n查看更多对话\n归档\n开\n开源使用\n本地可用\n选择工作区\n新对话\n创作\n\n青柠一下,灵感即来\n\n说一句目标,Lime 就接着帮你做。\n\n文案、图片、视频、搜索和网页任务围绕同一目标持续推进,并沉淀上下文、偏好和做法。\n\n帮我做一份 PPT 大纲\ntab\n添加资料\n高级设置\n当前模型\ndeepseek-v4-pro\n引导帮助\n写作\n添加资料\nPPT\n调研报告\n需求分析\n视频\n设计\nExcel\n编程\n更多做法\n回看最近结果 · PPT大纲需求沟通\n向下滑,看看 Lime 可以帮你做什么\n↓\n你可以从这些任务开始\n\n往下看更多任务样例;真正执行仍会回到生成里继续补充。\n\n拆解一条爆款内容\n把一条高表现内容拆成标题钩子、结构节奏、素材手法和可复用套路,方便快速复刻。\n复盘这个账号/项目\n围绕目标、已有结果和下一步动作判断当前该怎么推进,适合内容账号、项目推进和运营回看。\n脚本转口播/字幕稿\n把现有脚本整理成更适合口播和字幕的版本,补齐停顿、节奏和适合出镜表达的语气。\n每日趋势摘要\n先收一版内容趋势、热点方向和值得继续跟进的切口,适合每天快速开工前拉一遍。\n内容主稿生成\n围绕目标受众、表达结构和关键信息,先生成一版可继续迭代的内容首稿。\n长文转多平台发布稿\n把长文、资料或文章整理成多平台可直接发布的版本,不用再自己拆标题和结构。\n复制轮播帖\n拆解参考轮播帖的结构、文风和卖点,再输出一版可继续改写的轮播内容。\n视频配音\n围绕视频素材、语言要求与旁白意图,先在本地工作区整理一版可继续加工的配音稿。\n视频配音成其他语言\n先整理配音脚本和语言要求,输出一版可继续进入配音流程的执行稿。\n文章转 Slide 视频提纲\n把文章拆成镜头化结构,先生成一版适合做 Slide 视频的提纲。\n账号增长跟踪\n围绕参考账号和目标平台先整理一版增长打法,再持续跟踪内容节奏、关键指标和提醒条件。"
}
+1 -1
View File
@@ -72,7 +72,7 @@
- **改 subagent / automation / execution tracker / scheduler taxonomy**:先读 `task-agent-taxonomy.md`
- **讨论 `/goal`、Managed Objective 或跨 turn 目标续跑**:先读 `task-agent-taxonomy.md` 与 `query-loop.md`,再读 `../research/codex-goal/README.md` 与 `../roadmap/managed-objective/README.md`
- **讨论 Coding Agent、Skill Forge 或能力生成 draft**:先读 `query-loop.md` 与 `skill-standard.md`,再读 `../research/pi-mono-coding-agent/README.md` 与 `../roadmap/skill-forge/coding-agent-layer.md`
- **改 channels / browser connector / DevBridge / OpenClaw remote runtime**:先读 `remote-runtime.md`
- **改 channels / browser connector / DevBridge remote runtime**:先读 `remote-runtime.md`
- **改记忆来源链 / working memory / durable memory / Team Memory / compaction**:先读 `memory-compaction.md`
- **改 FileArtifact / artifact sidecar / versions / file checkpoint / evidence 中的文件快照**:先读 `persistence-map.md`
- **改 session detail / thread read / requestTelemetry / evidence / history-record**:先读 `state-history-telemetry.md`
+2 -2
View File
@@ -83,7 +83,7 @@ lime/
| `src-tauri/src/services/mcp_service.rs` | MCP 服务器与工具管理 |
| `src-tauri/src/commands/gateway_channel_cmd.rs` | 多渠道远程入口、Webhook/Tunnel 与渠道运行时 |
| `src-tauri/src/commands/browser_connector_cmd.rs` + `src-tauri/src/commands/webview_cmd.rs` | 浏览器连接器、ChromeBridge 与远程浏览器接入 |
| `src-tauri/src/commands/telegram_remote_cmd.rs` + `src-tauri/src/dev_bridge.rs` + `src-tauri/src/services/openclaw_service/*` | 单通道旧入口、开发桥与本地 Gateway 兼容支撑 |
| `src-tauri/src/commands/telegram_remote_cmd.rs` + `src-tauri/src/dev_bridge.rs` | 单通道旧入口与开发桥兼容支撑 |
### 基础设施层
@@ -107,7 +107,7 @@ lime/
| `agent/` | Aster Agent 运行时桥接、会话管理与状态读模型装配 |
| `skills/` | Skills 标准集成、动态加载与执行回调 |
| `providers/` | 多 Provider 认证与请求发送 |
| `services/` | 心跳、OpenClaw、浏览器窗口、MCP 等业务服务 |
| `services/` | 心跳、浏览器窗口、MCP 等业务服务 |
| `converter/` | 协议兼容与转换 |
| `server/` | HTTP Server 与 REST 能力 |
| `terminal/` | 已移除;不要恢复旧终端/PTTY 模块 |
+19 -36
View File
@@ -5,11 +5,11 @@
本文件定义 Lime 当前 `Remote / SDK / Server Mode` 的唯一 remote runtime 事实源,主要回答:
- 哪些远程入口才算当前产品主链
- `消息渠道`、`浏览器连接器 / ChromeBridge`、`OpenClaw`、`DevBridge`、`telegram_remote` 分别属于哪一层
- `消息渠道`、`浏览器连接器 / ChromeBridge`、`DevBridge`、`telegram_remote` 分别属于哪一层
- 哪些路径负责真实 ingress / control plane,哪些只是安装壳、调试桥或兼容入口
- 后续新增 remote 能力应该往哪里收敛,而不是继续长平级旁路
它是 **远程入口与控制面的 current 文档**,不是 OpenClaw 页面说明,也不是单条渠道命令的局部注释。
它是 **远程入口与控制面的 current 文档**,不是单条渠道命令的局部注释。
## 什么时候先读
@@ -18,7 +18,6 @@
- 调整 `gateway_channel_*`、`gateway_tunnel_*`、渠道 probe 或多账号渠道运行时
- 调整浏览器连接器、ChromeBridge、远程调试入口或 browser backend policy
- 调整 `DevBridge` HTTP 桥、浏览器 dev shell 的后端接通逻辑
- 调整 `OpenClaw` Gateway、Dashboard、安装与运行态管理
- 调整单通道 Telegram 远程触发入口,或评估是否应该继续保留它
如果一个需求同时碰到“远程触发 + 浏览器接入”“渠道入口 + 本地 Gateway”“调试桥 + 产品运行时”中的两项以上,默认属于本主链。
@@ -33,9 +32,8 @@
1. `消息渠道 runtime` 是当前 IM 远程入口主链
2. `浏览器连接器 / ChromeBridge` 是当前浏览器侧远程 transport 主链
3. `OpenClaw` 只作为本地 Gateway / 安装 / Dashboard 兼容壳,不再定义新的 remote 真相
4. `DevBridge` 只作为 debug-only 开发桥,不再冒充产品 remote runtime
5. `telegram_remote_cmd` 是旧单通道入口,不再继续扩成长期主链
3. `DevBridge` 只作为 debug-only 开发桥,不再冒充产品 remote runtime
4. `telegram_remote_cmd` 是旧单通道入口,不再继续扩成长期主链
固定规则只有一句:
@@ -83,30 +81,15 @@
- `browser_connector_cmd.rs` 只负责设置与安装入口
- 真正的 session / backend / remote debugging 状态继续由 `webview_cmd.rs`、`browser_runtime_cmd.rs` 暴露
### 3. `OpenClaw` 本地 Gateway 壳
### 3. 已删除的本地 Gateway 兼容壳
- `src/lib/api/openclaw.ts`
- `src-tauri/src/commands/openclaw_cmd.rs`
- `src-tauri/src/services/openclaw_service/*`
- `src/components/openclaw/*`
当前这里负责:
1. 本地安装、升级、环境检查
2. 本地 Gateway 进程拉起、停止、重启
3. Dashboard URL、健康检查、运行环境选择
4. 兼容页面与过渡操作壳
OpenClaw 安装、Gateway、Dashboard 与运行态管理模块已经判定为 `dead` 并从前端入口、Tauri 命令、DevBridge dispatcher、mock 与 core helper 中移除。
固定规则:
- `OpenClaw` 当前不是 remote runtime 的唯一事实源
- 它管理的是“本地 Gateway 壳与安装体验”,不是远程 session/control plane 真相
- 后续如果保留,只允许继续做 compat 支撑,不再扩成第二套产品 remote coordinator
这一步里“`OpenClaw` 属于 compat”是基于现有仓库实现和产品规划推断:
- 代码上它主要暴露安装、环境、Gateway、Dashboard 与 runtime candidate 管理
- 产品规划里它已被明确定位为“过渡安装入口”,不是长期一级产品导航
- 不再恢复 `src/components/openclaw/*`、`src/lib/api/openclaw.ts`、`src-tauri/src/commands/openclaw_cmd.rs` 或 `src-tauri/src/services/openclaw_service/*`
- 不再把本地 Gateway / Dashboard 壳当成 remote runtime 或一级系统入口
- 如需新增远程入口,只能回到 `消息渠道 runtime` 或 `浏览器连接器 / ChromeBridge` current 主链
### 4. `DevBridge`
@@ -167,30 +150,30 @@
- `src-tauri/src/dev_bridge.rs`
- `src-tauri/src/dev_bridge/*`
- `src/lib/dev-bridge/*`
- `src/lib/api/openclaw.ts`
- `src-tauri/src/commands/openclaw_cmd.rs`
- `src-tauri/src/services/openclaw_service/*`
- `src/components/openclaw/*`
保留原因:
- `DevBridge` 仍是浏览器开发模式的必要桥接层
- `OpenClaw` 仍承接本地 Gateway、安装、升级与 Dashboard 过渡体验
退出条件:
- `DevBridge` 继续只做开发态桥接,不再承担产品 remote 叙事
- `OpenClaw` 若继续保留,也只能作为本地壳与兼容入口,不再扩成第二套 remote control plane
### `deprecated`
- `src-tauri/src/commands/telegram_remote_cmd.rs`
- 任何继续把单渠道 bot runtime 直接定义为 remote 总入口的新实现
- 任何继续把 `OpenClaw` 页面或 `DevBridge` HTTP 桥当成产品 remote runtime 真相的新实现
- 任何继续把 `DevBridge` HTTP 桥当成产品 remote runtime 真相的新实现
### `dead`
- 当前 `M3` 不新增远程面的强制删除项;本轮先完成 current 事实源收口,不做额外硬删
- `src/components/openclaw/*`
- `src/lib/api/openclaw.ts`
- `src/lib/api/openclawDashboardWindow.ts`
- `src-tauri/src/commands/openclaw_cmd.rs`
- `src-tauri/src/services/openclaw_service/*`
- `src-tauri/src/dev_bridge/dispatcher/openclaw.rs`
- `src-tauri/crates/core/src/openclaw_install.rs`
## 最低验证要求
@@ -200,7 +183,7 @@
- 改渠道命令 / tunnel / webhook:`npm run test:contracts` 与相关渠道定向测试
- 改浏览器连接器 / ChromeBridge:浏览器相关定向测试或 `verify:gui-smoke`
- 改 `DevBridge`:至少补桥接定向测试
- 改 `OpenClaw`:至少补相关页面或命令定向测试
- 触碰已删除 OpenClaw surface:至少运行 `npm run governance:legacy-report` 与 `npm run test:contracts`,确认没有命令或入口回流
## 这一步如何服务主线
@@ -211,7 +194,7 @@
- 解释 IM 远程入口时,回到 `gateway_channel_*`
- 解释远程浏览器 transport 时,回到 `browser connector / ChromeBridge`
- 解释开发态浏览器接通时,回到 `DevBridge` compat
- 解释本地 Gateway / Dashboard 壳时,回到 `OpenClaw` compat
- 解释已删除本地 Gateway / Dashboard 壳时,回到 OpenClaw `dead` 分类与治理目录册
- 解释旧 Telegram 单通道触发时,视为 `deprecated`
这样后续 `M4 Memory / Compaction` 和 `M5 State / History / Telemetry` 就不必继续被 remote 入口语言打断。
@@ -140,7 +140,7 @@ Codex 值得借鉴的,不是 Bazel、不是纯 Rust、也不是它的体量;
当前 GitHub Actions 里:
- `ci.yml` 主要覆盖 `src-tauri/**` 改动下的 **Windows OpenClaw regression**
- `quality.yml` 主要覆盖前端、契约与一致性质量信号
- `deploy-docs.yml` 只负责文档站点
- `release.yml` 只负责发布
@@ -189,7 +189,7 @@ Lime 现在更像是“积木已经有了,但还没拼成系统”。
- Tauri command 参数结构
- 前后端共享数据结构
- bridge 交互协议
- OpenClaw / Gateway / Workspace 相关状态快照
- Gateway / Workspace 相关状态快照
- 插件 / MCP 相关 manifest 或接口字段
Codex 的经验告诉我们:
@@ -274,8 +274,6 @@ Codex 很重要的一个做法,是把很多检查统一收口到一个最终
- `src/lib/dev-bridge/**`
- `src/lib/tauri-mock/**`
- `src/components/openclaw/**`
- `src-tauri/src/services/openclaw_service/**`
- `src-tauri/src/app/**`
- Workspace / browser-runtime / MCP / plugin 高风险路径
@@ -390,7 +388,6 @@ Lime 现在涉及:
- bridge
- 浏览器 runtime
- Provider / Gateway
- OpenClaw 安装与运行态
- 文档站点
这些链路任何一处变化,都可能需要在正式 tag 前先经过一次“真实用户环境暴露”。
@@ -498,9 +495,8 @@ Codex 借鉴给我们的,不是“让 AI 代替工程”,而是:
优先建议覆盖:
1. `safeInvoke` / Tauri command 契约
2. OpenClaw 安装与运行态相关路径
3. bridge / headless / smoke 相关路径
4. Workspace 准备态相关 smoke
2. bridge / headless / smoke 相关路径
3. Workspace 准备态相关 smoke
可复用现有脚本:
-1
View File
@@ -18,4 +18,3 @@
## 当前条目
- `openclaw-windows-detection-followup.md`:OpenClaw 在 Windows 下“已安装但未检测到”的后续迭代记录
@@ -1,59 +0,0 @@
# OpenClaw Windows 检测问题后续记录
## 状态
- 结论:纳入下个迭代
- 范围:`OpenClaw` Windows 安装检测与诊断可观测性
- 优先级:中高
## 背景
用户反馈在 Windows 环境下已经安装了 `OpenClaw`,但应用内仍显示“未检测到 OpenClaw”。
这类问题通常不是“未安装”,而是“当前进程没有正确解析到 `openclaw` 命令”,典型场景包括:
- 安装完成后应用进程未刷新到最新 `PATH`
- `openclaw` 安装在 `npm` 全局目录,但该目录未进入当前进程可见路径
- `npm config get prefix`、`where openclaw` 与应用内补充搜索目录之间存在不一致
## 现象
- 页面提示:`未检测到 OpenClaw`
- 用户实际情况:系统中已完成 `OpenClaw` 安装
- 用户感知:会误以为需要重复安装,或者认为安装功能失效
## 影响
- 容易触发重复安装操作
- 会降低 Windows 用户对安装流程稳定性的信任
- 故障定位成本高,用户需要手工提供 `where openclaw`、`npm config get prefix` 等信息
## 本次已确认的处理方向
建议下个版本正式带上以下能力:
1. Windows 检测前主动刷新当前进程 `PATH`
2. 将 `npm` 全局前缀目录纳入 `openclaw` 命令补充搜索范围
3. 当检测到 npm 包已存在但命令未生效时,显示“待刷新”而不是“未检测到”
4. 在安装页直接展示诊断信息,包括:
- `npm` 命令路径
- `npm global prefix`
- `OpenClaw` 包路径
- `where openclaw` 命中结果
- 补充搜索目录
- 补充目录中的 `openclaw` 命中结果
## 建议验收标准
- Windows 已安装 `OpenClaw` 但当前进程未命中命令时:
- 不再提示继续重复安装
- 页面显示“待刷新”或等价状态
- 页面给出明确的重新检测/重启应用引导
- 诊断面板可直接暴露关键路径信息,便于用户截图反馈
- `cargo test windows_` 相关回归通过
- OpenClaw 前端页面测试通过
## 建议补充
- 后续可将这组诊断信息并入“故障诊断导出 JSON”
- 若后续还有类似问题,可统一沉淀为“命令可见性诊断”能力,而不是仅服务于 `OpenClaw`
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -13,7 +13,7 @@ Lime 当前是一个本地优先的 Tauri 桌面应用,而不是单一前端
- Rust 服务层与业务逻辑
- 数据库、文件系统与工作区状态
- Provider、协议转换与本地 HTTP Server
- 浏览器运行时、终端、OpenClaw 等桌面能力
- 浏览器运行时等桌面能力
- Agent Runtime 与真实模型行为
- macOS / Windows 平台差异
+6 -6
View File
@@ -3,9 +3,9 @@
"title": "Lime Agent QC Operating Scenarios",
"evidenceSchema": "docs/test/agent-qc-evidence.schema.json",
"qcloop": {
"purpose": "把 Agent 测试拆成可批量执行、独立质检、可返修的场景项。",
"recommendedMaxQcRounds": 3,
"workerPromptTemplate": "在 Lime 仓库中执行 Agent QC 场景 {{item}}。只做该场景要求的最小验证,收集命令、GUI、runtime 和证据路径,不要顺手修复无关问题。\n\n执行场景命令前先运行 qcloop worker preflight:`npm run agent-qc:qcloop-preflight -- --expected-cwd \"$(pwd)\" --check`。如果该场景命令需要 DevBridge / GUI / browser runtime,再运行:`npm run agent-qc:qcloop-preflight -- --expected-cwd \"$(pwd)\" --require-devbridge --timeout-ms 15000 --check`。\n\n如果 preflight 失败,停止后续高成本命令,输出 `QCLOOP_WORKER_RESULT=BLOCKED`,并在 stdout 中列出失败 check、health URL、cwd、stdout/stderr 证据路径;不要把环境阻断误判为产品 pass。\n\n最终 stdout 必须逐项列出 `evidence_required` 的证据是否满足,并逐项说明 `failure_modes` 如何被覆盖、排除或命中;不能只写“命令通过”或只给日志路径。\n\n如果 item 中包含 evidence_layers,最终 stdout 必须声明本次覆盖到哪些层级;只跑到 deterministic-smoke 时不得伪装成 gui-trace、runtime-transcript 或 release-artifact。\n\n只读约束:Agent QC worker 只允许执行场景命令、收集证据和输出 PASS/FAIL/BLOCKED;不得修改源码、配置、文档、锁文件或 git 状态。若 verifier 证据不足,输出缺口并让外层生成新 payload,不要在 qcloop repair 轮里修代码。\n\n结构化证据输出硬约束:最终 stdout 必须包含单行 `QCLOOP_WORKER_RESULT=PASS|FAIL|BLOCKED`;还必须包含单行 `QCLOOP_EVIDENCE_SUMMARY_JSON=<json>`,其中 `<json>` 是单个 JSON object,不要 Markdown / code fence。该 JSON 至少包含 scenario_id、result、commands[]、evidence_required[]、evidence_layers_covered[]、failure_modes[]、artifacts[]、blockers[]、gui_session_owner、release_scope。evidence_required[] 每项包含 name、status(pass|fail|blocked|missing)、evidence、artifact_path;failure_modes[] 每项包含 name、status(covered|excluded|hit|unknown)、evidence。不得输出密钥、token、用户私密内容或未经脱敏的请求 / 响应正文。",
"purpose": "把 Agent 测试拆成可批量执行、独立质检、只读取证的场景项;发布证据批次不得在 qcloop repair 轮中修改工作区。",
"recommendedMaxQcRounds": 1,
"workerPromptTemplate": "在 Lime 仓库中执行 Agent QC 场景 {{item}}。只做该场景要求的最小验证,收集命令、GUI、runtime 和证据路径,不要顺手修复无关问题。\n\n执行场景命令前先运行 qcloop worker preflight:`npm run agent-qc:qcloop-preflight -- --expected-cwd \"$(pwd)\" --check`。如果该场景命令需要 DevBridge / GUI / browser runtime,再运行:`npm run agent-qc:qcloop-preflight -- --expected-cwd \"$(pwd)\" --require-devbridge --timeout-ms 15000 --check`。\n\n如果 preflight 失败,停止后续高成本命令,输出 `QCLOOP_WORKER_RESULT=BLOCKED`,并在 stdout 中列出失败 check、health URL、cwd、stdout/stderr 证据路径;不要把环境阻断误判为产品 pass。\n\n最终 stdout 必须逐项列出 `evidence_required` 的证据是否满足,并逐项说明 `failure_modes` 如何被覆盖、排除或命中;不能只写“命令通过”或只给日志路径。\n\n如果 item 中包含 evidence_layers,最终 stdout 必须声明本次覆盖到哪些层级;只跑到 deterministic-smoke 时不得伪装成 gui-trace、runtime-transcript 或 release-artifact。\n\n只读约束:Agent QC worker 只允许执行场景命令、收集证据和输出 PASS/FAIL/BLOCKED;不得修改源码、配置、文档、锁文件或 git 状态。若 verifier 证据不足,输出缺口并让外层生成新 payload,不要在 qcloop repair 轮里修代码。\n\n结构化证据输出硬约束:最终 stdout 必须包含单行 `QCLOOP_WORKER_RESULT=PASS|FAIL|BLOCKED`;还必须包含单行 `QCLOOP_EVIDENCE_SUMMARY_JSON=<json>`,其中 `<json>` 是单个 JSON object,不要 Markdown / code fence。该 JSON 至少包含 scenario_id、result、commands[]、evidence_required[]、evidence_layers_covered[]、failure_modes[]、artifacts[]、blockers[]、gui_session_owner、release_scope。evidence_required[] 每项包含 name、status(pass|fail|blocked|missing)、evidence、artifact_path;failure_modes[] 每项包含 name、status(covered|excluded|hit|unknown)、evidence。不得输出密钥、token、用户私密内容或未经脱敏的请求 / 响应正文。\n\nGUI session owner / isolation 口径:`npm run agent-qc:gui-owner-check -- --check` 只用于外层启动新 GUI P0 批次前,不要在 qcloop worker 内用它判定当前正在执行的同批次 GUI item;worker 需要在 `gui_session_owner` 字段声明当前隔离方式,例如 `single-worker isolated qcloop job on dedicated port/db`、是否复用已有 DevBridge,以及是否观察到其他非本 job 的 GUI owner。只有发现非本 job 的 active/stale GUI owner 时,才把 `parallel GUI smoke interference` 记为 hit/blocker。",
"verifierPromptTemplate": "审查 worker 输出是否满足场景 {{item}} 的 verifier、evidenceRequired 和 failureModes。只输出一个合法 JSON object,不要 Markdown、不要代码块、不要前后缀文本。JSON 必须至少包含 {\"pass\": true|false, \"feedback\": \"...\"};可选字段 missingEvidence、nextAction、evidenceStatus、scenarioId 必须仍在同一个 JSON object 内。\n\nqcloop worker 执行证据:\n- attempt_status: {{attempt_status}}\n- attempt_type: {{attempt_type}}\n- exit_code: {{exit_code}}\n\nworker stdout:\n{{stdout}}\n\nqcloop issue ledger:\n{{issue_ledger}}\n\n判定要求:stdout 必须包含 `QCLOOP_WORKER_RESULT=PASS|FAIL|BLOCKED` 和 `QCLOOP_EVIDENCE_SUMMARY_JSON=<json>`。如果 stdout 缺少 `QCLOOP_EVIDENCE_SUMMARY_JSON`,或该 JSON 无法逐项证明 scenario verifier / evidenceRequired / failureModes,必须输出 {\"pass\": false, \"feedback\": \"...\"};不能因为命令名、退出码或 worker 自评 PASS 就通过。",
"recommendedMaxExecutorRetries": 0
},
@@ -181,12 +181,12 @@
"npm run smoke:agent-service-skill-entry"
],
"goal": "从 capability draft 到 verify/register,再到 runtime binding readiness 与 session scope 显式 enable。",
"verifier": "不能把已注册误判为已自动进入 tool surface;必须看到 readiness、metadata、enable 和 SkillTool gate 证据。",
"verifier": "不能把已注册误判为已自动进入 tool surface;必须看到 readiness、metadata、enable 和 SkillTool gate 证据。runtime-transcript 以 smoke 脚本生成的 .lime/qc/skill-forge-runtime-transcript-current.json 为准,必须包含 SkillTool request/decision/result 与 source metadata;本场景不要求 live model provider stream。",
"evidenceRequired": [
"capability draft evidence",
"registration result",
"runtime binding projection",
"SkillTool gate transcript"
"SkillTool gate transcript request/decision/result/source metadata"
],
"evidenceLayers": [
"deterministic-smoke",
@@ -445,7 +445,7 @@
"npm run verify:gui-smoke"
],
"goal": "发布前验证版本一致、启动冒烟、首屏 ready、Bridge health 和发布阻断条件可被 release gate 复核。",
"verifier": "verify:app-version 与 GUI startup smoke 成功;如无真实安装包 artifact,应明确记录为 source-tree startup smoke,不能把它伪装成 installer 验证;release Evidence Pack 覆盖由 release-summary gate 单独强制。",
"verifier": "verify:app-version 与 GUI startup smoke 成功;如无真实安装包 artifact,应明确记录为 source-tree startup smoke,不能把它伪装成 installer 验证;在明确声明 source-tree startup scope 且未伪装 installer 时,artifact missing 应判 excluded;release Evidence Pack 覆盖由 release-summary gate 单独强制。",
"evidenceRequired": [
"version check",
"startup smoke",
+2 -2
View File
@@ -81,7 +81,7 @@ npm run agent-qc:release-summary -- \
| `command-bridge-contract` | `npm run test:contracts` | 命令合同日志、四侧同步摘要 | 前端/Rust/mock/catalog 任一侧缺失 |
| `claw-chat-ready-streaming` | `npm run verify:gui-smoke` + Playwright MCP | GUI trace、DevBridge、runtime transcript、console/network | workspace 未 ready、流式卡死、中断不可恢复 |
| `tool-approval-sandbox-boundary` | `npm run smoke:agent-runtime-tool-surface` + `npm run smoke:agent-runtime-approval-sandbox` | tool timeline、approval decision、sandbox policy | 工具绕过授权、超时无恢复、危险工具暴露 |
| `skill-forge-register-bind-enable` | `npm run test:contracts` + `npm run smoke:agent-service-skill-entry` | draft、verify/register、runtime binding、SkillTool gate | 把 registered 误判为 executable、metadata 自动启用 skill |
| `skill-forge-register-bind-enable` | `npm run test:contracts` + `npm run smoke:agent-service-skill-entry` | draft、verify/register、runtime binding、`.lime/qc/skill-forge-runtime-transcript-current.json` 中的 SkillTool request/decision/result/source metadata | 把 registered 误判为 executable、metadata 自动启用 skill |
| `browser-runtime-site-adapter` | `npm run smoke:browser-runtime` + `npm run smoke:site-adapters` | browser session、adapter catalog、console/network、cleanup | session 泄漏、adapter 漂移、cleanup 缺失 |
| `workspace-ready-session-restore` | `npm run smoke:workspace-ready` + `npm run verify:gui-smoke` | workspace smoke、GUI smoke、DevBridge、design canvas 工程保存 / 打开结果 | ready 假阳性、会话恢复脏状态、设计画布导出无保存状态 |
| `harness-replay-regression` | `npm run harness:eval` + `npm run harness:eval:trend` | summary、trend、invalid case、observability outcome | fixture invalid、grader 合同漂移、trend 断裂 |
@@ -96,7 +96,7 @@ P0 场景允许先用确定性 smoke 快速证明主路径可执行,但 releas
| `deterministic-smoke` | 命令能运行、DevBridge 可达、workspace ready、基础 runtime surface 可读 | live long-turn transcript、真实中断恢复、approval/sandbox 完整轨迹 |
| `gui-trace` | 用户路径截图 / trace、console/network 摘要、关键 UI 断言 | 后端 turn 持久化和 tool timeline |
| `runtime-transcript` | submit / stream / interrupt / resume / tool request / decision / result 顺序 | GUI 可见性、桌面壳启动、发布包启动 |
| `release-artifact` | 安装包或 source-tree 启动范围、版本一致性、waiver | 运行期 Agent 行为质量 |
| `release-artifact` | 安装包或 source-tree 启动范围、版本一致性、waiver;若没有安装包,必须明确标记 `source-tree startup scope`,不能伪装 installer | 运行期 Agent 行为质量 |
如果当前只跑到 `deterministic-smoke`,worker 可以输出 `QCLOOP_WORKER_RESULT=PASS` 表示命令通过;但 verifier 仍应在缺少 deep evidence 时判该 P0 场景不满足 release pass。正确做法是补证据或拆分场景,不是降低 verifier。
@@ -32,7 +32,8 @@ npm run agent-qc:gui-owner-check -- \
npm run agent-qc:process-owner-check -- \
--format json \
--output "./.lime/qc/gui-process-owner-current.json" \
--markdown-output "./.lime/qc/gui-process-owner-current.md"
--markdown-output "./.lime/qc/gui-process-owner-current.md" \
--watch-history-output "./.lime/qc/raw-process-owner-watch-history.jsonl"
npm run agent-qc:qcloop-db-lease -- \
--db "./.lime/qc/qcloop-isolated-worker-preflight.db" \
@@ -40,20 +41,28 @@ npm run agent-qc:qcloop-db-lease -- \
--format json \
--output "./.lime/qc/qcloop-db-lease-current.json" \
--markdown-output "./.lime/qc/qcloop-db-lease-current.md"
npm run agent-qc:payload-coverage -- \
--payload "./.lime/qc/qcloop-p0-single-owner-ready-2026-05-11-0454.json" \
--format json \
--output "./.lime/qc/qcloop-p0-single-owner-ready-coverage-current.json" \
--check
```
通过条件:
- `gui-owner-current.json` 没有 active / stale GUI qcloop owner。
- `gui-process-owner-current.json` 没有 raw GUI smoke、qcloop worker、Cargo / Rust 构建 owner。
- `gui-process-owner-current.json` 没有 active raw GUI smoke、qcloop worker、Cargo / Rust 构建 owner;passive qcloop serve、passive Tauri runtime 和 observer shell 只作为上下文,不单独阻断。
- `qcloop-db-lease-current.json` 没有 running item、续约 lock 或 no-output active attempt。
- `qcloop-p0-single-owner-ready-coverage-current.json` 对 manifest P0 场景 `missing=[]`、`extra=[]`、`coverage.passed=true`。
- DevBridge preflight 通过:`npm run agent-qc:qcloop-preflight -- --require-devbridge --check`。
阻断条件:
- 存在 stale qcloop worker 且仍在续约 DB lease。
- 存在长时间 `smoke:*` 或 `verify:gui-smoke` 进程。
- 存在 Cargo / Rust 编译或 Tauri dev owner,可能抢占 target、DevBridge 或窗口状态。
- 存在 Cargo / Rust 编译 owner,可能抢占 target;`tauri dev` runtime 只有在被归类为 active owner 时才阻断。
- ready payload 与 manifest P0 不一致,或 payload coverage 仍未生成。
- 官方 `.lime/qc/agent-qc-evidence.json` 不是同一批次 8/8 P0 pass。
## 3. P0 执行矩阵
@@ -64,7 +73,7 @@ npm run agent-qc:qcloop-db-lease -- \
| `workspace-ready-session-restore` | `npm run smoke:workspace-ready` + `npm run verify:gui-smoke -- --reuse-running` | workspace ready、session restore、DevBridge health、GUI trace | GUI ready 和恢复状态都可观察 |
| `claw-chat-ready-streaming` | `npm run verify:gui-smoke -- --reuse-running` 或专项 Claw smoke | first delta、interrupt command、aborted turn、follow turn completed、GUI 可见恢复结果 | 不能只有“有回复”,必须证明中断后状态正确 |
| `tool-approval-sandbox-boundary` | `npm run smoke:agent-runtime-tool-surface` + `npm run smoke:agent-runtime-approval-sandbox` | approval policy、sandbox policy、denied/resolved flow、tool timeline | approval / sandbox 进入 turn config 且无绕过 |
| `skill-forge-register-bind-enable` | `npm run test:contracts` + `npm run smoke:agent-service-skill-entry` | draft / verify / register / binding readiness / enable allowlist | 注册不等于可执行,显式 enable 才可进入能力面 |
| `skill-forge-register-bind-enable` | `npm run test:contracts` + `npm run smoke:agent-service-skill-entry` | draft / verify / register / binding readiness / enable allowlist / runtime transcript | 注册不等于可执行,显式 enable 且 SkillTool gate 有结构化 transcript 才可进入能力面 |
| `browser-runtime-site-adapter` | `npm run smoke:browser-runtime` + `npm run smoke:site-adapters` | attach/status、adapter list、cleanup、console/network 摘要 | cleanup warning 必须归类;session 泄漏不可放行 |
| `harness-replay-regression` | `npm run harness:eval` + `npm run harness:eval:trend` | eval summary、observability gap、trend 样本 | trend 样本不足只能作为 seed,不能替代长期趋势 |
| `release-package-startup-smoke` | `npm run verify:app-version` + `npm run verify:gui-smoke -- --reuse-running` | version consistency、artifact scope、startup smoke | 必须标明 source-tree 还是 installer artifact |
@@ -136,3 +145,25 @@ npm run agent-qc:qcloop-db-lease -- \
- Evidence 契约:`docs/tests/lime-agent-qc-evidence-contract.md`
- 机器 manifest:`docs/test/agent-qc-scenarios.manifest.json`
- Evidence schema:`docs/test/agent-qc-evidence.schema.json`
## 10. 2026-05-11 04:44 样本状态
当前执行矩阵的最新输入不是“缺少测试文档”,而是 owner gate 和官方 Evidence Pack 仍在阻断:
| Gate | 当前状态 | 下一步含义 |
| --- | --- | --- |
| `agent-qc:audit -- --check` | `incomplete`,`16/17` | 只剩 `real-qcloop-evidence` 未满足 |
| `agent-qc:objective-checklist -- --check` | `incomplete`,`5/7` | raw process owner busy + official Evidence Pack fail |
| `agent-qc:gui-owner-check -- --check` | pass | qcloop GUI owner 已清空 |
| `agent-qc:process-owner-check` | busy | PID `59011` stale `smoke:design-canvas` 是唯一 active blocker |
| official Evidence Pack | fail | `.lime/qc/agent-qc-evidence.json` 不能发布 |
| isolated P0 full v3 | failed,4 pass / 4 exhausted | 只能作为 sidecar,不可覆盖官方 evidence |
| Skill Forge runtime transcript 单项补证 | completed,1 pass / 0 fail | 已证明单项 verifier 可采信 runtime transcript,但不能替代 8/8 full P0 |
`skill-forge-register-bind-enable` 的最新前置证据是 `.lime/qc/skill-forge-runtime-transcript-current.json`:它覆盖 deterministic-smoke 与 runtime-transcript 两层、8 个事件,并证明 Rust exact tests 不再是 `running 0 tests`。随后 qcloop job `1778445676473687000` 已把该场景跑成 `success`,sidecar `.lime/qc/agent-qc-evidence.skill-forge-runtime-transcript.json` 为单场景 `pass`。但该 sidecar 缺少其余 7 个 P0 场景,release summary `--check` 仍正确失败;下一轮 full P0 必须在同一批次内重新覆盖 8/8,而不是拼接 partial pass。
因此当前安全动作只有三类:
1. 只读刷新 owner / qcloop status / audit sidecar。
2. 继续完善文档和 runbook,不能降低 verifier 或 evidenceRequired。
3. 等 PID `59011` 自然释放,或 owner 明确确认后按 stale raw GUI owner runbook 处理。
@@ -271,7 +271,7 @@ npm run agent-qc:audit -- --format json
## 6. 当前 Lime 样本状态
截至 2026-05-11 01:13,本地测试体系已经具备标准、manifest、schema、qcloop exporter、release summary 和 audit,但不能宣称 P0 完成。
截至 2026-05-11 04:44,本地测试体系已经具备标准、manifest、schema、qcloop exporter、release summary、completion audit、objective checklist 和 raw process owner gate,但不能宣称 P0 完成。
当前关键事实:
@@ -298,6 +298,11 @@ npm run agent-qc:audit -- --format json
- 最新完整 `verify:local` 已刷新为 `status=fail`,失败阶段是 `verify:gui-smoke / smoke:claw-chat-ready-streaming`。后续 direct Claw post-refresh fallback 已 pass,但另一轮 session-restore direct smoke 在 DevBridge 中途不可达时 fail;两者都只能作为 Claw deep flow 的 sidecar 证据,不能替代完整 `verify:local` pass。
- 2026-05-11 01:13 观察到外部 `verify:gui-smoke -- --reuse-running --timeout-ms 240000` 自然通过,且 Claw streaming / interrupt / resume 在 latest summary 中为 `verdict=pass`、`recoveryVisibleSource=live-stream`。这证明 GUI smoke 主链已经恢复到可通过状态,但 `.lime/qc/gui-process-owner-current.json` 仍显示 raw process owner `busy`,且完整 `verify:local` 尚未重跑通过。
- 2026-05-11 01:32 已把 raw process owner 检查脚本化为 `npm run agent-qc:process-owner-check`,把 qcloop SQLite lease 取证脚本化为 `npm run agent-qc:qcloop-db-lease`,并新增 `docs/tests/lime-agent-autonomous-test-execution-matrix.md` 作为 Lime 样本的执行矩阵。最新 sidecar 仍显示 `qcloop stale` 与 `raw process owner busy`,因此本轮仍不能启动完整 `verify:local` 或新的 full GUI P0。
- 2026-05-11 03:11 后,`.lime/qc/verify-local-current.json` 与 `.lime/qc/verify-gui-smoke-current.json` 已恢复为 `status=pass`,completion audit 提升到 `16/17`;剩余目标级缺口收敛为真实 qcloop P0 Evidence Pack。
- 2026-05-11 04:24 的隔离 P0 full v3 已终态 `failed`,无 running / pending / stale:`command-bridge-contract`、`claw-chat-ready-streaming`、`tool-approval-sandbox-boundary`、`harness-replay-regression` 通过;`skill-forge-register-bind-enable`、`browser-runtime-site-adapter`、`workspace-ready-session-restore`、`release-package-startup-smoke` 仍 fail / blocked。v3 只能作为 sidecar,不能升格为官方 Evidence Pack。
- 2026-05-11 04:44 的 raw process owner 已收敛到单一 active blocker:PID `59011` 的 stale `smoke:design-canvas`,`cargoOrRust=0`、`qcloopRelated=0`、`staleActiveGuiSmoke=1`。确认前仍不得终止该进程,也不得启动新的 full GUI P0。
- `skill-forge-register-bind-enable` 的 Rust exact filter 缺口已修正:`smoke:agent-service-skill-entry` 现在拒绝 `running 0 tests`,并产出 `.lime/qc/skill-forge-runtime-transcript-current.json`。随后单项 qcloop `1778445676473687000` 已采信该 runtime transcript 并导出 `.lime/qc/agent-qc-evidence.skill-forge-runtime-transcript.json`,该场景为 `pass`;但它只覆盖 1 个场景,不能覆盖官方 8/8 P0 Evidence Pack。
- 官方 `.lime/qc/agent-qc-evidence.json` 仍为 `status=fail`。当前测试体系已经能把“本地 smoke 通过”“sidecar 通过/失败”“官方发布证据通过”分开处理,这正是运营级测试要保留的硬边界。
这正是自主测试体系的价值:它没有把“本机某次手动看起来可用”误判为发布通过,而是把缺证据和真实产品 blocker 留在门禁中。
@@ -309,11 +314,12 @@ npm run agent-qc:audit -- --format json
2. 继续只读观察所有 running / stale qcloop 批次;若当前 worker 自然结束,再导出 sidecar 并根据终态决定是否复用待用 payload。没有 owner 明确授权前,不 kill、不 pause、不重启这些进程。
3. 把 P0 分成两层验收:确定性 smoke 层用于证明主路径可执行,deep evidence 层用于证明 live transcript / trace / console-network / cleanup / approval-sandbox 证据齐全;官方 release gate 仍必须看 deep evidence pass。
4. 先等当前 running/stale GUI qcloop 批次自然结束,或在 owner 明确授权下收口;下一轮只允许一个 GUI owner 跑 full P0,worker 必须输出 GUI session owner / isolation statement。
5. 在单一 GUI owner 前提下,定位 `workspace-ready-session-restore` 中 `smoke:design-canvas` 的保存成功状态断言失败,因为它已经是 qcloop worker 内可复现的 GUI P0 signal,不应被旧 isolated pass 覆盖;下一步重点是确认导出按钮点击后是否有保存中 / 保存成功状态,还是导出流程在首个可观察状态前卡住。
6. 把 `tool-approval-sandbox-boundary` 拆成更窄的 runtime transcript 或 harness replay 场景,让 approval / sandbox 证据以结构化 JSON 进入 verifier,而不是只引用组件级 smoke。
7. 在 `.lime/qc/gui-process-owner-current.json` 不再显示长时间 raw GUI smoke / Cargo owner 后,重新跑完整 `npm run verify:local`;只有该统一门禁通过,`local-verify-gate` 才能关闭。
8. 环境阻断解除后,定位 `claw-chat-ready-streaming` 的 stop / interrupt 后持久化、会话恢复和 DevBridge 中途不可达问题,补 Rust 或 harness replay 回归。
9. 只有 8/8 P0 全部 pass 后,才把结果导出为官方 `.lime/qc/agent-qc-evidence.json`,再运行 `npm run agent-qc:release-summary -- --check` 和 `npm run agent-qc:audit -- --format json`。
5. 保留 `skill-forge-register-bind-enable` 单项 qcloop pass sidecar,下一轮 full P0 仍要重新在同一批次内覆盖它,不能把单项 sidecar 拼接成官方 Evidence Pack。
6. 在单一 GUI owner 前提下,优先定位 `workspace-ready-session-restore` / `browser-runtime-site-adapter` / `release-package-startup-smoke` 的 GUI trace、session isolation、release artifact 层证据缺口;不能用旧 isolated pass 覆盖最新 P0 sidecar failure。
7. 把 `tool-approval-sandbox-boundary` 保持为已通过的 runtime transcript / harness replay 场景,避免后续退回只引用组件级 smoke。
8. 在 `.lime/qc/gui-process-owner-current.json` 不再显示长时间 raw GUI smoke / Cargo owner 后,重新跑完整 `npm run verify:local`;只有该统一门禁继续通过,`local-verify-gate` 才能保持关闭。
9. 环境阻断解除后,定位 `claw-chat-ready-streaming` 的 stop / interrupt 后持久化、会话恢复和 DevBridge 中途不可达问题,补 Rust 或 harness replay 回归,防止 v3 pass 变成一次性偶然通过。
10. 只有 8/8 P0 全部 pass 后,才把结果导出为官方 `.lime/qc/agent-qc-evidence.json`,再运行 `npm run agent-qc:release-summary -- --check` 和 `npm run agent-qc:audit -- --format json`。
## 8. 与其他文档的关系
+421 -2
View File
@@ -4,7 +4,7 @@
## 1. 当前结论
截至 2026-05-11 01:13,本地已经具备 Agent QC 标准、manifest、Evidence Pack schema、qcloop exporter、release summary gate、completion audit、qcloop status sidecar 和 GUI smoke 能力。隔离 qcloop 已经证明部分 P0 smoke 能在 worker 内跑通;最新显式 Sensenova provider 的 `verify:gui-smoke -- --reuse-running` 已通过,并覆盖 Claw streaming / interrupt / resume,但官方 Evidence Pack 仍为失败,完整 `verify:local` 与 stale qcloop P0 owner 仍未闭环:
截至 2026-05-11 05:02,本地已经具备 Agent QC 标准、manifest、Evidence Pack schema、qcloop exporter、release summary gate、completion audit、qcloop status sidecar、payload coverage gate 和 GUI smoke 能力。隔离 qcloop 已经证明部分 P0 smoke 能在 worker 内跑通;最新完整 `verify:local` 已通过,隔离 P0 full v3 已把 `command-bridge-contract`、`claw-chat-ready-streaming`、`tool-approval-sandbox-boundary`、`harness-replay-regression` 推进到 qcloop pass,后续单项 qcloop 又把 `skill-forge-register-bind-enable` 推进到 runtime-transcript pass;但官方 Evidence Pack 仍为失败,且 PID `59011` 的 raw `smoke:design-canvas` stale owner 仍未释放。2026-05-11 05:02 的最新门禁刷新见 [5.38](#538-completion-audit-纳入-payload-coverage2026-05-11-0502):
```text
.lime/qc/agent-qc-evidence.json status=fail scenarios=8/8
@@ -246,7 +246,7 @@ evidence=.lime/qc/verify-gui-smoke-reuse-sensenova-session-restore-2026-05-11-01
| active scenario | `browser-runtime-site-adapter` | 仍卡在 browser runtime / site adapter 类 P0 |
| stale age | 约 `28020s` | 已远超 stale 阈值 |
| GUI owner gate | `ownerCount=1`、`staleOwnerCount=1`、`oldestStaleSeconds≈27026` | 不允许启动新的 full GUI P0 |
| raw process owner | `busy`,`activeGuiSmoke=2`、`cargoOrRust=4`、`qcloopRelated=7` | 不允许启动完整 `verify:local` |
| raw process owner | `busy`,`activeGuiSmoke=3`、`cargoOrRust=4`、`qcloopRelated=7` | 不允许启动完整 `verify:local` |
| DB lease | `lock_owner=qcloop-worker-1`,`lock_expires_at=2026-05-11T01:47:06+08:00` | lease 仍被续约 |
| active attempt | `dc625f8e-b3b9-46b7-9758-4b0273438d50` / `running` | stdout/stderr 仍为空 |
@@ -545,3 +545,422 @@ evidence=.lime/qc/verify-gui-smoke-reuse-sensenova-session-restore-2026-05-11-01
| objective completion audit | `achieved=false` | `.lime/qc/objective-completion-audit-current.json` schema `v3` 失败项仍为 `real-p0-qcloop-evidence` 与 `local-verify-gate` |
该口径不改变核心发布事实:没有真实 8/8 P0 qcloop pass 和完整 `verify:local` pass 时,仍不能把当前状态标记为整体完成,也不能覆盖官方 `.lime/qc/agent-qc-evidence.json`。
### 5.16 当前门禁刷新与 stale owner 收口(2026-05-11 02:42)
本轮只执行只读 / sidecar 取证与低冲突脚本修正;未执行 git commit / push / tag / release,未修改 qcloop SQLite DB,未覆盖官方 `.lime/qc/agent-qc-evidence.json`,也未启动新的 full GUI P0 或完整 `verify:local`。
| 门禁 | 当前值 | 结论 |
| --- | --- | --- |
| isolated full P0 v1 `1778405842243079000` | `failed`,5 success / 3 exhausted | 旧 stale owner 已自然进入终态;该批次不能作为发布 pass |
| GUI owner gate | `pass`,ownerCount=`0` | qcloop GUI owner 已释放,可以作为后续新批次的必要条件之一 |
| raw process owner | `busy`,activeGuiSmoke=`3`、cargoOrRust=`5`、qcloopRelated=`6` | 仍不应启动完整 `verify:local` 或新的 full GUI P0 |
| sidecar Evidence Pack | `.lime/qc/agent-qc-evidence.isolated-p0-full-v1-after-intervention.json` status=`fail` | 只作为失败证据;不覆盖官方 Evidence Pack |
| sidecar release summary | `exit 1` / status=`fail` | `agent-qc:release-summary --check` 正确拒绝该 sidecar 作为绿色发布证据 |
| completion audit | `15/17`,`status=incomplete` | 当前只剩 `real-qcloop-evidence` 与 `local-verify-gate` 两个目标级缺口 |
本轮还修正了 completion audit 的 sidecar 归并口径:同一个 qcloop job 存在更新的终态 status sidecar 时,旧的 `pre-intervention` / stale snapshot 不再被误判为“仍有未终态 owner”。该修正只消除历史 sidecar 噪声,不会放宽真正的发布门禁;官方 Evidence Pack 仍必须是真实 `8/8` P0 pass,完整 `verify:local` 仍必须 pass。
本轮验证:
```bash
node --check scripts/agent-qc-completion-audit.mjs
npx vitest run scripts/lib/agent-qc-completion-audit-core.test.ts
npm run agent-qc:audit -- --format json --output ./.lime/qc/objective-completion-audit-current.json
npm run agent-qc:release-summary -- --evidence ./.lime/qc/agent-qc-evidence.isolated-p0-full-v1-after-intervention.json --require-scenario-manifest docs/test/agent-qc-scenarios.manifest.json --require-risk P0 --tag sidecar-isolated-p0-full-v1-after-intervention --check
```
下一刀仍不应直接重跑重型门禁;应先等 raw process owner 释放,再按顺序执行 `agent-qc:process-owner-check -- --check`、`agent-qc:gui-owner-check -- --check`、完整 `npm run verify:local`,最后再启动单 owner full P0 qcloop 并导出真实官方 Evidence Pack。
### 5.17 raw process owner 精确化(2026-05-11 02:47)
`agent-qc:process-owner-check` 已从粗粒度进程计数改为区分 active owner、passive runtime 和 observer:
| 分类 | 当前值 | 含义 |
| --- | --- | --- |
| active GUI smoke | `1` | 只有 PID `59011` 的 `npm run smoke:design-canvas ...` 仍是重型 GUI owner |
| stale active GUI smoke | `1` | PID `59011` 已运行约 `7h`,超过默认 `30min` stale 阈值 |
| active qcloop worker | `0` | 旧 qcloop serve 进程被归为 passive server,不再误判为 active worker |
| active Cargo / Rust build | `0` | `tauri dev` runtime 被归为 passive runtime,不再误判为 Cargo build owner |
| passive qcloop server | `6` | 仅说明历史隔离 server 仍在,不代表 active P0 worker |
| passive Tauri runtime | `4` | 仅说明当前 GUI runtime 仍在,不代表正在编译 |
| observer process | `2` | 只读 `ps` / `rg` watcher,不再计入阻断 owner |
最新 sidecar:`.lime/qc/gui-process-owner-current.json` / `.md`。当前 `ownerIntervention.status=requires_owner_confirmation`,确认文本为:
```text
确认处理 stale raw GUI owner PID 59011,可以终止这些进程并记录 sidecar。
```
确认前仍不执行 kill / pause / interrupt,也不启动完整 `verify:local` 或新的 full GUI P0。该修正只消除 idle qcloop serve、Tauri runtime 和观察脚本的误报;真实阻断仍是 stale `smoke:design-canvas` owner。
### 5.18 stale raw GUI owner 处置请求(2026-05-11 02:51)
再次只读刷新确认 PID `59011` 仍存活,`etime=07:04:53`,仍是唯一 active raw GUI smoke owner。已生成处置请求 sidecar:
- `.lime/qc/stale-raw-gui-owner-intervention-request.json`
- `.lime/qc/stale-raw-gui-owner-intervention-request.md`
该请求只用于 owner 决策,不代表授权。确认文本仍为:
```text
确认处理 stale raw GUI owner PID 59011,可以终止这些进程并记录 sidecar。
```
确认前继续保持:不 kill / pause / interrupt PID `59011`,不启动完整 `verify:local`,不启动新的 full GUI P0,不覆盖官方 Evidence Pack,不执行 git commit / push / tag / release。
### 5.19 process owner 分类核心回归(2026-05-11 02:55)
为避免 raw process owner gate 后续再次把 passive runtime / observer 误判成 active blocker,本轮把分类逻辑抽到 `scripts/lib/agent-qc-process-owner-core.mjs`,并新增 `scripts/lib/agent-qc-process-owner-core.test.ts`。当前验证:
```bash
node --check scripts/lib/agent-qc-process-owner-core.mjs
node --check scripts/agent-qc-process-owner-check.mjs
npx vitest run scripts/lib/agent-qc-process-owner-core.test.ts scripts/lib/agent-qc-completion-audit-core.test.ts
```
结果:`21 tests` 全部通过。最新 `agent-qc:process-owner-check --check` 仍按预期失败:`activeGuiSmoke=1`、`cargoOrRust=0`、`qcloopRelated=0`、`staleActiveGuiSmoke=1`、`passiveQcloopServer=6`、`passiveTauriRuntime=4`、`observer=2`。因此门禁没有被放宽,仍卡在 PID `59011` 的 stale `smoke:design-canvas`。
### 5.20 objective checklist sidecar(2026-05-11 02:59)
新增目标级 checklist sidecar:
- `.lime/qc/objective-completion-checklist-current.json`
- `.lime/qc/objective-completion-checklist-current.md`
该 checklist 把显式目标映射到文档、manifest、schema、qcloop 工具、owner gate、官方 Evidence Pack、`verify:local` 和 git guardrail。当前状态仍为 `incomplete`,`4/7` pass;阻断项为 raw process owner busy、官方 Evidence Pack 非 pass、`verify:local` 非 pass。短暂观察到的外部 `verify:gui-smoke` transient owner 已自然结束,最新 raw process owner 又收敛为唯一 active blocker:PID `59011` 的 stale `smoke:design-canvas`。
### 5.21 raw process owner watch history(2026-05-11 03:01)
`agent-qc:process-owner-check` 新增 `--watch-history-output`,已追加 `.lime/qc/raw-process-owner-watch-history.jsonl`。最新记录:`status=busy`,`activeGuiSmoke=1`,`staleActiveGuiSmoke=1`,`ownerIntervention=requires_owner_confirmation`,PID `59011` 运行约 `7h16m`。该 JSONL 只记录观察,不授权处理进程。
### 5.22 objective checklist 脚本化(2026-05-11 03:04)
新增 `scripts/agent-qc-objective-checklist.mjs` 与 npm 入口 `agent-qc:objective-checklist`,把目标级 checklist 从一次性 sidecar 生成逻辑固化为可重复门禁。当前命令:
```bash
npm run agent-qc:objective-checklist -- --format json --output ./.lime/qc/objective-completion-checklist-current.json
npm run agent-qc:objective-checklist -- --format markdown --output ./.lime/qc/objective-completion-checklist-current.md
npm run agent-qc:objective-checklist -- --check
```
前两条已生成当前 sidecar;`--check` 按预期 exit `1`,因为 checklist 仍为 `incomplete`(`4/7`)。这不会放宽门禁,只把“目标是否完成”的判断变成可重复脚本。
### 5.23 objective checklist core 回归(2026-05-11 03:07)
`agent-qc:objective-checklist` 已抽出 `scripts/lib/agent-qc-objective-checklist-core.mjs`,并新增 `scripts/lib/agent-qc-objective-checklist-core.test.ts`。回归覆盖:owner clear 时 checklist 可 complete、raw process owner busy 时保持 `pass_with_blocking_owner`、官方 Evidence Pack / `verify:local` fail 时列为 blocker、Markdown 渲染。该轮同时修正了 owner gate 已 pass 时 checklist 可能误判的边界。最新定向测试:
```bash
npx vitest run scripts/lib/agent-qc-objective-checklist-core.test.ts scripts/lib/agent-qc-process-owner-core.test.ts scripts/lib/agent-qc-completion-audit-core.test.ts
```
结果:`25 tests` 全部通过。当前实际 checklist 仍为 `incomplete`,`4/7`。
### 5.24 raw GUI owner post-confirmation runbook(2026-05-11 03:09)
`docs/tests/lime-agent-qc-stale-owner-intervention.md` 新增第 8 节,专门覆盖 `gui-owner-check` 已通过但 `process-owner-check` 仍因 raw GUI smoke stale 的场景。该 runbook 明确:确认前不处理进程;确认后只处理 owner 明确确认的 PID / PGID;不得顺手清理 passive qcloop serve、passive Tauri runtime 或 observer shell;处理后必须先通过 `agent-qc:process-owner-check -- --check` 与 `agent-qc:gui-owner-check -- --check`,再跑完整 `verify:local`。
### 5.25 local verify gate 已关闭(2026-05-11 03:11)
外部完整门禁已把 `.lime/qc/verify-local-current.json` 与 `.lime/qc/verify-gui-smoke-current.json` 刷新为 `status=pass`。重新运行 `agent-qc:audit` 后,completion audit 从 `15/17` 提升为 `16/17`,剩余目标级缺口只剩 `real-qcloop-evidence`。Objective checklist 从 `4/7` 提升为 `5/7`,其中 `verify:local` 已 PASS。
当前仍不能启动新的 full P0 qcloop,因为 raw process owner 仍为 `busy`:PID `59011` 的 stale `smoke:design-canvas` 仍存活,`ownerIntervention=requires_owner_confirmation`。因此下一刀不是重跑 `verify:local`,而是等待或获得明确确认后处理 PID `59011`,再启动 single-owner full P0 qcloop 并导出真实 8/8 P0 official Evidence Pack。
### 5.26 隔离 P0 full v2 终态失败(2026-05-11 03:50)
观察到本地已有隔离 P0 full v2 批次 `1778440541478632000`(`lime-agent-qc-isolated-p0-full-v2-2026-05-11-0315`)运行在 `127.0.0.1:18086`。本轮只做只读监控、sidecar 导出和 release gate 校验;未覆盖官方 `.lime/qc/agent-qc-evidence.json`,未执行 git commit / push / tag / release。
| 门禁 | 当前值 | 结论 |
| --- | --- | --- |
| qcloop status | `failed`,3 success / 5 exhausted | 批次无 stale,但仍不能发布 |
| sidecar Evidence Pack | `.lime/qc/agent-qc-evidence.isolated-p0-full-v2-2026-05-11-0315.json` status=`fail` | 2 pass / 4 fail / 2 blocked |
| sidecar release summary | `.lime/qc/release-agent-qc.sidecar-isolated-p0-full-v2-2026-05-11-0315.md`,`--check` exit `1` | release gate 正确拒绝 |
| direct contracts sanity | `npm run test:contracts` pass | 当前工作树的命令契约已恢复;v2 中 `command-bridge-contract` 失败是历史运行时证据,不可改写为 pass |
| raw process owner | `busy` | PID `59011` 的 stale `smoke:design-canvas` 仍阻断新 full P0 |
v2 的主要失败分类:
| 场景 | sidecar 状态 | 主要原因 |
| --- | --- | --- |
| `command-bridge-contract` | `fail` | worker 运行当时 `test:contracts` 命中 `execution_run_get_general_workbench_state` mock drift;后续 direct sanity 已通过,但不回写历史 item |
| `claw-chat-ready-streaming` | `fail` | 长 turn 在首个流式增量前 completed,未完整证明中断与恢复 |
| `skill-forge-register-bind-enable` | `fail` | `test:contracts` 在该 worker 中失败,Skill P0 不能判通过 |
| `browser-runtime-site-adapter` | `blocked` | 缺 `gui-trace` 与干净 GUI session isolation |
| `workspace-ready-session-restore` | `blocked` | active GUI owner 阻断核心命令执行 / isolation statement |
| `release-package-startup-smoke` | `fail` | `verify:gui-smoke` exit `1`,release startup smoke 未闭环 |
### 5.27 隔离 P0 full v3 终态失败但无 stale(2026-05-11 04:24)
随后观察到隔离 P0 full v3 批次 `1778442773271496000`(`lime-agent-qc-isolated-p0-full-v3-2026-05-11-0354`)运行在 `127.0.0.1:18087`。该批次修正了 worker 内 GUI session owner / isolation 口径,避免 worker 把同批次 GUI owner 误判为外部抢占;本轮仍只做只读监控和 sidecar 导出。
| 门禁 | 当前值 | 结论 |
| --- | --- | --- |
| qcloop status | `failed`,4 success / 4 exhausted | 批次已终态,无 running / pending / stale;仍不能发布 |
| sidecar Evidence Pack | `.lime/qc/agent-qc-evidence.isolated-p0-full-v3-2026-05-11-0354.json` status=`fail` | 4 pass / 1 fail / 3 blocked |
| sidecar release summary | `.lime/qc/release-agent-qc.sidecar-isolated-p0-full-v3-2026-05-11-0354.md`,`--check` exit `1` | release gate 正确拒绝 |
| completion audit | `16/17`,`status=incomplete` | 只剩 `real-qcloop-evidence` |
| objective checklist | `5/7`,`status=incomplete` | raw process owner busy + official Evidence Pack fail |
| raw process owner | `busy`,activeGuiSmoke=`1`、cargoOrRust 波动中、qcloopRelated=`0` | PID `59011` 仍是 stale GUI owner;同时有外部 Rust 定向测试 / rustc 编译在跑 |
v3 的通过项和剩余缺口:
| 场景 | sidecar 状态 | 说明 |
| --- | --- | --- |
| `command-bridge-contract` | `pass` | 当前 qcloop worker 与 verifier 均通过 |
| `claw-chat-ready-streaming` | `pass` | qcloop worker / verifier 接受 GUI、DevBridge、runtime transcript 证据 |
| `tool-approval-sandbox-boundary` | `pass` | qcloop worker / verifier 通过 |
| `harness-replay-regression` | `pass` | qcloop worker / verifier 通过 |
| `skill-forge-register-bind-enable` | `fail` | verifier 认为只覆盖 deterministic smoke,未证明 runtime-transcript / readiness / metadata / explicit enable / SkillTool gate |
| `browser-runtime-site-adapter` | `blocked` | deterministic smoke 有证据,但缺 `gui-trace`,并命中 parallel GUI smoke interference |
| `workspace-ready-session-restore` | `blocked` | workspace 与 GUI smoke 命令成功,但 `GUI session owner / isolation statement` 因非本 job stale owner PID `59011` 被标为 blocked |
| `release-package-startup-smoke` | `blocked` | `verify:app-version` 与 `verify:gui-smoke` 成功,但缺 release-artifact 层,且 GUI isolation 被 PID `59011` 阻断 |
当前不能把 v3 sidecar 升格为官方发布证据。关闭条件仍是:PID `59011` 自然释放,或 owner 明确确认处理 stale raw GUI owner 后,重新执行 single-owner full P0,并得到官方 `.lime/qc/agent-qc-evidence.json` 的真实 8/8 P0 pass。
### 5.28 Skill Forge P0 定向 smoke 补证(2026-05-11 04:31)
v3 中 `skill-forge-register-bind-enable` 的失败不是 GUI owner 阻断,而是 `smoke:agent-service-skill-entry` 当时的 Rust exact filter 没有实际运行关键后端测试:日志显示多个 `running 0 tests`,导致 verifier 正确拒绝把 deterministic smoke 升格为 runtime / SkillTool gate 证据。
随后观察到当前工作树中的 `scripts/agent-service-skill-entry-smoke.mjs` 已改为:
- 为 root Tauri 测试显式使用 `-p lime`
- 为 `lime-agent` SkillTool gate 测试显式使用 `-p lime-agent`
- 使用完整 Rust test path 与 `--exact`
- 捕获 stdout/stderr 并拒绝没有 `N passed` 的 Rust 定向测试,避免 `running 0 tests` 被误判为通过
定向证据:
| 证据 | 状态 | 说明 |
| --- | --- | --- |
| `.lime/qc/smoke-agent-service-skill-entry-after-rust-exact-fix-2026-05-11-0430.log` | `pass` | 当前 `smoke:agent-service-skill-entry` 已通过 |
| Rust exact tests | `pass` | 日志中关键 root / `lime-agent` 测试均出现 `running 1 test` 与 `1 passed` |
| 服务技能入口路由回归 | `pass` | 4 个 Vitest 文件、66 tests pass |
| Agent A2UI 挂起主链 | `pass` | 1 个 Vitest 文件,6 tests pass / 102 skipped |
该补证只说明 v3 的 Skill Forge 失败已有可复核的修复方向,不能回写 v3 sidecar,也不能替代新的 full P0。后续仍必须在 raw process owner 清空后重新运行 single-owner full P0,让 `skill-forge-register-bind-enable` 在 qcloop worker / verifier 内重新通过。
### 5.29 raw process owner 回到单一 PID 阻断(2026-05-11 04:32)
最新 `agent-qc:process-owner-check` sidecar 显示外部 Rust/Cargo owner 已自然清空:`activeGuiSmoke=1`、`cargoOrRust=0`、`qcloopRelated=0`、`staleActiveGuiSmoke=1`。唯一 active blocker 仍是 PID `59011` 的 stale `smoke:design-canvas`,`etime≈8h46m`。
该状态说明下一轮 full P0 的唯一环境前置阻断已经收敛为 raw GUI owner;确认前仍不得处理 PID `59011`,也不得启动新的 full GUI P0 或覆盖官方 Evidence Pack。
### 5.30 Skill Forge 单项 qcloop 补证终态(2026-05-11 04:39)
观察到单项 qcloop job `1778445171616868000`(`lime-agent-qc-skill-forge-rust-exact-fix-2026-05-11-0432`)运行在 `127.0.0.1:18087` 并进入终态 `failed`,`0 success / 1 exhausted / 0 stale`。该批次只覆盖 `skill-forge-register-bind-enable`,不是 full P0,也不能覆盖官方 Evidence Pack。
| 证据 | 状态 | 说明 |
| --- | --- | --- |
| `.lime/qc/qcloop-status.skill-forge-rust-exact-fix-current.json` | `failed` | 单项 job 已终态,无 stale |
| `.lime/qc/agent-qc-evidence.skill-forge-rust-exact-fix.json` | `fail` | deterministic smoke 证据充分,但 runtime-transcript 层仍缺 |
| `.lime/qc/release-agent-qc.sidecar-skill-forge-rust-exact-fix.md` | `fail` | release summary 正确拒绝单项 sidecar 作为 full P0 发布证据 |
这次补证把 `skill-forge-register-bind-enable` 的缺口从“Rust exact tests 没实际运行”推进为更准确的 P0 缺口:
- `npm run test:contracts` 与 `npm run smoke:agent-service-skill-entry` 在 qcloop worker 内已能产出可审查 deterministic-smoke 证据
- capability draft、registration、runtime binding readiness、metadata、显式 enable、SkillTool gate 的 deterministic evidence 已被 verifier 接受
- 仍缺 live runtime transcript:未产出 submit / stream / tool-request / decision / result 级 artifact
因此下一刀不是再修 Rust exact filter,而是补 `skill-forge-register-bind-enable` 的 runtime transcript 采集路径;在该 artifact 存在前,P0 verifier 不应把 Skill Forge 场景判为 pass。
### 5.31 Skill Forge runtime transcript sidecar 已出现,但不能回写历史 job(2026-05-11 04:44)
继续只读复核后,`agent-qc:process-owner-check` 已回到单一 active blocker:
| 检查 | 当前值 | 结论 |
| --- | --- | --- |
| raw process owner | `busy` | 仍不能启动完整 `verify:local` 或新的 full GUI P0 |
| active GUI smoke | `1` | PID `59011` 的 stale `smoke:design-canvas`,`etime≈8h58m` |
| qcloop worker | `0` | 单项 Skill Forge qcloop worker 已自然结束 |
| Cargo / Rust owner | `0` | 外部编译 owner 已自然清空 |
| official Evidence Pack | `fail` | `.lime/qc/agent-qc-evidence.json` 仍不能发布 |
同时观察到 `.lime/qc/skill-forge-runtime-transcript-current.json` 已生成:
```text
schemaVersion=v1
scenarioId=skill-forge-register-bind-enable
result=pass
evidenceLayersCovered=deterministic-smoke,runtime-transcript
runtimeTranscript.events=8
```
这把 Skill Forge 的剩余缺口进一步收窄:runtime transcript sidecar 已存在,但它是在单项 qcloop job `1778445171616868000` 失败后生成的,不能回写历史 qcloop verdict,也不能覆盖官方 full P0 Evidence Pack。下一轮 owner 清空后,应先重跑 `skill-forge-register-bind-enable` 单项 qcloop,确认 worker / verifier 会采信该 artifact 或重新生成同等 artifact;然后再进入 8/8 single-owner full P0。
当前仍禁止:
- 终止 / pause / interrupt PID `59011`,除非 owner 明确确认 `确认处理 stale raw GUI owner PID 59011,可以终止这些进程并记录 sidecar。`
- 启动新的 full GUI P0。
- 覆盖 `.lime/qc/agent-qc-evidence.json`。
- git commit / push / tag / release。
### 5.32 Skill Forge runtime transcript 单项 qcloop 通过(2026-05-11 04:48)
继续只读观察后,发现第二个单项 qcloop job `1778445676473687000`(`lime-agent-qc-skill-forge-runtime-transcript-2026-05-11-0441`)已在 `127.0.0.1:18087` 终态 `completed`,`1 success / 0 fail / 0 stale`。本轮只导出 sidecar,并运行 release summary 检查;未覆盖官方 Evidence Pack。
| 证据 | 状态 | 说明 |
| --- | --- | --- |
| `.lime/qc/qcloop-status.skill-forge-runtime-transcript-current.json` | `complete` | `skill-forge-register-bind-enable` 单项 success |
| `.lime/qc/agent-qc-evidence.skill-forge-runtime-transcript.json` | `pass` | 只覆盖 `skill-forge-register-bind-enable` 1 个场景 |
| `.lime/qc/release-agent-qc.sidecar-skill-forge-runtime-transcript.md` | `fail` | 缺少其余 7 个 P0 场景,release gate 正确拒绝 |
这说明 `skill-forge-register-bind-enable` 的 runtime-transcript 层已能被 qcloop worker / verifier 采信;该缺口从“需要补 transcript artifact”推进为“需要在下一轮 full P0 同批次重新覆盖”。它仍不能关闭官方发布门禁,因为官方 `.lime/qc/agent-qc-evidence.json` 仍是旧的 8/8 `fail`,且当前 raw process owner 仍被 PID `59011` 阻断。
### 5.33 stale raw GUI owner 处置请求已刷新(2026-05-11 04:52)
最新只读刷新显示,`agent-qc:gui-owner-check -- --check` 仍为 pass,但 raw process owner 仍为 busy:
| 检查 | 当前值 | 结论 |
| --- | --- | --- |
| active GUI smoke | `1` | PID `59011` |
| runtime | `09:06:20` / `32780s` | 远超 `--timeout-ms 600000` 对应的 10 分钟 |
| qcloop worker | `0` | 没有新的 active qcloop worker |
| Cargo / Rust owner | `0` | 没有 active 编译 owner |
| owner intervention | `requires_owner_confirmation` | 仍需要 owner 明确确认才能处理 PID |
已刷新:
- `.lime/qc/gui-process-owner-current.json`
- `.lime/qc/gui-process-owner-current.md`
- `.lime/qc/raw-process-owner-watch-history.jsonl`
- `.lime/qc/stale-raw-gui-owner-intervention-request.json`
- `.lime/qc/stale-raw-gui-owner-intervention-request.md`
确认文本仍是:
```text
确认处理 stale raw GUI owner PID 59011,可以终止这些进程并记录 sidecar。
```
在该确认出现前,当前 Agent 只能继续只读观察或维护文档 / sidecar;不得处理进程、不得启动 full GUI P0、不得覆盖官方 Evidence Pack。
### 5.34 single-owner full P0 待执行 payload 已生成但未启动(2026-05-11 04:54)
在不启动新 qcloop job 的前提下,已生成下一轮 full P0 待执行 payload:
| 产物 | 状态 | 说明 |
| --- | --- | --- |
| `.lime/qc/qcloop-p0-single-owner-ready-2026-05-11-0454.json` | `valid=true` | 覆盖 8 个 P0 scenario |
| `.lime/qc/qcloop-p0-single-owner-ready-2026-05-11-0454.md` | ready note | 记录 scenario IDs、启动前置门禁和当前 blocker |
| qcloop job | not started | 没有提交到 qcloop server,没有抢占 GUI |
payload 覆盖:
```text
command-bridge-contract
claw-chat-ready-streaming
tool-approval-sandbox-boundary
skill-forge-register-bind-enable
browser-runtime-site-adapter
workspace-ready-session-restore
harness-replay-regression
release-package-startup-smoke
```
启动前置仍是:
```bash
npm run agent-qc:process-owner-check -- --check
npm run agent-qc:gui-owner-check -- --check
npm run agent-qc:qcloop-preflight -- --require-devbridge --check
```
当前 PID `59011` 未释放,所以该 payload 只能作为 ready-to-run artifact,不能启动,也不能用于覆盖官方 Evidence Pack。
### 5.35 full P0 payload coverage 已校验(2026-05-11 04:55)
已新增 coverage sidecar:
- `.lime/qc/qcloop-p0-single-owner-ready-coverage-2026-05-11-0455.json`
- `.lime/qc/qcloop-p0-single-owner-ready-coverage-2026-05-11-0455.md`
校验结果:
| 检查 | 当前值 |
| --- | --- |
| status | `blocked` |
| manifest P0 count | `8` |
| payload item count | `8` |
| missing scenarios | `none` |
| extra scenarios | `none` |
| order matches manifest | `true` |
| payload validation | `true` |
这证明待执行 payload 与 `docs/test/agent-qc-scenarios.manifest.json` 的 P0 场景完全一致。`status=blocked` 只来自 owner gate:PID `59011` 仍未释放。该 coverage 不是执行证据,不改变官方 Evidence Pack 状态。
### 5.36 owner 清空后的 full P0 runbook 已准备(2026-05-11 04:57)
已新增 post-owner-clear runbook:
- `.lime/qc/post-owner-clear-full-p0-runbook-2026-05-11-0457.md`
- `.lime/qc/qcloop-p0-single-owner-ready-submit-curl-2026-05-11-0457.txt`
该 runbook 只记录 owner 清空后的执行顺序,不提交 job。核心顺序:
1. `agent-qc:process-owner-check -- --check`
2. `agent-qc:gui-owner-check -- --check`
3. `agent-qc:qcloop-preflight -- --require-devbridge --check`
4. 使用 ready payload 提交单一 qcloop server / DB / port 的 full P0 job。
5. 只在 qcloop 8/8 P0 success 后覆盖官方 `.lime/qc/agent-qc-evidence.json`。
6. 再执行 release summary、completion audit 和 objective checklist。
生成时 guardrails 仍为:未启动 job、未覆盖官方 Evidence、未改 DB、未处理 PID、未执行 git mutation。
### 5.37 full P0 payload coverage 已脚本化(2026-05-11 04:59)
为避免后续继续依赖一次性 Python 片段校验 payload 覆盖,本轮新增可重复门禁:
- `scripts/agent-qc-payload-coverage.mjs`
- `scripts/lib/agent-qc-payload-coverage-core.mjs`
- `scripts/lib/agent-qc-payload-coverage-core.test.ts`
- `package.json` script:`agent-qc:payload-coverage`
当前命令已生成最新 coverage sidecar:
```bash
npm run agent-qc:payload-coverage -- \
--payload "./.lime/qc/qcloop-p0-single-owner-ready-2026-05-11-0454.json" \
--format json \
--output "./.lime/qc/qcloop-p0-single-owner-ready-coverage-current.json" \
--check
```
结果:coverage `passed=true`,missing / extra 均为空,owner status 仍为 `busy`,因此 overall status 是 `blocked`。这把“payload 是否覆盖 manifest P0”的检查从手工 sidecar 提升为可重复工具,但不改变当前发布阻断:PID `59011` 仍未释放,full P0 未启动。
### 5.38 completion audit 纳入 payload coverage(2026-05-11 05:02)
`agent-qc:audit` 已新增 `qcloop-payload-coverage` 检查项,要求 ready payload coverage sidecar 显示 P0 manifest 覆盖完整。最新刷新:
| 门禁 | 当前值 | 说明 |
| --- | --- | --- |
| completion audit | `17/18`,`status=incomplete` | 新增 payload coverage 项为 PASS |
| payload coverage item | `pass` | `status=blocked coverage=pass manifestP0=8 payloadItems=8 missing=0 extra=0 owner=busy` |
| objective checklist | `5/7`,`status=incomplete` | 仍被 raw process owner 与 official Evidence Pack 阻断 |
该项只证明 ready payload 覆盖正确,不代表 full P0 已执行。最终发布门禁仍只接受官方 `.lime/qc/agent-qc-evidence.json` 的真实 8/8 P0 pass。
### 5.39 执行矩阵纳入 payload coverage gate(2026-05-11 05:04)
`docs/tests/lime-agent-autonomous-test-execution-matrix.md` 的执行前 Owner Gate 已补充 `npm run agent-qc:payload-coverage`。后续 Agent 在启动 full P0 前,不仅要确认 GUI / raw process / DB lease 清空,还必须确认 ready payload 与 P0 manifest 一致。
新增通过条件:
```text
qcloop-p0-single-owner-ready-coverage-current.json:
coverage.passed=true
missing=[]
extra=[]
```
新增阻断条件:ready payload 与 manifest P0 不一致,或 payload coverage sidecar 未生成。该更新把刚新增的脚本纳入人读执行矩阵,避免后续 Agent 只看 owner clear 就直接启动过期 payload。
### 5.40 stale owner runbook 纳入精确进程树计划(2026-05-11 05:10)
`docs/tests/lime-agent-qc-stale-owner-intervention.md` 已新增 8.6,明确确认前可以做只读进程树取证,但不能发送 signal。该节把以下 sidecar 纳入标准处置链:
- `.lime/qc/stale-raw-gui-owner-process-tree-current.json`
- `.lime/qc/stale-raw-gui-owner-recursive-tree-current.json`
- `.lime/qc/stale-raw-gui-owner-intervention-plan-current.json`
当前计划显示 recommended scope 为 `process_group`,但仍只是确认后的计划。没有 owner 确认文本前,仍不得处理 PID `59011` 或其 process group。
@@ -467,3 +467,51 @@ npm run agent-qc:gui-owner-check -- \
2026-05-11 01:28 追加完成审计前只读续刷:`qcloop-status.isolated-p0-full-v1-current.json` 仍为 `stale`,job `1778405842243079000` 保持 `4 success / 1 running / 3 pending / 1 stale`;active item `browser-runtime-site-adapter` stale 约 `27786s`,worker stdout/stderr 仍为 `0 / 0`。`gui-process-owner-current.json` 仍为 `busy`:`activeGuiSmoke=2`、`cargoOrRust=5`、`qcloopRelated=7`。SQLite 只读复核显示 lease 被续约到 `2026-05-11T01:43:06+08:00`。因此仍不能启动完整 `verify:local` 或新的 full GUI P0;completion audit 仍为 `incomplete`,缺口为 `real-qcloop-evidence` 与 `local-verify-gate`。
2026-05-11 01:32 追加 DB lease 取证脚本化:新增 `npm run agent-qc:qcloop-db-lease`,用于从 qcloop SQLite DB 只读导出 active item、lease、attempt stdout/stderr 长度与进程快照,替代临时 `sqlite3` 片段。使用该脚本刷新后,job `1778405842243079000` 仍为 `running`,active item `1778405842246191000` 仍是 `browser-runtime-site-adapter`,`lock_owner=qcloop-worker-1`,`lock_expires_at=2026-05-11T01:47:06+08:00`,active attempt `dc625f8e-b3b9-46b7-9758-4b0273438d50` stdout/stderr 仍为空。raw process owner 仍为 `busy`:`activeGuiSmoke=2`、`cargoOrRust=4`、`qcloopRelated=7`。本轮仍未处理 PID `69738`、未改 DB、未启动完整 `verify:local` 或新 full GUI P0。
2026-05-11 02:47 追加 raw process owner 精确化:`agent-qc:process-owner-check` 现在把 observer shell、passive qcloop serve 与 passive Tauri dev runtime 从 active owner 中拆出,只把真正会抢 GUI / 构建 / worker 的进程计入阻断。最新 `.lime/qc/gui-process-owner-current.json` 显示 `activeGuiSmoke=1`、`cargoOrRust=0`、`qcloopRelated=0`、`passiveQcloopServer=6`、`passiveTauriRuntime=4`、`observer=1`;唯一 active blocker 是 PID `59011` 的 `npm run smoke:design-canvas ...`,已运行约 7 小时并被标记为 `staleActiveGuiSmoke=1`。该 sidecar 给出 `ownerIntervention.status=requires_owner_confirmation` 和确认文本;确认前仍不得 kill / pause / interrupt,也不得启动完整 `verify:local` 或新 full GUI P0。
2026-05-11 02:51 追加 stale raw GUI owner 处置请求:再次只读刷新后,PID `59011` 仍是唯一 active raw GUI smoke owner,`etime=07:04:53`。已写入 `.lime/qc/stale-raw-gui-owner-intervention-request.json` / `.md`,其中 `guardrails` 明确 `processTerminated=false`、`qcloopDbModified=false`、`officialEvidenceOverwritten=false`、`gitMutation=false`、`newFullP0Started=false`、`verifyLocalStarted=false`。确认前仍不得处理进程或启动重型门禁。
2026-05-11 02:55 追加 process owner core 回归:新增 `scripts/lib/agent-qc-process-owner-core.mjs` 与 `scripts/lib/agent-qc-process-owner-core.test.ts`,覆盖 etime 解析、命令脱敏、active GUI owner / passive qcloop serve / passive Tauri runtime / observer 分类,以及 active qcloop worker 与 Cargo build 仍阻断的场景。`npx vitest run scripts/lib/agent-qc-process-owner-core.test.ts scripts/lib/agent-qc-completion-audit-core.test.ts` 通过 `21 tests`。最新 process owner check 仍失败,唯一 active blocker 仍是 PID `59011`。
2026-05-11 02:59 追加 objective checklist:新增 `.lime/qc/objective-completion-checklist-current.json` / `.md`,把用户目标逐项映射到 docs/tests、manifest/schema、qcloop tooling、owner gate、官方 Evidence Pack、`verify:local` 和 git guardrail。短暂运行的外部 `verify:gui-smoke` transient owner 已自然结束,最新 process owner 又只剩 PID `59011` 作为 active blocker。
2026-05-11 03:01 追加 raw process owner watch history:`agent-qc:process-owner-check -- --watch-history-output ./.lime/qc/raw-process-owner-watch-history.jsonl` 已可追加 JSONL 观察记录。最新记录仍为 `busy`,唯一 active blocker 是 PID `59011`,`etime=07:16:00`。该历史只用于证明持续 stale,不构成处置授权。
2026-05-11 03:04 追加 objective checklist 脚本化:新增 `scripts/agent-qc-objective-checklist.mjs` 与 `npm run agent-qc:objective-checklist`。该脚本读取 completion audit、GUI owner、raw process owner,生成 `.lime/qc/objective-completion-checklist-current.json` / `.md`,并在 `--check` 下对 incomplete 状态 exit `1`。当前 checklist 仍为 `4/7`,不会把 sidecar 或 partial pass 误判为完成。
2026-05-11 03:07 追加 objective checklist core 回归:新增 `scripts/lib/agent-qc-objective-checklist-core.mjs` 与 `scripts/lib/agent-qc-objective-checklist-core.test.ts`,覆盖 owner clear / owner busy / official evidence fail / verify:local fail / Markdown 渲染。`npx vitest run scripts/lib/agent-qc-objective-checklist-core.test.ts scripts/lib/agent-qc-process-owner-core.test.ts scripts/lib/agent-qc-completion-audit-core.test.ts` 通过 `25 tests`。当前实际 checklist 仍为 `4/7`,`--check` 继续按预期 exit `1`。
2026-05-11 03:09 追加 raw GUI owner runbook:`docs/tests/lime-agent-qc-stale-owner-intervention.md` 第 8 节已覆盖 raw GUI owner stale 场景,明确确认文本、禁止动作、post-confirmation 最小闭环和关闭条件。该文档不授权处理 PID `59011`,只为后续 owner 明确确认后的执行提供边界。
2026-05-11 03:11 追加 local verify gate 关闭:`.lime/qc/verify-local-current.json` 与 `.lime/qc/verify-gui-smoke-current.json` 已为 `status=pass`。`agent-qc:audit` 当前为 `16/17`,只剩 `real-qcloop-evidence`;`agent-qc:objective-checklist` 当前为 `5/7`,`verify:local` 项已 PASS。raw process owner 仍因 PID `59011` busy,不能启动新的 full P0 qcloop。
2026-05-11 03:50 追加隔离 P0 full v2 终态记录:观察到本地已有 job `1778440541478632000`(`lime-agent-qc-isolated-p0-full-v2-2026-05-11-0315`)运行在 `127.0.0.1:18086` 并进入终态 `failed`,`qcloop-status.isolated-p0-full-v2-current.json` 为 `3 success / 5 exhausted / 0 stale`。已导出 sidecar `.lime/qc/agent-qc-evidence.isolated-p0-full-v2-2026-05-11-0315.json`,release summary `--check` 正确 exit `1`。该批次不能覆盖官方 Evidence Pack;本轮未启动新批次、未 kill 进程、未改 DB、未 push。随后 direct `npm run test:contracts` 通过,说明 v2 中 `command-bridge-contract` 的 mock drift 是历史运行证据,不能回写为 pass。
2026-05-11 04:24 追加隔离 P0 full v3 终态记录:观察到 job `1778442773271496000`(`lime-agent-qc-isolated-p0-full-v3-2026-05-11-0354`)运行在 `127.0.0.1:18087` 并进入终态 `failed`,`qcloop-status.isolated-p0-full-v3-current.json` 为 `4 success / 4 exhausted / 0 stale`。已导出 sidecar `.lime/qc/agent-qc-evidence.isolated-p0-full-v3-2026-05-11-0354.json`,release summary `--check` 正确 exit `1`。v3 pass 场景为 `command-bridge-contract`、`claw-chat-ready-streaming`、`tool-approval-sandbox-boundary`、`harness-replay-regression`;剩余失败 / blocked 场景为 `skill-forge-register-bind-enable`、`browser-runtime-site-adapter`、`workspace-ready-session-restore`、`release-package-startup-smoke`。最新 process owner 仍为 `busy`:activeGuiSmoke=`1`、qcloopRelated=`0`,cargoOrRust 数量随外部编译进度波动。PID `59011` 仍是 stale `smoke:design-canvas`,`etime≈8h41m`;同时外部 Rust 定向测试 / rustc 编译仍在跑。因此仍不得覆盖官方 `.lime/qc/agent-qc-evidence.json`,也不得发布。
2026-05-11 04:31 追加 Skill Forge P0 定向补证:v3 中 `skill-forge-register-bind-enable` 失败的核心原因是此前 `smoke:agent-service-skill-entry` 的 Rust exact filter 实际运行 0 tests,verifier 正确拒绝 runtime / SkillTool gate 证据。随后观察到当前 `scripts/agent-service-skill-entry-smoke.mjs` 已改为显式 `-p lime` / `-p lime-agent`、完整 test path + `--exact`,并拒绝没有 `N passed` 的 Rust 定向测试。证据 `.lime/qc/smoke-agent-service-skill-entry-after-rust-exact-fix-2026-05-11-0430.log` 显示当前 smoke 已通过,关键 Rust tests 均出现 `running 1 test` / `1 passed`,服务技能入口路由与 Agent A2UI 挂起主链 Vitest 也通过。该结果只关闭 Skill Forge 的本地定向 smoke 缺口,不能回写 v3 sidecar,也不能替代新的 single-owner full P0。
2026-05-11 04:32 追加 owner 复核:外部 Rust/Cargo owner 已自然清空,`agent-qc:process-owner-check` 最新 sidecar 为 `activeGuiSmoke=1`、`cargoOrRust=0`、`qcloopRelated=0`、`staleActiveGuiSmoke=1`。唯一 active blocker 仍是 PID `59011` 的 stale `smoke:design-canvas`;确认前仍不得 kill / pause / interrupt,也不得启动新的 full GUI P0。
2026-05-11 04:39 追加 Skill Forge 单项 qcloop 补证终态:job `1778445171616868000`(`lime-agent-qc-skill-forge-rust-exact-fix-2026-05-11-0432`)在 `127.0.0.1:18087` 进入终态 `failed`,`0 success / 1 exhausted / 0 stale`。已导出 `.lime/qc/agent-qc-evidence.skill-forge-rust-exact-fix.json`,release summary `--check` 正确 exit `1`,因为该 sidecar 只覆盖单项且仍缺 runtime-transcript 层。该批次证明 deterministic smoke 侧已不再卡在 Rust exact filter / running 0 tests;新的真实缺口是 `skill-forge-register-bind-enable` 需要 live submit / stream / tool-request / decision / result 级 runtime transcript artifact。
2026-05-11 04:44 追加 post-job runtime transcript 与 owner 复核:再次只读刷新后,qcloop worker 进程已清空,raw process owner 仍为 `busy`,唯一 active blocker 是 PID `59011` 的 stale `smoke:design-canvas`,`activeGuiSmoke=1`、`cargoOrRust=0`、`qcloopRelated=0`、`staleActiveGuiSmoke=1`。同时 `.lime/qc/skill-forge-runtime-transcript-current.json` 已生成,`scenarioId=skill-forge-register-bind-enable`、`result=pass`、`evidenceLayersCovered=deterministic-smoke,runtime-transcript`、`runtimeTranscript.events=8`。该 artifact 生成时间晚于单项 qcloop 失败,不能回写 `1778445171616868000`,只能作为下一轮 single-owner 单项重跑的输入或对照。当前仍未 kill / pause / interrupt PID `59011`,未改 qcloop DB,未覆盖官方 Evidence Pack,未执行 git commit / push / tag / release。
2026-05-11 04:48 追加 Skill Forge runtime transcript 单项通过:观察到 job `1778445676473687000`(`lime-agent-qc-skill-forge-runtime-transcript-2026-05-11-0441`)已 completed,`qcloop-status.skill-forge-runtime-transcript-current.json` 为 `1 success / 0 stale`。已导出 sidecar `.lime/qc/agent-qc-evidence.skill-forge-runtime-transcript.json`,其中 `skill-forge-register-bind-enable` 为 `pass`。同时运行 release summary `--check` 到 `.lime/qc/release-agent-qc.sidecar-skill-forge-runtime-transcript.md`,按预期 exit `1`,因为单项 sidecar 缺少其余 7 个 P0 场景。该结果可以作为下一轮 full P0 的前置信号,但不能覆盖官方 `.lime/qc/agent-qc-evidence.json`。
2026-05-11 04:52 追加 stale raw GUI owner 处置请求刷新:`agent-qc:process-owner-check` 仍显示 `activeGuiSmoke=1`、`cargoOrRust=0`、`qcloopRelated=0`、`staleActiveGuiSmoke=1`,唯一 active blocker 是 PID `59011`,`etime=09:06:20`。已刷新 `.lime/qc/stale-raw-gui-owner-intervention-request.json` / `.md`,确认文本保持 `确认处理 stale raw GUI owner PID 59011,可以终止这些进程并记录 sidecar。`。确认前仍未 kill / pause / interrupt,未改 DB,未启动 full GUI P0,未覆盖官方 Evidence Pack。
2026-05-11 04:54 追加 single-owner full P0 ready payload:已运行 `agent-qc:qcloop-job -- --risk P0 --check` 生成 `.lime/qc/qcloop-p0-single-owner-ready-2026-05-11-0454.json`,并写入说明 `.lime/qc/qcloop-p0-single-owner-ready-2026-05-11-0454.md`。该 payload 覆盖 8 个 P0 scenario 且 `_validation.valid=true`,但仅为待执行产物,没有提交 qcloop job、没有启动 GUI P0、没有覆盖官方 Evidence Pack。启动前仍必须先通过 `agent-qc:process-owner-check -- --check`、`agent-qc:gui-owner-check -- --check` 与 `agent-qc:qcloop-preflight -- --require-devbridge --check`。
2026-05-11 04:55 追加 ready payload coverage:新增 `.lime/qc/qcloop-p0-single-owner-ready-coverage-2026-05-11-0455.json` / `.md`,比较 ready payload 与 `docs/test/agent-qc-scenarios.manifest.json`。结果为 manifest P0 count=`8`、payload item count=`8`、missing=`none`、extra=`none`、orderMatchesManifest=`true`、payloadValidation=`true`;整体 status 仍为 `blocked`,因为 raw process owner gate 仍 busy。该 coverage 只证明待执行 payload 完整,不是 qcloop 执行结果。
2026-05-11 04:57 追加 post-owner-clear full P0 runbook:新增 `.lime/qc/post-owner-clear-full-p0-runbook-2026-05-11-0457.md` 与 `.lime/qc/qcloop-p0-single-owner-ready-submit-curl-2026-05-11-0457.txt`。该 runbook 明确 owner 清空后先跑 process owner / GUI owner / DevBridge preflight,再使用 dedicated qcloop port 提交 ready payload;只有 8/8 P0 success 后才允许覆盖官方 `.lime/qc/agent-qc-evidence.json` 并运行 release summary / audit / objective checklist。生成时未提交 job,未改 DB,未覆盖官方 evidence。
2026-05-11 04:59 追加 payload coverage 脚本化:新增 `npm run agent-qc:payload-coverage`,由 `scripts/agent-qc-payload-coverage.mjs` 读取 manifest、ready payload 和 raw process owner sidecar,输出 coverage / owner gate 状态。当前 `.lime/qc/qcloop-p0-single-owner-ready-coverage-current.json` 显示 coverage `passed=true`、missing=`[]`、extra=`[]`、owner=`busy`、status=`blocked`。`scripts/lib/agent-qc-payload-coverage-core.test.ts` 已覆盖 P0 提取、字符串 item 解析、ready / blocked / missing 场景。
2026-05-11 05:02 追加 completion audit 同步:`agent-qc:audit` 新增 `qcloop-payload-coverage` 检查项,最新 `.lime/qc/objective-completion-audit-current.json` 为 `17/18`,只剩 `real-qcloop-evidence`。该同步把 ready payload coverage 纳入目标审计,但不放宽发布门禁;payload coverage pass 仍不能替代 qcloop 8/8 full P0 pass。
2026-05-11 05:04 追加执行矩阵同步:`docs/tests/lime-agent-autonomous-test-execution-matrix.md` 的执行前 Owner Gate 已加入 `npm run agent-qc:payload-coverage`,并把 payload coverage sidecar 缺失或 manifest 不一致列为阻断条件。该更新保证后续启动 full P0 前同时满足 owner clear、DB lease clear、DevBridge preflight 和 payload coverage pass。
2026-05-11 05:10 追加 stale owner runbook 同步:`docs/tests/lime-agent-qc-stale-owner-intervention.md` 第 8.6 节已纳入精确进程树与 intervention plan sidecar,要求确认前只读生成 process tree / recursive tree / intervention plan,确认后也必须重新生成以防 PID / PGID 变化。该更新不授权当前 Agent 处理 PID `59011`,只把后续 owner 确认后的最小处置范围固化到长期 runbook。
@@ -162,3 +162,102 @@ node scripts/agent-qc-completion-audit.mjs --format json > ".lime/qc/agent-qc-au
```
如果 release summary 报缺少结构化 evidenceRefs,不能手工补假路径;应回到 qcloop worker stdout,确认 `QCLOOP_EVIDENCE_SUMMARY_JSON` 的 `artifacts[]`、`gui_session_owner` 和 `release_scope` 是否真实存在。
## 8. raw GUI owner stale 处置补充
当 `agent-qc:gui-owner-check -- --check` 已通过,但 `agent-qc:process-owner-check -- --check` 仍因 raw GUI smoke 进程阻断时,按本节处理。该场景不是 qcloop job owner,而是宿主机上仍存活的 GUI smoke / Playwright / Chrome owner。
当前典型状态:
```text
gui owner: pass
process owner: busy
activeGuiSmoke=1
staleActiveGuiSmoke=1
ownerIntervention=requires_owner_confirmation
PID=59011
command=npm run smoke:design-canvas ...
```
### 8.1 只读取证
```bash
npm run agent-qc:process-owner-check -- \
--format json \
--output "./.lime/qc/gui-process-owner-current.json" \
--markdown-output "./.lime/qc/gui-process-owner-current.md" \
--watch-history-output "./.lime/qc/raw-process-owner-watch-history.jsonl"
npm run agent-qc:objective-checklist -- \
--format json \
--output "./.lime/qc/objective-completion-checklist-current.json"
```
机器可读处置请求位于:
- `.lime/qc/stale-raw-gui-owner-intervention-request.json`
- `.lime/qc/stale-raw-gui-owner-intervention-request.md`
### 8.2 禁止操作
确认前禁止:
- `kill` / `pkill` / `killall` raw GUI smoke、Playwright 或 Chrome 进程。
- 启动完整 `npm run verify:local`。
- 启动新的 full GUI P0 qcloop 批次。
- 覆盖官方 `.lime/qc/agent-qc-evidence.json`。
- 执行 git commit / push / tag / release。
### 8.3 raw owner 确认格式
必须看到完整确认文本后才能处理:
```text
确认处理 stale raw GUI owner PID 59011,可以终止这些进程并记录 sidecar。
```
如果 PID 已变化,必须重新运行 `agent-qc:process-owner-check`,使用最新 `ownerIntervention.requiredConfirmationText`,不得复用旧 PID。
### 8.4 获得确认后的最小闭环
1. 再次运行 `agent-qc:process-owner-check`,确认 PID、PGID、命令和 `etime`。
2. 记录 before sidecar,包含 PID、PGID、命令、确认文本和禁止动作清单。
3. 只处理 owner 明确确认的 PID / 进程组;不要顺手清理 passive qcloop serve、passive Tauri runtime 或 observer shell。
4. 处理后立即运行:
```bash
npm run agent-qc:process-owner-check -- --check
npm run agent-qc:gui-owner-check -- --check
```
5. 两个 owner gate 都通过后,先跑完整 `npm run verify:local`;只有 verify local pass 后,才允许进入新的 single-owner full P0 qcloop。
### 8.5 关闭条件
raw owner stale 处置完成必须同时满足:
- `agent-qc:process-owner-check -- --check` 通过。
- `agent-qc:gui-owner-check -- --check` 通过。
- action sidecar 证明只处理了 owner 确认的 PID / PGID。
- `objective-completion-checklist-current.json` 不再把 raw process owner 列为 blocker。
### 8.6 精确进程树与处置计划
确认前可以继续做只读进程树取证,但不能发送 signal。当前标准 sidecar:
- `.lime/qc/stale-raw-gui-owner-process-tree-current.json`
- `.lime/qc/stale-raw-gui-owner-recursive-tree-current.json`
- `.lime/qc/stale-raw-gui-owner-intervention-plan-current.json`
这些 sidecar 的用途是缩小确认后的处置范围,而不是授权处置。若 `intervention-plan-current.json` 显示:
```text
recommendedScope=process_group
rootPid=59011
rootPgid=59011
processGroupPids=59011,59100
```
则确认后的默认动作是只处理该已确认 process group;递归树中属于子 shell / browser helper 的 PID 只作为佐证,不允许额外扩大到 passive qcloop serve、passive Tauri runtime、系统 Chrome 或其他 observer shell。
处置前必须重新生成 `recursive-tree-current` 和 `intervention-plan-current`,以防 PID / PGID 被系统复用或子进程已自然退出。若最新计划与确认文本中的 PID 不一致,停止并重新请求 owner 确认。
+65 -64
View File
@@ -1,12 +1,13 @@
{
"name": "lime",
"version": "1.33.0",
"version": "1.34.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "lime",
"version": "1.33.0",
"version": "1.34.0",
"license": "GPL-3.0-only",
"dependencies": {
"@babel/standalone": "^7.29.0",
"@fabianlars/tauri-plugin-oauth": "^2",
@@ -27,9 +28,9 @@
"@radix-ui/react-tooltip": "^1.1.3",
"@sentry/browser": "^8.55.0",
"@tanstack/react-virtual": "^3.13.18",
"@tauri-apps/api": "2.10.1",
"@tauri-apps/plugin-deep-link": "2.4.7",
"@tauri-apps/plugin-dialog": "2.6.0",
"@tauri-apps/api": "2.11.0",
"@tauri-apps/plugin-deep-link": "2.4.9",
"@tauri-apps/plugin-dialog": "2.7.1",
"@tauri-apps/plugin-global-shortcut": "2.3.1",
"@tauri-apps/plugin-shell": "2.3.5",
"@tiptap/core": "^3.19.0",
@@ -94,7 +95,7 @@
"@babel/plugin-transform-react-jsx-source": "^7.27.1",
"@eslint/js": "^9.15.0",
"@fast-check/vitest": "^0.2.4",
"@tauri-apps/cli": "^2.10.1",
"@tauri-apps/cli": "^2.11.1",
"@types/babel__standalone": "^7.1.9",
"@types/fabric": "^5.3.11",
"@types/node": "^22.9.0",
@@ -3465,9 +3466,9 @@
}
},
"node_modules/@tauri-apps/api": {
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz",
"integrity": "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==",
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.0.tgz",
"integrity": "sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA==",
"license": "Apache-2.0 OR MIT",
"funding": {
"type": "opencollective",
@@ -3475,9 +3476,9 @@
}
},
"node_modules/@tauri-apps/cli": {
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.10.1.tgz",
"integrity": "sha512-jQNGF/5quwORdZSSLtTluyKQ+o6SMa/AUICfhf4egCGFdMHqWssApVgYSbg+jmrZoc8e1DscNvjTnXtlHLS11g==",
"version": "2.11.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.1.tgz",
"integrity": "sha512-rpEbaJ/HzNb6fwsquwoAbq29/Vt4gADhS423A8fdkwL4edJ0wZmoB8ar7O6JPDL834MUKOCm/rrJ7c9oAaEaYQ==",
"dev": true,
"license": "Apache-2.0 OR MIT",
"bin": {
@@ -3491,23 +3492,23 @@
"url": "https://opencollective.com/tauri"
},
"optionalDependencies": {
"@tauri-apps/cli-darwin-arm64": "2.10.1",
"@tauri-apps/cli-darwin-x64": "2.10.1",
"@tauri-apps/cli-linux-arm-gnueabihf": "2.10.1",
"@tauri-apps/cli-linux-arm64-gnu": "2.10.1",
"@tauri-apps/cli-linux-arm64-musl": "2.10.1",
"@tauri-apps/cli-linux-riscv64-gnu": "2.10.1",
"@tauri-apps/cli-linux-x64-gnu": "2.10.1",
"@tauri-apps/cli-linux-x64-musl": "2.10.1",
"@tauri-apps/cli-win32-arm64-msvc": "2.10.1",
"@tauri-apps/cli-win32-ia32-msvc": "2.10.1",
"@tauri-apps/cli-win32-x64-msvc": "2.10.1"
"@tauri-apps/cli-darwin-arm64": "2.11.1",
"@tauri-apps/cli-darwin-x64": "2.11.1",
"@tauri-apps/cli-linux-arm-gnueabihf": "2.11.1",
"@tauri-apps/cli-linux-arm64-gnu": "2.11.1",
"@tauri-apps/cli-linux-arm64-musl": "2.11.1",
"@tauri-apps/cli-linux-riscv64-gnu": "2.11.1",
"@tauri-apps/cli-linux-x64-gnu": "2.11.1",
"@tauri-apps/cli-linux-x64-musl": "2.11.1",
"@tauri-apps/cli-win32-arm64-msvc": "2.11.1",
"@tauri-apps/cli-win32-ia32-msvc": "2.11.1",
"@tauri-apps/cli-win32-x64-msvc": "2.11.1"
}
},
"node_modules/@tauri-apps/cli-darwin-arm64": {
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.10.1.tgz",
"integrity": "sha512-Z2OjCXiZ+fbYZy7PmP3WRnOpM9+Fy+oonKDEmUE6MwN4IGaYqgceTjwHucc/kEEYZos5GICve35f7ZiizgqEnQ==",
"version": "2.11.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.1.tgz",
"integrity": "sha512-6eEKMBXsQPCuM1EmvrjT2+aBuxWQuFdKdW8pzNuNQtpq45nEEpBlD5gr8pUeAyOU1DQKlkFaEc/MPBxb/Pfjtg==",
"cpu": [
"arm64"
],
@@ -3522,9 +3523,9 @@
}
},
"node_modules/@tauri-apps/cli-darwin-x64": {
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.10.1.tgz",
"integrity": "sha512-V/irQVvjPMGOTQqNj55PnQPVuH4VJP8vZCN7ajnj+ZS8Kom1tEM2hR3qbbIRoS3dBKs5mbG8yg1WC+97dq17Pw==",
"version": "2.11.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.1.tgz",
"integrity": "sha512-LQUO7exfRWjWALNhetph5guWpMeHphRpokOLk0OIbTTExaNwJNFu3I4vb+CCM/4G/QGoZe/5XikZOJdNEFP1ig==",
"cpu": [
"x64"
],
@@ -3539,9 +3540,9 @@
}
},
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.10.1.tgz",
"integrity": "sha512-Hyzwsb4VnCWKGfTw+wSt15Z2pLw2f0JdFBfq2vHBOBhvg7oi6uhKiF87hmbXOBXUZaGkyRDkCHsdzJcIfoJC2w==",
"version": "2.11.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.1.tgz",
"integrity": "sha512-5i/awiBCRRhOUG8yjn0fMHXIWD5Ez8eEk5LtvOxyQrKuJkRaZDvnbIjZbE183blAwkoA4xN3aO/prJiqscl02Q==",
"cpu": [
"arm"
],
@@ -3556,9 +3557,9 @@
}
},
"node_modules/@tauri-apps/cli-linux-arm64-gnu": {
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.10.1.tgz",
"integrity": "sha512-OyOYs2t5GkBIvyWjA1+h4CZxTcdz1OZPCWAPz5DYEfB0cnWHERTnQ/SLayQzncrT0kwRoSfSz9KxenkyJoTelA==",
"version": "2.11.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.1.tgz",
"integrity": "sha512-9LrwDw3S9Fygtw/Q6WDhOP+3svJRGAsejeE+GKrc0eO1ThMVhwi2LL6hw4dlKw93IfS7VY1G19sWGxJ/NcU4nA==",
"cpu": [
"arm64"
],
@@ -3573,9 +3574,9 @@
}
},
"node_modules/@tauri-apps/cli-linux-arm64-musl": {
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.10.1.tgz",
"integrity": "sha512-MIj78PDDGjkg3NqGptDOGgfXks7SYJwhiMh8SBoZS+vfdz7yP5jN18bNaLnDhsVIPARcAhE1TlsZe/8Yxo2zqg==",
"version": "2.11.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.1.tgz",
"integrity": "sha512-mNA5dbbqPqDUdTIwdUYYuhO2GvIe9UnB2r0VU2njxBOS3Opbx4gKNC5yP0Iu4rYmEmqdlwry9VzGZQ3wq9dyFg==",
"cpu": [
"arm64"
],
@@ -3590,9 +3591,9 @@
}
},
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.10.1.tgz",
"integrity": "sha512-X0lvOVUg8PCVaoEtEAnpxmnkwlE1gcMDTqfhbefICKDnOTJ5Est3qL0SrWxizDackIOKBcvtpejrSiVpuJI1kw==",
"version": "2.11.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.1.tgz",
"integrity": "sha512-fZj3Gwq+6fUs305T5WQiD5iSGJw+j/4w/HGmk4sHDAcy+rp9zU5eaxB7nOyz5/I/nkNAuKPqfp6uIbiUBXkBCw==",
"cpu": [
"riscv64"
],
@@ -3607,9 +3608,9 @@
}
},
"node_modules/@tauri-apps/cli-linux-x64-gnu": {
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.10.1.tgz",
"integrity": "sha512-2/12bEzsJS9fAKybxgicCDFxYD1WEI9kO+tlDwX5znWG2GwMBaiWcmhGlZ8fi+DMe9CXlcVarMTYc0L3REIRxw==",
"version": "2.11.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.1.tgz",
"integrity": "sha512-XFxGxOvHM7jjeD6ozCKdGfhzJ7lERYDGZl1/Kb4fsvchaJsfLJ981TlyTG8Qy/gFq+f5GitH3bfrX9JAkjPEyw==",
"cpu": [
"x64"
],
@@ -3624,9 +3625,9 @@
}
},
"node_modules/@tauri-apps/cli-linux-x64-musl": {
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.10.1.tgz",
"integrity": "sha512-Y8J0ZzswPz50UcGOFuXGEMrxbjwKSPgXftx5qnkuMs2rmwQB5ssvLb6tn54wDSYxe7S6vlLob9vt0VKuNOaCIQ==",
"version": "2.11.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.1.tgz",
"integrity": "sha512-d5C2/Zm+68v7R9wTuTCjRQEVrWjcdMkJBZ1+rXse+QdMMlTB9+u9PDNDLw9PQflWxYLaYZ7tjxxL9Nb9II6PbA==",
"cpu": [
"x64"
],
@@ -3641,9 +3642,9 @@
}
},
"node_modules/@tauri-apps/cli-win32-arm64-msvc": {
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.10.1.tgz",
"integrity": "sha512-iSt5B86jHYAPJa/IlYw++SXtFPGnWtFJriHn7X0NFBVunF6zu9+/zOn8OgqIWSl8RgzhLGXQEEtGBdR4wzpVgg==",
"version": "2.11.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.1.tgz",
"integrity": "sha512-YdeVWFAR1pTXzUU6NLstPq4G6OLxuDrXCXEBdmBH+5EZIDXUx0D2kJlz3+YjpazkKvAzYpgziTsyRagls0OfRQ==",
"cpu": [
"arm64"
],
@@ -3658,9 +3659,9 @@
}
},
"node_modules/@tauri-apps/cli-win32-ia32-msvc": {
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.10.1.tgz",
"integrity": "sha512-gXyxgEzsFegmnWywYU5pEBURkcFN/Oo45EAwvZrHMh+zUSEAvO5E8TXsgPADYm31d1u7OQU3O3HsYfVBf2moHw==",
"version": "2.11.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.1.tgz",
"integrity": "sha512-VBGkuH0eB9K9LLSMv361Gzr5Ou72sCS4+ztpmkWEQ+wd/amhcYOsf3X6qn1RJZDzIhiOYHJEOysZUC3baD01rA==",
"cpu": [
"ia32"
],
@@ -3675,9 +3676,9 @@
}
},
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.10.1.tgz",
"integrity": "sha512-6Cn7YpPFwzChy0ERz6djKEmUehWrYlM+xTaNzGPgZocw3BD7OfwfWHKVWxXzdjEW2KfKkHddfdxK1XXTYqBRLg==",
"version": "2.11.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.1.tgz",
"integrity": "sha512-b3ORhIAKgp9ZYY+zBt7b7r0kLU2kjvyGF0+MS2SBym3emsweGPybEqocJcmtMuxyBhkOKHP4CiuEJEDuAlTx6A==",
"cpu": [
"x64"
],
@@ -3692,21 +3693,21 @@
}
},
"node_modules/@tauri-apps/plugin-deep-link": {
"version": "2.4.7",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-deep-link/-/plugin-deep-link-2.4.7.tgz",
"integrity": "sha512-K0FQlLM6BoV7Ws2xfkh+Tnwi5VZVdkI4Vw/3AGLSf0Xvu2y86AMBzd9w/SpzKhw9ai2B6ES8di/OoGDCExkOzg==",
"version": "2.4.9",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-deep-link/-/plugin-deep-link-2.4.9.tgz",
"integrity": "sha512-u0SKOUHnJ1wqeqXsDFq2+kASCBj9xxbG0g9XZWPy9SOmU4wXtp6b/wiYpm6oH6/5fBTQsLqnLhIvqLBRpgHJlA==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.10.1"
"@tauri-apps/api": "^2.11.0"
}
},
"node_modules/@tauri-apps/plugin-dialog": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.6.0.tgz",
"integrity": "sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==",
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.1.tgz",
"integrity": "sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.8.0"
"@tauri-apps/api": "^2.11.0"
}
},
"node_modules/@tauri-apps/plugin-global-shortcut": {
+8 -6
View File
@@ -1,7 +1,7 @@
{
"name": "lime",
"private": true,
"version": "1.33.0",
"version": "1.34.0",
"description": "AI content workspace for Chinese creators: desktop writing, research, prompt management, knowledge base, and multi-model workflows.",
"keywords": [
"ai",
@@ -84,6 +84,7 @@
"agent-qc:gui-flow:check": "node scripts/agent-qc-gui-flow-report.mjs --check --format json",
"agent-qc:check": "node scripts/agent-qc-report.mjs --check --format json && node scripts/agent-qc-gui-flow-report.mjs --check --format json",
"agent-qc:qcloop-job": "node scripts/agent-qc-qcloop-job.mjs",
"agent-qc:payload-coverage": "node scripts/agent-qc-payload-coverage.mjs",
"agent-qc:qcloop-status": "node scripts/agent-qc-qcloop-status.mjs",
"agent-qc:qcloop-db-lease": "node scripts/agent-qc-qcloop-db-lease.mjs",
"agent-qc:qcloop-preflight": "node scripts/agent-qc-qcloop-preflight.mjs",
@@ -127,7 +128,8 @@
"smoke:agent-runtime-approval-sandbox": "node scripts/agent-runtime-approval-sandbox-smoke.mjs",
"agent-qc:gui-owner-check": "node scripts/agent-qc-gui-owner-check.mjs",
"agent-qc:process-owner-check": "node scripts/agent-qc-process-owner-check.mjs",
"agent-qc:secret-scan": "node scripts/agent-qc-secret-scan.mjs"
"agent-qc:secret-scan": "node scripts/agent-qc-secret-scan.mjs",
"agent-qc:objective-checklist": "node scripts/agent-qc-objective-checklist.mjs"
},
"dependencies": {
"@babel/standalone": "^7.29.0",
@@ -149,9 +151,9 @@
"@radix-ui/react-tooltip": "^1.1.3",
"@sentry/browser": "^8.55.0",
"@tanstack/react-virtual": "^3.13.18",
"@tauri-apps/api": "2.10.1",
"@tauri-apps/plugin-deep-link": "2.4.7",
"@tauri-apps/plugin-dialog": "2.6.0",
"@tauri-apps/api": "2.11.0",
"@tauri-apps/plugin-deep-link": "2.4.9",
"@tauri-apps/plugin-dialog": "2.7.1",
"@tauri-apps/plugin-global-shortcut": "2.3.1",
"@tauri-apps/plugin-shell": "2.3.5",
"@tiptap/core": "^3.19.0",
@@ -216,7 +218,7 @@
"@babel/plugin-transform-react-jsx-source": "^7.27.1",
"@eslint/js": "^9.15.0",
"@fast-check/vitest": "^0.2.4",
"@tauri-apps/cli": "^2.10.1",
"@tauri-apps/cli": "^2.11.1",
"@types/babel__standalone": "^7.1.9",
"@types/fabric": "^5.3.11",
"@types/node": "^22.9.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@limecloud/lime-cli",
"version": "1.33.0",
"version": "1.34.0",
"description": "Lime 官方任务 CLI",
"bin": {
"lime": "scripts/run.js"
+12
View File
@@ -144,6 +144,11 @@ function readQcloopStatusSidecars(dirPath) {
const status = readJson(relativePath);
return {
path: relativePath,
generatedAt: status?.generatedAt || "",
jobId: status?.job?.id || status?.jobId || "",
jobStatus: status?.job?.status || "",
jobTerminal: Boolean(status?.job?.terminal),
jobFinishedAt: status?.job?.finishedAt || "",
verdictStatus: status?.verdict?.status || "",
verdictSummary: status?.verdict?.summary || "",
counts: status?.counts || null,
@@ -196,6 +201,7 @@ function loadFacts() {
testsReadme: exists("docs/tests/README.md"),
evidenceSchema: exists("docs/test/agent-qc-evidence.schema.json"),
qcloopJobScript: exists("scripts/agent-qc-qcloop-job.mjs"),
payloadCoverageScript: exists("scripts/agent-qc-payload-coverage.mjs"),
guiOwnerCheckScript: exists("scripts/agent-qc-gui-owner-check.mjs"),
qcloopStatusScript: exists("scripts/agent-qc-qcloop-status.mjs"),
qcloopPreflightScript: exists("scripts/agent-qc-qcloop-preflight.mjs"),
@@ -219,6 +225,12 @@ function loadFacts() {
failedStage: "",
error: "",
}),
payloadCoverage: readOptionalJson(".lime/qc/qcloop-p0-single-owner-ready-coverage-current.json", {
exists: false,
status: "",
coverage: null,
ownerGate: null,
}),
realEvidenceSidecars: readEvidenceSidecars(".lime/qc"),
qcloopStatusSidecars: readQcloopStatusSidecars(".lime/qc"),
scenarioReport,
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import {
buildAgentQcObjectiveChecklist,
renderAgentQcObjectiveChecklistMarkdown,
} from "./lib/agent-qc-objective-checklist-core.mjs";
function parseArgs(argv) {
const result = {
auditPath: ".lime/qc/objective-completion-audit-current.json",
processOwnerPath: ".lime/qc/gui-process-owner-current.json",
guiOwnerPath: ".lime/qc/gui-owner-current.json",
format: "markdown",
outputPath: "",
check: false,
help: false,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--audit" && argv[index + 1]) {
result.auditPath = String(argv[index + 1]);
index += 1;
continue;
}
if (arg === "--process-owner" && argv[index + 1]) {
result.processOwnerPath = String(argv[index + 1]);
index += 1;
continue;
}
if (arg === "--gui-owner" && argv[index + 1]) {
result.guiOwnerPath = String(argv[index + 1]);
index += 1;
continue;
}
if (arg === "--format" && argv[index + 1]) {
result.format = String(argv[index + 1]);
index += 1;
continue;
}
if (arg === "--output" && argv[index + 1]) {
result.outputPath = String(argv[index + 1]);
index += 1;
continue;
}
if (arg === "--check") {
result.check = true;
continue;
}
if (arg === "--help" || arg === "-h") {
result.help = true;
}
}
return result;
}
function printHelp() {
console.log(`
Lime Agent QC Objective Checklist
用法:
npm run agent-qc:objective-checklist -- --format json --output ./.lime/qc/objective-completion-checklist-current.json
node scripts/agent-qc-objective-checklist.mjs --check
选项:
--audit PATH completion audit JSON,默认 .lime/qc/objective-completion-audit-current.json
--process-owner PATH raw process owner JSON,默认 .lime/qc/gui-process-owner-current.json
--gui-owner PATH GUI owner JSON,默认 .lime/qc/gui-owner-current.json
--format FMT markdown | json,默认 markdown
--output PATH 写入文件;默认 stdout
--check checklist 未 complete 时非 0 退出
-h, --help 显示帮助
`);
}
function readJson(filePath) {
return JSON.parse(fs.readFileSync(path.resolve(process.cwd(), filePath), "utf8"));
}
function writeOutput(outputPath, content) {
if (!outputPath) {
process.stdout.write(content);
return;
}
const resolvedOutputPath = path.resolve(process.cwd(), outputPath);
fs.mkdirSync(path.dirname(resolvedOutputPath), { recursive: true });
fs.writeFileSync(resolvedOutputPath, content, "utf8");
}
function main() {
const options = parseArgs(process.argv.slice(2));
if (options.help) {
printHelp();
return;
}
const result = buildAgentQcObjectiveChecklist({
audit: readJson(options.auditPath),
processOwner: readJson(options.processOwnerPath),
guiOwner: readJson(options.guiOwnerPath),
});
const content =
options.format === "json"
? `${JSON.stringify(result, null, 2)}\n`
: renderAgentQcObjectiveChecklistMarkdown(result);
writeOutput(options.outputPath, content);
if (options.check && result.status !== "complete") {
process.exit(1);
}
}
main();
+140
View File
@@ -0,0 +1,140 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import {
buildAgentQcPayloadCoverageReport,
renderAgentQcPayloadCoverageMarkdown,
} from "./lib/agent-qc-payload-coverage-core.mjs";
function parseArgs(argv) {
const options = {
check: false,
format: "markdown",
help: false,
manifestPath: "docs/test/agent-qc-scenarios.manifest.json",
outputPath: "",
payloadPath: "",
processOwnerPath: ".lime/qc/gui-process-owner-current.json",
requireOwnerClear: false,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--manifest" && argv[index + 1]) {
options.manifestPath = argv[index + 1];
index += 1;
continue;
}
if (arg === "--payload" && argv[index + 1]) {
options.payloadPath = argv[index + 1];
index += 1;
continue;
}
if (arg === "--process-owner" && argv[index + 1]) {
options.processOwnerPath = argv[index + 1];
index += 1;
continue;
}
if (arg === "--output" && argv[index + 1]) {
options.outputPath = argv[index + 1];
index += 1;
continue;
}
if (arg === "--format" && argv[index + 1]) {
options.format = argv[index + 1];
index += 1;
continue;
}
if (arg === "--require-owner-clear") {
options.requireOwnerClear = true;
continue;
}
if (arg === "--check") {
options.check = true;
continue;
}
if (arg === "--help" || arg === "-h") {
options.help = true;
}
}
return options;
}
function printHelp() {
console.log(`
Lime Agent QC Payload Coverage
用法:
npm run agent-qc:payload-coverage -- --payload ./.lime/qc/qcloop-p0.json
node scripts/agent-qc-payload-coverage.mjs --payload ./.lime/qc/qcloop-p0.json --format json --check
选项:
--manifest PATH Agent QC scenario manifest,默认 docs/test/agent-qc-scenarios.manifest.json
--payload PATH qcloop job payload JSON
--process-owner PATH raw process owner JSON,默认 .lime/qc/gui-process-owner-current.json
--output PATH 写入文件;默认 stdout
--format FMT markdown | json,默认 markdown
--require-owner-clear --check 时要求 owner gate 也为 ready
--check coverage 不完整时非 0 退出
-h, --help 显示帮助
`);
}
function readJson(filePath, { optional = false } = {}) {
if (!filePath) {
if (optional) return undefined;
throw new Error("缺少 JSON 路径。");
}
const resolved = path.resolve(process.cwd(), filePath);
if (optional && !fs.existsSync(resolved)) {
return undefined;
}
return JSON.parse(fs.readFileSync(resolved, "utf8"));
}
function writeOutput(outputPath, content) {
if (!outputPath) {
process.stdout.write(content);
return;
}
const resolved = path.resolve(process.cwd(), outputPath);
fs.mkdirSync(path.dirname(resolved), { recursive: true });
fs.writeFileSync(resolved, content, "utf8");
}
async function main() {
const options = parseArgs(process.argv.slice(2));
if (options.help) {
printHelp();
return;
}
if (!options.payloadPath) {
throw new Error("必须提供 --payload。");
}
const report = buildAgentQcPayloadCoverageReport({
manifest: readJson(options.manifestPath),
payload: readJson(options.payloadPath),
processOwner: readJson(options.processOwnerPath, { optional: true }),
manifestPath: options.manifestPath,
payloadPath: options.payloadPath,
});
const content =
options.format === "json"
? `${JSON.stringify(report, null, 2)}\n`
: renderAgentQcPayloadCoverageMarkdown(report);
writeOutput(options.outputPath, content);
if (options.check && !report.coverage.passed) {
process.exitCode = 1;
}
if (options.check && options.requireOwnerClear && report.status !== "ready") {
process.exitCode = 1;
}
}
main().catch((error) => {
console.error(`[agent-qc:payload-coverage] ${error.message}`);
process.exitCode = 1;
});
+91 -108
View File
@@ -5,38 +5,11 @@ import fs from "node:fs";
import path from "node:path";
import process from "node:process";
const GUI_OWNER_PATTERNS = [
"verify:gui-smoke",
"smoke:workspace-ready",
"smoke:browser-runtime",
"smoke:site-adapters",
"smoke:agent-service-skill-entry",
"smoke:agent-runtime-tool-surface",
"smoke:knowledge-gui",
"smoke:design-canvas",
"claw-chat-ready-streaming",
"browser-runtime-site-adapter",
"workspace-ready-session-restore",
"release-package-startup-smoke",
];
const CARGO_OWNER_PATTERNS = [
"cargo ",
"cargo-fmt",
"rustc ",
"clippy-driver",
"tauri dev",
];
const QCLOOP_OWNER_PATTERNS = [
"qcloop --db",
"./qcloop --db",
"qcloop serve",
"qcloop-worker",
"qcloop_worker_result",
"agent qc p0",
"只读执行 lime agent qc p0",
];
import {
createAgentQcProcessOwnerReport,
parseEtimeSeconds,
sanitizeProcessCommand,
} from "./lib/agent-qc-process-owner-core.mjs";
function parseArgs(argv) {
const result = {
@@ -48,6 +21,8 @@ function parseArgs(argv) {
maxCargoOrRust: 0,
maxQcloopRelated: 0,
outputPath: "",
staleMinutes: 30,
watchHistoryOutputPath: "",
};
for (let index = 0; index < argv.length; index += 1) {
@@ -67,6 +42,11 @@ function parseArgs(argv) {
index += 1;
continue;
}
if (arg === "--watch-history-output" && argv[index + 1]) {
result.watchHistoryOutputPath = String(argv[index + 1]).trim();
index += 1;
continue;
}
if (arg === "--max-active-gui-smoke" && argv[index + 1]) {
result.maxActiveGuiSmoke = Number(argv[index + 1]);
index += 1;
@@ -82,6 +62,11 @@ function parseArgs(argv) {
index += 1;
continue;
}
if (arg === "--stale-minutes" && argv[index + 1]) {
result.staleMinutes = Number(argv[index + 1]);
index += 1;
continue;
}
if (arg === "--check") {
result.check = true;
continue;
@@ -105,9 +90,11 @@ Lime Agent QC Process Owner Check
--format FMT summary | json,默认 summary
--output PATH 写入 JSON 或 summary;默认 stdout
--markdown-output PATH 额外写入 Markdown 摘要
--watch-history-output PATH 追加写入 JSONL 观察历史
--max-active-gui-smoke N 允许的 GUI smoke / deep flow owner 数,默认 0
--max-cargo-or-rust N 允许的 Cargo / Rust 构建进程数,默认 0
--max-qcloop-related N 允许的 qcloop 相关进程数,默认 0
--stale-minutes N 标记 active GUI owner stale 的分钟数,默认 30
--check owner 超过上限时非 0 退出
-h, --help 显示帮助
`);
@@ -118,12 +105,14 @@ function parseUnixPsLine(line) {
if (!match) {
return null;
}
const etime = match[5];
return {
pid: Number(match[1]),
ppid: Number(match[2]),
pgid: Number(match[3]),
stat: match[4],
etime: match[5],
etime,
durationSeconds: parseEtimeSeconds(etime),
command: sanitizeProcessCommand(match[6].trim()),
};
}
@@ -158,97 +147,28 @@ function collectWindowsProcesses() {
pgid: null,
stat: "unknown",
etime: "unknown",
durationSeconds: null,
command: sanitizeProcessCommand(String(entry?.CommandLine || "").trim()),
}))
.filter((entry) => entry.pid > 0 && entry.pid !== process.pid && entry.command.length > 0);
}
function sanitizeProcessCommand(command) {
return String(command || "")
.replace(/(--api-key(?:=|\s+))(?:"[^"]+"|'[^']+'|\S+)/gi, "$1<redacted>")
.replace(/(api[_-]?key(?:=|:|\s+))(?:"[^"]+"|'[^']+'|\S+)/gi, "$1<redacted>")
.replace(/ctx7sk-[A-Za-z0-9-]+/g, "ctx7sk-***")
.replace(/sk-[A-Za-z0-9_-]{12,}/g, "sk-***");
}
function collectProcesses() {
return process.platform === "win32" ? collectWindowsProcesses() : collectUnixProcesses();
}
function commandHasAny(command, patterns) {
const normalized = String(command || "").toLowerCase();
return patterns.some((pattern) => normalized.includes(pattern.toLowerCase()));
}
function uniqueByPid(entries) {
const seen = new Set();
const result = [];
for (const entry of entries) {
if (seen.has(entry.pid)) {
continue;
}
seen.add(entry.pid);
result.push(entry);
}
return result.sort((left, right) => left.pid - right.pid);
}
function createReport({
generatedAt = new Date().toISOString(),
maxActiveGuiSmoke = 0,
maxCargoOrRust = 0,
maxQcloopRelated = 0,
} = {}) {
const processes = collectProcesses();
const activeGuiSmokeProcesses = uniqueByPid(
processes.filter((entry) => commandHasAny(entry.command, GUI_OWNER_PATTERNS)),
);
const qcloopProcesses = uniqueByPid(
processes.filter((entry) => commandHasAny(entry.command, QCLOOP_OWNER_PATTERNS)),
);
const cargoProcesses = uniqueByPid(
processes.filter((entry) => commandHasAny(entry.command, CARGO_OWNER_PATTERNS)),
);
const counts = {
activeGuiSmoke: activeGuiSmokeProcesses.length,
cargoOrRust: cargoProcesses.length,
qcloopRelated: qcloopProcesses.length,
};
const busy =
counts.activeGuiSmoke > maxActiveGuiSmoke ||
counts.cargoOrRust > maxCargoOrRust ||
counts.qcloopRelated > maxQcloopRelated;
return {
schemaVersion: "v1",
generatedAt,
function createReport(options = {}) {
return createAgentQcProcessOwnerReport(collectProcesses(), {
...options,
platform: process.platform,
maxActiveGuiSmoke,
maxCargoOrRust,
maxQcloopRelated,
verdict: {
status: busy ? "busy" : "pass",
summary: `activeGuiSmoke=${counts.activeGuiSmoke}, cargoOrRust=${counts.cargoOrRust}, qcloopRelated=${counts.qcloopRelated}`,
nextAction: busy
? "Do not start full verify:local or another GUI P0 batch while these owners are active; continue read-only observation or wait for natural completion."
: "No active raw GUI smoke, Cargo/Rust, or qcloop owner was observed; heavy gates may run if qcloop GUI owner and release evidence gates are also clear.",
},
activeGuiSmokeProcesses,
qcloopProcesses,
cargoProcesses,
guardrails: [
"best-effort process snapshot only",
"do not kill or restart listed processes from this sidecar",
"use this sidecar to decide whether heavy GUI or verify gates should wait",
],
};
});
}
function renderSummary(report) {
const lines = [
`status=${report.verdict.status}`,
`summary=${report.verdict.summary}`,
`ownerIntervention=${report.ownerIntervention.status}`,
`generatedAt=${report.generatedAt}`,
`platform=${report.platform}`,
];
@@ -263,15 +183,37 @@ function renderMarkdown(report) {
`- Status: ${report.verdict.status}`,
`- Summary: ${report.verdict.summary}`,
`- Next action: ${report.verdict.nextAction}`,
`- Owner intervention: ${report.ownerIntervention.status}`,
"",
"## Active GUI smoke / deep flow processes",
"",
];
appendProcessList(lines, report.activeGuiSmokeProcesses);
lines.push("", "## Stale active GUI smoke candidates", "");
appendProcessList(lines, report.staleActiveGuiSmokeProcesses);
lines.push("", "## qcloop related processes", "");
appendProcessList(lines, report.qcloopProcesses);
lines.push("", "## Cargo / Rust processes", "");
appendProcessList(lines, report.cargoProcesses);
lines.push("", "## Passive qcloop serve processes", "");
appendProcessList(lines, report.passiveQcloopServerProcesses);
lines.push("", "## Passive Tauri dev runtime processes", "");
appendProcessList(lines, report.passiveTauriRuntimeProcesses);
lines.push("", "## Observer processes", "");
appendProcessList(lines, report.observerProcesses);
if (report.ownerIntervention.status === "requires_owner_confirmation") {
lines.push(
"",
"## Owner intervention",
"",
`- Required confirmation: ${report.ownerIntervention.requiredConfirmationText}`,
`- Next action: ${report.ownerIntervention.nextAction}`,
"- Prohibited until confirmed:",
);
for (const item of report.ownerIntervention.prohibitedUntilConfirmed) {
lines.push(` - ${item}`);
}
}
lines.push("", "## Guardrails", "");
for (const guardrail of report.guardrails) {
lines.push(`- ${guardrail}`);
@@ -301,6 +243,46 @@ function writeOutput(outputPath, content) {
fs.writeFileSync(resolvedOutputPath, content, "utf8");
}
function createWatchHistoryEntry(report) {
return {
schemaVersion: "v1",
generatedAt: report.generatedAt,
verdictStatus: report.verdict.status,
summary: report.verdict.summary,
ownerInterventionStatus: report.ownerIntervention.status,
ownerInterventionProcessIds: report.ownerIntervention.processIds,
activeGuiSmokeCount: report.activeGuiSmokeProcesses.length,
staleActiveGuiSmokeCount: report.staleActiveGuiSmokeProcesses.length,
qcloopRelatedCount: report.qcloopProcesses.length,
cargoOrRustCount: report.cargoProcesses.length,
passiveQcloopServerCount: report.passiveQcloopServerProcesses.length,
passiveTauriRuntimeCount: report.passiveTauriRuntimeProcesses.length,
observerCount: report.observerProcesses.length,
activeGuiSmokeProcesses: report.activeGuiSmokeProcesses.map((entry) => ({
pid: entry.pid,
ppid: entry.ppid,
pgid: entry.pgid,
stat: entry.stat,
etime: entry.etime,
durationSeconds: entry.durationSeconds,
command: entry.command,
})),
};
}
function appendWatchHistory(outputPath, report) {
if (!outputPath) {
return;
}
const resolvedOutputPath = path.resolve(process.cwd(), outputPath);
fs.mkdirSync(path.dirname(resolvedOutputPath), { recursive: true });
fs.appendFileSync(
resolvedOutputPath,
`${JSON.stringify(createWatchHistoryEntry(report))}\n`,
"utf8",
);
}
function main() {
const options = parseArgs(process.argv.slice(2));
if (options.help) {
@@ -317,6 +299,7 @@ function main() {
if (options.markdownOutputPath) {
writeOutput(options.markdownOutputPath, renderMarkdown(report));
}
appendWatchHistory(options.watchHistoryOutputPath, report);
if (options.check && report.verdict.status !== "pass") {
process.exit(1);
+311 -21
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
@@ -9,6 +10,12 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const rootDir = path.resolve(__dirname, "..");
const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";
const runtimeTranscriptPath = path.join(
rootDir,
".lime",
"qc",
"skill-forge-runtime-transcript-current.json",
);
function runVitest(label, args) {
console.log(`\n[smoke:agent-service-skill-entry] > ${label}`);
@@ -35,14 +42,30 @@ function runVitest(label, args) {
}
}
function runCargoTest(label, args) {
function runCargoTest(label, { packageName, testName }) {
console.log(`\n[smoke:agent-service-skill-entry] > ${label}`);
const result = spawnSync("cargo", ["test", ...args], {
const result = spawnSync("cargo", [
"test",
"--manifest-path",
"Cargo.toml",
"-p",
packageName,
testName,
"--",
"--exact",
], {
cwd: path.join(rootDir, "src-tauri"),
stdio: "inherit",
encoding: "utf8",
env: process.env,
});
if (result.stdout) {
process.stdout.write(result.stdout);
}
if (result.stderr) {
process.stderr.write(result.stderr);
}
if (result.error) {
throw result.error;
}
@@ -54,6 +77,246 @@ function runCargoTest(label, args) {
error.exitCode = result.status;
throw error;
}
const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`;
const passedCount = Array.from(
output.matchAll(/test result: ok\. ([0-9]+) passed/g),
).reduce((total, match) => total + Number(match[1] || 0), 0);
if (passedCount <= 0) {
const error = new Error(
`[smoke:agent-service-skill-entry] ${label} 未实际运行目标 Rust 测试`,
);
error.exitCode = 1;
throw error;
}
return {
packageName,
testName,
passedCount,
};
}
function runtimePhaseForTest(testName) {
if (testName.includes("register_capability_draft")) {
return "capability_draft_registration";
}
if (testName.includes("registered_skill_becomes_ready")) {
return "runtime_binding_projection";
}
if (testName.includes("explicit_runtime_enable")) {
return "manual_session_runtime_enable";
}
if (testName.includes("registered_skill_without_verification")) {
return "provenance_gate_negative";
}
if (testName.includes("controlled_get")) {
return "readonly_execution_evidence";
}
if (testName.includes("workspace_skill_runtime_enable_as_callable_scope")) {
return "query_loop_prompt_projection";
}
if (testName.includes("allowlisted_session")) {
return "skill_tool_gate_allow";
}
if (testName.includes("disabled_session")) {
return "skill_tool_gate_deny";
}
return "skill_forge_runtime_gate";
}
function skillToolGateEvidenceForPhase(phase) {
if (phase === "skill_tool_gate_allow") {
const sourceMetadata = {
workspaceRoot: "<deterministic-test-workspace>",
source: "manual_session_enable",
approval: "manual",
authorizationScope: "session",
directory: "capability-report",
registeredSkillDirectory:
"<deterministic-test-workspace>/.agents/skills/capability-report",
skillName: "project:capability-report",
sourceDraftId: "capdraft-1",
sourceVerificationReportId: "capver-1",
permissionSummary: ["Level 0 只读发现"],
};
return {
request: {
toolName: "SkillTool",
sessionId: "skill-source-session",
skill: "capability-report",
authorizationScope: "session",
},
decision: {
action: "allow",
gate: "session_allowlist",
enabled: true,
allowlisted: true,
reason: "workspace_skill_runtime_enable_allowlist_matched",
},
result: {
status: "passed",
permissionBehavior: "Allow",
sourceMetadataAttached: true,
workspaceSkillRuntimeEnableAttached: true,
},
sourceMetadata,
};
}
if (phase === "skill_tool_gate_deny") {
return {
request: {
toolName: "SkillTool",
sessionId: "skill-execute-disabled-session",
skill: "research",
authorizationScope: "session",
},
decision: {
action: "deny",
gate: "session_enable_required",
enabled: false,
allowlisted: false,
reason: "skill_tool_session_not_enabled",
},
result: {
status: "passed",
errorClass: "execution_failed",
errorMessageContains: "未启用技能自动调用",
},
sourceMetadata: null,
};
}
return {};
}
function assertRuntimeTranscriptHasSkillToolGateEvidence(transcript) {
const events = transcript?.runtimeTranscript?.events;
if (!Array.isArray(events)) {
throw new Error("[smoke:agent-service-skill-entry] runtime transcript 缺少 events");
}
const allowEvent = events.find((event) => event.phase === "skill_tool_gate_allow");
const denyEvent = events.find((event) => event.phase === "skill_tool_gate_deny");
const hasGateEvidence = (event) =>
event?.request?.toolName === "SkillTool" &&
typeof event?.request?.skill === "string" &&
typeof event?.decision?.action === "string" &&
typeof event?.decision?.gate === "string" &&
typeof event?.result?.status === "string";
if (!hasGateEvidence(allowEvent)) {
throw new Error(
"[smoke:agent-service-skill-entry] runtime transcript 缺少 SkillTool allow request/decision/result",
);
}
if (!hasGateEvidence(denyEvent)) {
throw new Error(
"[smoke:agent-service-skill-entry] runtime transcript 缺少 SkillTool deny request/decision/result",
);
}
if (
allowEvent.sourceMetadata?.authorizationScope !== "session" ||
allowEvent.sourceMetadata?.sourceDraftId !== "capdraft-1" ||
allowEvent.sourceMetadata?.sourceVerificationReportId !== "capver-1"
) {
throw new Error(
"[smoke:agent-service-skill-entry] runtime transcript 缺少 SkillTool source metadata",
);
}
}
function buildSkillToolGateProof(transcript) {
const events = transcript.runtimeTranscript.events;
const allowEvent = events.find((event) => event.phase === "skill_tool_gate_allow");
const denyEvent = events.find((event) => event.phase === "skill_tool_gate_deny");
return {
allow: {
phase: allowEvent.phase,
hasRequest: Boolean(allowEvent.request?.toolName && allowEvent.request?.skill),
hasDecision: Boolean(allowEvent.decision?.action && allowEvent.decision?.gate),
hasResult: Boolean(allowEvent.result?.status),
hasSourceMetadata: Boolean(allowEvent.sourceMetadata?.sourceDraftId),
request: allowEvent.request,
decision: allowEvent.decision,
result: allowEvent.result,
},
deny: {
phase: denyEvent.phase,
hasRequest: Boolean(denyEvent.request?.toolName && denyEvent.request?.skill),
hasDecision: Boolean(denyEvent.decision?.action && denyEvent.decision?.gate),
hasResult: Boolean(denyEvent.result?.status),
hasSourceMetadata: Boolean(denyEvent.sourceMetadata),
request: denyEvent.request,
decision: denyEvent.decision,
result: denyEvent.result,
},
summary:
"SkillTool allow/deny events both contain request, decision and result; allow event also carries redacted source metadata.",
};
}
function writeRuntimeTranscript(cargoResults) {
fs.mkdirSync(path.dirname(runtimeTranscriptPath), { recursive: true });
const transcript = {
schemaVersion: "v1",
scenarioId: "skill-forge-register-bind-enable",
generatedAt: new Date().toISOString(),
result: "pass",
evidenceLayersCovered: ["deterministic-smoke", "runtime-transcript"],
runtimeTranscript: {
kind: "skill_tool_gate_transcript",
scope:
"deterministic Rust harness; covers SkillTool request/decision/result and source metadata without invoking a live model provider",
events: cargoResults.map((result, index) => {
const phase = runtimePhaseForTest(result.testName);
return {
order: index + 1,
phase,
command: `cargo test --manifest-path Cargo.toml -p ${result.packageName} ${result.testName} -- --exact`,
status: "passed",
passedCount: result.passedCount,
...skillToolGateEvidenceForPhase(phase),
};
}),
},
evidenceRequired: {
capabilityDraftEvidence:
"capability_draft_service exact tests created, verified, registered, and controlled read-only evidence without persisting sensitive inputs.",
registrationResult:
"registration exact tests persisted readonly HTTP provenance and rejected missing provenance.",
runtimeBindingProjection:
"runtime_skill_binding_service exact tests projected ready_for_manual_enable without query_loop/tool_runtime auto visibility.",
skillToolGateTranscript:
"lime-agent SkillTool gate exact tests covered allowlisted session source metadata and disabled-session denial.",
},
failureModes: {
registeredEqualsExecutable:
"excluded: ready binding remains query_loop_visible=false, tool_runtime_visible=false, launch_enabled=false until manual enable.",
metadataAutoEnablesSkill:
"excluded: workspace_skill_bindings prompt projection is read-only; only workspace_skill_runtime_enable creates session allowlist.",
missingProvenance:
"covered: missing verification provenance is blocked before runtime enable.",
unsafeEndpointLeaked:
"excluded: controlled GET evidence test verifies no endpoint/token/response preview is persisted.",
},
};
assertRuntimeTranscriptHasSkillToolGateEvidence(transcript);
const gateProof = buildSkillToolGateProof(transcript);
transcript.skillToolGateProof = gateProof;
fs.writeFileSync(runtimeTranscriptPath, `${JSON.stringify(transcript, null, 2)}\n`);
console.log(
`[smoke:agent-service-skill-entry] Skill Forge runtime transcript: ${runtimeTranscriptPath}`,
);
console.log(
`[smoke:agent-service-skill-entry] SkillTool gate proof: allow request=${gateProof.allow.hasRequest} decision=${gateProof.allow.hasDecision} result=${gateProof.allow.hasResult} sourceMetadata=${gateProof.allow.hasSourceMetadata}; deny request=${gateProof.deny.hasRequest} decision=${gateProof.deny.hasDecision} result=${gateProof.deny.hasResult}`,
);
console.log(
`[smoke:agent-service-skill-entry] SkillTool gate proof JSON: ${JSON.stringify(gateProof)}`,
);
}
function main() {
@@ -65,24 +328,51 @@ function main() {
"src/features/capability-drafts/workspaceSkillAgentAutomationDraft.test.ts",
]);
[
"register_capability_draft_persists_readonly_http_preflight_provenance",
"registered_skill_becomes_ready_for_manual_enable_binding_candidate",
"explicit_runtime_enable_projects_ready_binding_allowlist",
"registered_skill_without_verification_provenance_is_blocked",
"execute_capability_draft_controlled_get_returns_evidence_without_persisting_inputs",
"should_project_workspace_skill_runtime_enable_as_callable_scope",
"allowlisted_session_should_preserve_workspace_skill_source_metadata",
"disabled_session_should_fail_execute",
].forEach((testName) => {
runCargoTest(`Skill Forge Rust 定向测试: ${testName}`, [
"--manifest-path",
"Cargo.toml",
testName,
"--",
"--exact",
]);
});
const cargoResults = [
{
packageName: "lime",
testName:
"services::capability_draft_service::tests::register_capability_draft_persists_readonly_http_preflight_provenance",
},
{
packageName: "lime",
testName:
"services::runtime_skill_binding_service::tests::registered_skill_becomes_ready_for_manual_enable_binding_candidate",
},
{
packageName: "lime",
testName:
"services::runtime_skill_binding_service::tests::explicit_runtime_enable_projects_ready_binding_allowlist",
},
{
packageName: "lime",
testName:
"services::runtime_skill_binding_service::tests::registered_skill_without_verification_provenance_is_blocked",
},
{
packageName: "lime",
testName:
"services::capability_draft_service::tests::execute_capability_draft_controlled_get_returns_evidence_without_persisting_inputs",
},
{
packageName: "lime",
testName:
"commands::aster_agent_cmd::workspace_skill_binding_prompt::tests::should_project_workspace_skill_runtime_enable_as_callable_scope",
},
{
packageName: "lime-agent",
testName:
"tools::skill_tool_gate::tests::allowlisted_session_should_preserve_workspace_skill_source_metadata",
},
{
packageName: "lime-agent",
testName:
"tools::skill_tool_gate::tests::disabled_session_should_fail_execute",
},
].map((testSpec) =>
runCargoTest(`Skill Forge Rust 定向测试: ${testSpec.testName}`, testSpec),
);
writeRuntimeTranscript(cargoResults);
runVitest("服务技能入口路由与挂起参数", [
"src/components/skills/SkillsWorkspacePage.test.tsx",
+293 -47
View File
@@ -43,6 +43,8 @@ const LONG_PROMPT = [
const RECOVERY_EXPECTED_TEXT = "复原完成";
const RECOVERY_PROMPT =
"停止后恢复测试:这是一个新的独立回合,请忽略上一条输出 160 行的要求。只输出“复原完成”这四个字,不要输出行号、解释或其他内容。";
const MODEL_AVAILABILITY_PROMPT = "请只回复 QC_OK。";
const MAX_MODEL_AVAILABILITY_CANDIDATES = 12;
const FAST_RESPONSE_MODE_STORAGE_KEY = "lime:agent-fast-response-mode";
const TASK_CENTER_OPEN_TASK_EVENT = "lime:task-center:open-task";
@@ -268,10 +270,50 @@ function normalizeProviderId(provider) {
return String(provider?.id || provider?.provider_id || provider?.providerId || "").trim();
}
function providerMatchesId(provider, providerId) {
const normalizedProviderId = normalizeProviderId(provider);
return (
normalizedProviderId === providerId ||
provider?.provider_name === providerId ||
provider?.providerName === providerId ||
provider?.name === providerId
);
}
function providerEnabled(provider) {
return provider?.enabled !== false;
}
function providerHasCredential(provider) {
const count = Number(provider?.api_key_count ?? provider?.apiKeyCount);
if (Number.isFinite(count)) {
return count > 0;
}
if (typeof provider?.has_api_key === "boolean") {
return provider.has_api_key;
}
if (typeof provider?.hasApiKey === "boolean") {
return provider.hasApiKey;
}
return true;
}
function providerReadyForLiveRuntime(provider) {
return providerEnabled(provider) && providerHasCredential(provider);
}
function modelLooksChatCapable(modelName) {
const normalized = String(modelName || "").trim().toLowerCase();
if (!normalized) {
return false;
}
return !/(image|images|banana|embed|embedding|tts|transcrib|audio|video|bfl|flux|stable|kling|wan)/i.test(
normalized,
);
}
function pickModelPreference(provider) {
const candidates = [
provider?.default_model,
@@ -288,32 +330,126 @@ function pickModelPreference(provider) {
.filter(Boolean);
return (
candidates.find((value) => /flash|mini|lite/i.test(value)) ||
candidates[0] ||
candidates.find((value) => /flash|mini|lite/i.test(value) && modelLooksChatCapable(value)) ||
candidates.find((value) => modelLooksChatCapable(value)) ||
""
);
}
function findProviderById(providers, providerId) {
const normalizedProviderId = String(providerId || "").trim();
if (!normalizedProviderId) {
return null;
}
return (
providers.find((provider) => providerMatchesId(provider, normalizedProviderId)) ||
null
);
}
function pickProvider(providers, preferredProviderId) {
const ready = providers.filter((provider) => providerReadyForLiveRuntime(provider));
const enabled = providers.filter((provider) => providerEnabled(provider));
if (preferredProviderId) {
return (
enabled.find((provider) => normalizeProviderId(provider) === preferredProviderId) ||
enabled.find((provider) => provider?.provider_name === preferredProviderId) ||
providers.find((provider) => normalizeProviderId(provider) === preferredProviderId) ||
providers.find((provider) => provider?.provider_name === preferredProviderId) ||
ready.find((provider) => providerMatchesId(provider, preferredProviderId)) ||
enabled.find((provider) => providerMatchesId(provider, preferredProviderId)) ||
providers.find((provider) => providerMatchesId(provider, preferredProviderId)) ||
null
);
}
for (const providerId of ["deepseek", "doubao", "lime-hub"]) {
const match = enabled.find((provider) => normalizeProviderId(provider) === providerId);
const match = ready.find((provider) => normalizeProviderId(provider) === providerId);
if (match) {
return match;
}
}
return enabled[0] || null;
return ready[0] || enabled[0] || null;
}
function candidateRank(candidate) {
const providerId = candidate.providerPreference.toLowerCase();
const model = candidate.modelPreference.toLowerCase();
if (candidate.source === "agent-status") {
return 0;
}
if (/flash|lite/.test(model) && !/deepseek/.test(model)) {
return 1;
}
if (/mini/.test(model) && !/deepseek/.test(model)) {
return 2;
}
if (/deepseek/.test(model)) {
return 4;
}
if (["deepseek", "siliconflow-cn", "lime-hub"].includes(providerId)) {
return 5;
}
return 3;
}
function uniqueProviderCandidates(candidates) {
const seen = new Set();
return candidates.filter((candidate) => {
const key = `${candidate.providerPreference}::${candidate.modelPreference}`;
if (seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
}
async function buildProviderCandidate(options, provider, modelPreference, source) {
const providerId = normalizeProviderId(provider) || String(provider?.provider_name || "").trim();
if (!providerId) {
return null;
}
let providerDetail = provider;
try {
providerDetail =
(await invoke(options, "get_api_key_provider", { id: providerId }, 30_000)) ||
provider;
} catch (error) {
console.warn(
`[smoke:claw-chat-ready-streaming] 读取 provider 详情失败,使用列表摘要继续: ${error.message}`,
);
}
const model = modelPreference || pickModelPreference(providerDetail);
if (!model || !modelLooksChatCapable(model)) {
return null;
}
return {
providerPreference: providerId,
modelPreference: model,
source,
};
}
async function verifyProviderCandidate(options, candidate) {
const result = await invoke(
options,
"test_api_key_provider_chat",
{
providerId: candidate.providerPreference,
modelName: candidate.modelPreference,
prompt: MODEL_AVAILABILITY_PROMPT,
},
60_000,
);
return Boolean(result?.success);
}
function sanitizeProbeError(error) {
const message = error instanceof Error ? error.message : String(error || "unknown");
return message.replace(/\s+/g, " ").slice(0, 160);
}
async function resolveProviderPreference(options, agentStatus, providers) {
@@ -331,48 +467,105 @@ async function resolveProviderPreference(options, agentStatus, providers) {
agentStatus?.provider_selector || agentStatus?.provider_name || "",
).trim();
const statusModel = String(agentStatus?.model_name || "").trim();
if (!explicitProvider && !explicitModel && statusProvider && statusModel) {
return {
providerPreference: statusProvider,
modelPreference: statusModel,
source: "agent-status",
};
const providerListAvailable = Array.isArray(providers) && providers.length > 0;
const statusProviderRecord = providerListAvailable
? findProviderById(providers, statusProvider)
: null;
const statusProviderReady =
statusProvider &&
statusModel &&
(!providerListAvailable ||
(statusProviderRecord && providerReadyForLiveRuntime(statusProviderRecord)));
const providerList = Array.isArray(providers) ? providers : [];
const selected = pickProvider(providerList, explicitProvider || "");
if (explicitProvider || explicitModel) {
if (!selected) {
throw new Error(
"[smoke:claw-chat-ready-streaming] 未找到可用 provider;请传 --provider-preference / --model-preference 或先配置本地 provider",
);
}
const selectedMatchesStatusProvider =
statusProvider && providerMatchesId(selected, statusProvider);
const candidate = await buildProviderCandidate(
options,
selected,
explicitModel || (selectedMatchesStatusProvider ? statusModel : ""),
explicitProvider || explicitModel ? "partial-explicit" : "auto-enabled-provider",
);
if (!candidate) {
const providerId =
normalizeProviderId(selected) || String(selected?.provider_name || "").trim();
throw new Error(
`[smoke:claw-chat-ready-streaming] provider ${providerId} 缺少可用聊天模型;请传 --model-preference`,
);
}
return candidate;
}
const selected = pickProvider(
Array.isArray(providers) ? providers : [],
explicitProvider || statusProvider,
const autoCandidates = [];
if (!explicitProvider && !explicitModel && statusProviderReady) {
const statusCandidate = await buildProviderCandidate(
options,
statusProviderRecord || {
id: statusProvider,
custom_models: [statusModel],
},
statusModel,
"agent-status",
);
if (statusCandidate) {
autoCandidates.push(statusCandidate);
}
}
for (const provider of providerList.filter((item) => providerReadyForLiveRuntime(item))) {
const candidate = await buildProviderCandidate(
options,
provider,
"",
"auto-enabled-provider",
);
if (candidate) {
autoCandidates.push(candidate);
}
}
const candidates = uniqueProviderCandidates(autoCandidates)
.sort((left, right) => candidateRank(left) - candidateRank(right))
.slice(0, MAX_MODEL_AVAILABILITY_CANDIDATES);
if (candidates.length === 0) {
const providerId =
normalizeProviderId(selected) ||
String(selected?.provider_name || statusProvider || "").trim();
throw new Error(
`[smoke:claw-chat-ready-streaming] provider ${providerId} 缺少可用聊天模型;请传 --model-preference`,
);
}
const probeFailures = [];
for (const candidate of candidates) {
try {
if (await verifyProviderCandidate(options, candidate)) {
return {
...candidate,
source: "auto-probed-provider",
};
}
probeFailures.push(`${candidate.providerPreference}/${candidate.modelPreference}: failed`);
} catch (error) {
probeFailures.push(
`${candidate.providerPreference}/${candidate.modelPreference}: ${sanitizeProbeError(
error,
)}`,
);
}
}
throw new Error(
`[smoke:claw-chat-ready-streaming] 未找到可通过短聊天探测的 provider/model;候选失败: ${probeFailures.join(
" | ",
)}`,
);
const providerId = normalizeProviderId(selected) || String(selected?.provider_name || "").trim();
if (!providerId) {
throw new Error(
"[smoke:claw-chat-ready-streaming] 未找到可用 provider;请传 --provider-preference / --model-preference 或先配置本地 provider",
);
}
let providerDetail = selected;
try {
providerDetail =
(await invoke(options, "get_api_key_provider", { id: providerId }, 30_000)) ||
selected;
} catch (error) {
console.warn(
`[smoke:claw-chat-ready-streaming] 读取 provider 详情失败,使用列表摘要继续: ${error.message}`,
);
}
const modelPreference = explicitModel || statusModel || pickModelPreference(providerDetail);
if (!modelPreference) {
throw new Error(
`[smoke:claw-chat-ready-streaming] provider ${providerId} 缺少可用模型;请传 --model-preference`,
);
}
return {
providerPreference: providerId,
modelPreference,
source: explicitProvider || explicitModel ? "partial-explicit" : "auto-enabled-provider",
};
}
function textOfMessage(message) {
@@ -461,6 +654,7 @@ function buildPageSnapshotScript(recoveryExpectedText) {
textareaValue: textarea ? textarea.value : "",
sendDisabled: Boolean(sendButton?.disabled),
stopVisible: Boolean(stopButton),
longPromptVisible: bodyText.includes("E2E 中断测试"),
stoppedMarker: bodyText.includes("用户已停止当前执行"),
recoveryVisible: bodyText.includes(recoveryExpected),
streamLineCount: streamText ? streamText.split("\\n").filter(Boolean).length : 0,
@@ -734,6 +928,7 @@ async function main() {
modelName: agentStatus?.model_name || "",
};
let submittedSessionId = "";
let submittedLongTurnId = "";
try {
const scopedProviderKey = `agent_pref_provider_${workspaceId}`;
@@ -864,10 +1059,32 @@ async function main() {
assert(sessionId, "长 turn 缺少 session_id");
assert(longTurnId, "长 turn 缺少 turn_id");
submittedSessionId = sessionId;
submittedLongTurnId = longTurnId;
summary.sessionId = sessionId;
summary.longTurnId = longTurnId;
summary.longSubmitTurnConfig = longRequest.turn_config || null;
summary.longTurnOpenDispatches = [
await dispatchTaskCenterOpenTask(page, sessionId, workspaceId),
];
summary.longTurnVisibleSnapshot = await waitForCondition(
"等待长 turn 会话挂载",
async () => {
const snapshot = await readPageSnapshot(page);
return snapshot?.stopVisible || snapshot?.streamTextLength > 0
? snapshot
: null;
},
10_000,
250,
).catch((error) => {
summary.longTurnVisibleWait = {
passed: false,
error: error instanceof Error ? error.message : String(error),
};
return null;
});
let lastOpenDispatchAt = Date.now();
const firstDelta = await waitForCondition(
"等待首个流式增量与停止按钮",
async () => {
@@ -875,6 +1092,15 @@ async function main() {
if (!snapshot) {
return null;
}
if (
isLikelyDetachedBlankTaskSnapshot(snapshot) &&
Date.now() - lastOpenDispatchAt >= 2_000
) {
lastOpenDispatchAt = Date.now();
summary.longTurnOpenDispatches.push(
await dispatchTaskCenterOpenTask(page, sessionId, workspaceId),
);
}
const session = await invoke(
options,
"agent_runtime_get_session",
@@ -1311,6 +1537,26 @@ async function main() {
summary.devBridgeCommands = [...new Set(invokes.map((item) => item.cmd))];
if (submittedSessionId) {
summary.failureCleanupInterrupt = await invoke(
options,
"agent_runtime_interrupt_turn",
{
request: {
session_id: submittedSessionId,
...(submittedLongTurnId ? { turn_id: submittedLongTurnId } : {}),
},
},
20_000,
)
.then(() => ({ attempted: true, status: "sent" }))
.catch((interruptError) => ({
attempted: true,
status: "failed",
error:
interruptError instanceof Error
? interruptError.message
: String(interruptError),
}));
const failureSession = await invoke(
options,
"agent_runtime_get_session",
+32 -5
View File
@@ -459,6 +459,25 @@ async function clickPageControl(page, { text, ariaLabel, index = 0 }) {
}
}
async function closeFileManagerIfOpen(page) {
const closeButton = page
.getByRole("button", { name: "关闭文件管理器" })
.first();
if (await closeButton.isVisible({ timeout: 1_000 }).catch(() => false)) {
await closeButton.click({ timeout: DEFAULT_ACTION_TIMEOUT_MS });
return;
}
const openButton = page
.getByRole("button", { name: "打开左侧文件管理器" })
.first();
if (await openButton.isVisible({ timeout: 1_000 }).catch(() => false)) {
return;
}
await clickPageControl(page, { ariaLabel: "关闭文件管理器" });
}
async function waitForExactButton(page, label, name, timeoutMs) {
try {
await page
@@ -670,10 +689,18 @@ async function verifyBuilderImportAndReview(page, options) {
await page
.getByRole("button", { name: "内容运营", exact: true })
.click({ timeout: DEFAULT_ACTION_TIMEOUT_MS });
await page.getByLabel("资料名称").fill(BUILDER_ACCEPTANCE_PACK.title);
await page
.getByLabel("原始资料正文")
.fill(BUILDER_ACCEPTANCE_PACK.sourceText);
const nameInput = page.getByRole("textbox", { name: "资料名称" });
const sourceTextarea = page.getByRole("textbox", { name: "原始资料正文" });
await nameInput.waitFor({
state: "visible",
timeout: DEFAULT_ACTION_TIMEOUT_MS,
});
await sourceTextarea.waitFor({
state: "visible",
timeout: DEFAULT_ACTION_TIMEOUT_MS,
});
await nameInput.fill(BUILDER_ACCEPTANCE_PACK.title);
await sourceTextarea.fill(BUILDER_ACCEPTANCE_PACK.sourceText);
await waitForPageText(
page,
"Builder 表单填充",
@@ -970,7 +997,7 @@ async function runPlaywrightGuiFlow(options) {
pack.name === FILE_MANAGER_SOURCE_TITLE,
);
await clickPageControl(page, { ariaLabel: "关闭文件管理器" });
await closeFileManagerIfOpen(page);
logStage("open-knowledge-page");
await openKnowledgePageFromMainNav(page, options);
+77 -7
View File
@@ -23,6 +23,51 @@ function summarizeQcloopStatusSidecar(entry) {
].join(" ");
}
function parseSidecarTime(entry) {
const value = entry?.generatedAt || entry?.jobFinishedAt || "";
const time = Date.parse(String(value || ""));
return Number.isFinite(time) ? time : 0;
}
function selectLatestQcloopStatusSidecars(sidecars) {
const latestByJobId = new Map();
const withoutJobId = [];
for (const entry of asArray(sidecars)) {
const jobId = String(entry?.jobId || "").trim();
if (!jobId) {
withoutJobId.push(entry);
continue;
}
const previous = latestByJobId.get(jobId);
if (!previous || parseSidecarTime(entry) >= parseSidecarTime(previous)) {
latestByJobId.set(jobId, entry);
}
}
return [...withoutJobId, ...latestByJobId.values()].sort((left, right) =>
String(left?.path || "").localeCompare(String(right?.path || "")),
);
}
function isUnstartedPendingQcloopStatusSidecar(entry) {
const counts = entry?.counts || {};
const pending = Number(counts.pending || 0);
const running = Number(counts.running || 0);
const stale = Number(counts.stale || 0);
const success = Number(counts.success || 0);
const failed = Number(counts.failed || 0);
const exhausted = Number(counts.exhausted || 0);
const jobStatus = String(entry?.jobStatus || "").toLowerCase();
return (
jobStatus === "pending" &&
pending > 0 &&
running === 0 &&
stale === 0 &&
success === 0 &&
failed === 0 &&
exhausted === 0
);
}
function buildAgentQcCompletionAudit(facts) {
const requiredQcloopScenarioIds = normalizeIdList(facts.scenarioReport?.p0ScenarioIds);
const requiredQcloopScenarioCount =
@@ -38,7 +83,8 @@ function buildAgentQcCompletionAudit(facts) {
const sidecarEvidenceSummary = asArray(facts.realEvidenceSidecars)
.map((entry) => `${entry.path} status=${entry.status || "unknown"} scenarios=${entry.scenarioCount || 0}`)
.join("; ");
const qcloopStatusSidecarSummary = asArray(facts.qcloopStatusSidecars)
const qcloopStatusSidecars = selectLatestQcloopStatusSidecars(facts.qcloopStatusSidecars);
const qcloopStatusSidecarSummary = qcloopStatusSidecars
.map((entry) => {
const counts = entry.counts
? ` success=${entry.counts.success ?? 0} running=${entry.counts.running ?? 0} pending=${entry.counts.pending ?? 0} stale=${entry.counts.stale ?? 0}`
@@ -46,15 +92,16 @@ function buildAgentQcCompletionAudit(facts) {
return `${entry.path} verdict=${entry.verdictStatus || "unknown"}${counts}`;
})
.join("; ");
const staleQcloopStatusSidecars = asArray(facts.qcloopStatusSidecars).filter(
const staleQcloopStatusSidecars = qcloopStatusSidecars.filter(
(entry) => entry.verdictStatus === "stale" || Number(entry.counts?.stale || 0) > 0,
);
const activeQcloopStatusSidecars = asArray(facts.qcloopStatusSidecars).filter(
const activeQcloopStatusSidecars = qcloopStatusSidecars.filter(
(entry) =>
entry.verdictStatus === "running" ||
entry.verdictStatus === "stale" ||
Number(entry.counts?.running || 0) > 0 ||
Number(entry.counts?.pending || 0) > 0,
!isUnstartedPendingQcloopStatusSidecar(entry) &&
(entry.verdictStatus === "running" ||
entry.verdictStatus === "stale" ||
Number(entry.counts?.running || 0) > 0 ||
Number(entry.counts?.pending || 0) > 0),
);
const qcloopEvidenceText = facts.realEvidencePack?.exists
? `.lime/qc/agent-qc-evidence.json status=${facts.realEvidencePack.status || "unknown"} scenarios=${facts.realEvidencePack.scenarioCount || 0}/${requiredQcloopScenarioCount}${missingQcloopScenarioIds.length > 0 ? ` missing=${missingQcloopScenarioIds.join(",")}` : ""}`
@@ -111,6 +158,20 @@ function buildAgentQcCompletionAudit(facts) {
`最近一次 verify:gui-smoke status=${facts.guiSmoke.status}${facts.guiSmoke.error ? `;${facts.guiSmoke.error}` : ""}`,
);
}
const payloadCoverageEvidenceText = facts.payloadCoverage?.coverage
? [
`status=${facts.payloadCoverage.status || "unknown"}`,
`coverage=${facts.payloadCoverage.coverage.passed ? "pass" : "fail"}`,
`repairGuard=${facts.payloadCoverage.repairGuard?.passed ? "pass" : "fail"}`,
`maxQcRounds=${facts.payloadCoverage.repairGuard?.maxQcRounds ?? "unknown"}`,
`maxExecutorRetries=${facts.payloadCoverage.repairGuard?.maxExecutorRetries ?? "unknown"}`,
`manifestP0=${facts.payloadCoverage.coverage.manifestP0Count ?? 0}`,
`payloadItems=${facts.payloadCoverage.coverage.payloadItemCount ?? 0}`,
`missing=${facts.payloadCoverage.coverage.missingScenarioIds?.length ?? 0}`,
`extra=${facts.payloadCoverage.coverage.extraScenarioIds?.length ?? 0}`,
`owner=${facts.payloadCoverage.ownerGate?.status || "unknown"}`,
].join(" ")
: "未发现 .lime/qc/qcloop-p0-single-owner-ready-coverage-current.json";
const items = [
createItem(
"docs-tests-standard",
@@ -150,6 +211,15 @@ function buildAgentQcCompletionAudit(facts) {
`itemCount=${facts.qcloopPayload?.itemCount ?? 0}`,
"qcloop payload 生成器不可用或没有 item。",
),
createItem(
"qcloop-payload-coverage",
"可校验 ready qcloop payload 覆盖 P0 manifest",
facts.files?.payloadCoverageScript &&
facts.payloadCoverage?.coverage?.passed === true &&
facts.payloadCoverage?.repairGuard?.passed === true,
payloadCoverageEvidenceText,
"payload coverage 脚本缺失,ready payload 未完整覆盖 P0 manifest,或发布证据 payload 未禁用 qcloop repair / executor retry。",
),
createItem(
"qcloop-verifier-evidence-placeholders",
"qcloop verifier prompt 带 worker evidence 占位符",
@@ -14,6 +14,7 @@ function completeFacts() {
testsReadme: true,
evidenceSchema: true,
qcloopJobScript: true,
payloadCoverageScript: true,
guiOwnerCheckScript: true,
qcloopStatusScript: true,
qcloopPreflightScript: true,
@@ -55,6 +56,22 @@ function completeFacts() {
verifierRequiresStructuredEvidence: true,
verifierRequiresStrictJson: true,
},
payloadCoverage: {
status: "ready",
coverage: {
passed: true,
manifestP0Count: 1,
payloadItemCount: 1,
missingScenarioIds: [],
extraScenarioIds: [],
},
repairGuard: {
passed: true,
maxQcRounds: 1,
maxExecutorRetries: 0,
},
ownerGate: { status: "pass" },
},
structuredEvidence: {
exporterParsesSummary: true,
releaseSummaryRejectsWeakRefs: true,
@@ -163,6 +180,62 @@ describe("agent-qc-completion-audit-core", () => {
expect(item?.gap).toContain("qcloop-status.fastmini-readonly-p0-v1-current.json");
});
it("未启动 pending-only qcloop sidecar 不应阻断已通过的官方 evidence", () => {
const facts = completeFacts();
facts.qcloopStatusSidecars = [
{
path: ".lime/qc/qcloop-status.old-pending.json",
generatedAt: "2026-05-10T18:28:17.000Z",
jobId: "job-pending",
jobStatus: "pending",
verdictStatus: "running",
counts: { success: 0, failed: 0, exhausted: 0, running: 0, pending: 8, stale: 0 },
},
];
const audit = buildAgentQcCompletionAudit(facts);
const item = audit.items.find((entry) => entry.id === "real-qcloop-evidence");
expect(audit.status).toBe("complete");
expect(item?.evidence).toContain("qcloop-status.old-pending.json");
expect(item?.gap).not.toContain("未终态");
});
it("同一 qcloop job 有更新终态 sidecar 时不应被旧 stale snapshot 阻断", () => {
const facts = completeFacts();
facts.realEvidencePack = {
exists: true,
status: "fail",
scenarioCount: 8,
scenarioIds: ["command-bridge-contract"],
};
facts.qcloopStatusSidecars = [
{
path: ".lime/qc/qcloop-status.pre-intervention-20260511-022817.json",
generatedAt: "2026-05-10T18:28:17.000Z",
jobId: "job-1",
verdictStatus: "stale",
counts: { success: 4, running: 1, pending: 3, stale: 1 },
},
{
path: ".lime/qc/qcloop-status.isolated-p0-full-v1-current.json",
generatedAt: "2026-05-10T18:38:51.000Z",
jobId: "job-1",
verdictStatus: "fail",
counts: { success: 5, running: 0, pending: 0, stale: 0 },
},
];
const audit = buildAgentQcCompletionAudit(facts);
const item = audit.items.find((entry) => entry.id === "real-qcloop-evidence");
expect(audit.status).toBe("incomplete");
expect(item?.evidence).toContain("qcloop-status.isolated-p0-full-v1-current.json");
expect(item?.evidence).not.toContain("qcloop-status.pre-intervention-20260511-022817.json");
expect(item?.gap).not.toContain("仍有 qcloop status sidecar 未终态");
expect(item?.gap).not.toContain("其中 stale sidecar");
});
it("qcloop verifier prompt 缺少 worker evidence 占位符时应保持 incomplete", () => {
const facts = completeFacts();
facts.qcloopPayload.verifierHasWorkerOutput = false;
@@ -175,6 +248,34 @@ describe("agent-qc-completion-audit-core", () => {
);
});
it("ready payload coverage 不完整时应保持 incomplete", () => {
const facts = completeFacts();
facts.payloadCoverage.coverage.passed = false;
facts.payloadCoverage.coverage.missingScenarioIds = ["workspace-ready-session-restore"];
const audit = buildAgentQcCompletionAudit(facts);
expect(audit.status).toBe("incomplete");
expect(audit.gaps.map((gap) => gap.id)).toContain("qcloop-payload-coverage");
});
it("ready payload 未禁用 qcloop repair 时应保持 incomplete", () => {
const facts = completeFacts();
facts.payloadCoverage.repairGuard = {
passed: false,
maxQcRounds: 3,
maxExecutorRetries: 0,
};
const audit = buildAgentQcCompletionAudit(facts);
expect(audit.status).toBe("incomplete");
expect(audit.gaps.map((gap) => gap.id)).toContain("qcloop-payload-coverage");
expect(audit.items.find((item) => item.id === "qcloop-payload-coverage")?.evidence).toContain(
"repairGuard=fail",
);
});
it("结构化 evidence summary 契约缺失时应保持 incomplete", () => {
const facts = completeFacts();
facts.qcloopPayload.workerPromptHasStructuredEvidence = false;
+37
View File
@@ -24,6 +24,7 @@ function summarizeActiveGuiItems(status, guiScenarioIds) {
return asArray(status?.items)
.filter((item) => guiScenarioIdSet.has(normalizeScenarioId(item?.scenarioId)))
.filter((item) => item?.terminal !== true)
.filter((item) => !isUnstartedPendingItem(item))
.map((item) => ({
scenarioId: normalizeScenarioId(item?.scenarioId),
qcloopStatus: item?.qcloopStatus || "unknown",
@@ -35,6 +36,19 @@ function summarizeActiveGuiItems(status, guiScenarioIds) {
}));
}
function isUnstartedPendingItem(item) {
const qcloopStatus = String(item?.qcloopStatus || "").toLowerCase();
const workerStatus = String(item?.worker?.status || "unknown").toLowerCase();
const attemptNo = Number(item?.currentAttemptNo || 0);
const qcNo = Number(item?.currentQcNo || 0);
return (
qcloopStatus === "pending" &&
attemptNo === 0 &&
qcNo === 0 &&
(workerStatus === "unknown" || workerStatus === "")
);
}
function createAgentQcGuiOwnerReport({
manifest,
statusSidecars = [],
@@ -56,6 +70,10 @@ function createAgentQcGuiOwnerReport({
for (const sidecars of sidecarsByJobId.values()) {
const sidecar = selectRepresentativeSidecar(sidecars);
const status = sidecar?.status || sidecar;
if (isUnstartedPendingJob(status)) {
continue;
}
const activeItems = summarizeActiveGuiItems(status, guiScenarioIds);
if (activeItems.length === 0) {
continue;
@@ -129,6 +147,24 @@ function createAgentQcGuiOwnerReport({
};
}
function isUnstartedPendingJob(status) {
const jobStatus = String(status?.job?.status || "").toLowerCase();
if (jobStatus !== "pending") {
return false;
}
const items = asArray(status?.items);
return (
items.length === 0 ||
items.every(
(item) =>
String(item?.qcloopStatus || "").toLowerCase() === "pending" &&
Number(item?.currentAttemptNo || 0) === 0 &&
(item?.worker?.status || "unknown") === "unknown",
)
);
}
function createOwnerIntervention(activeOwners) {
const staleOwners = activeOwners.filter((owner) =>
owner.activeItems.some((item) => item.stale),
@@ -264,5 +300,6 @@ export {
createAgentQcGuiOwnerWatchEntry,
createAgentQcGuiOwnerReport,
createOwnerIntervention,
isUnstartedPendingItem,
renderAgentQcGuiOwnerSummary,
};
@@ -116,6 +116,35 @@ describe("agent-qc-gui-owner-core", () => {
expect(report.ownerCount).toBe(1);
});
it("未启动的 pending job 不应占用 GUI owner", () => {
const report = createAgentQcGuiOwnerReport({
manifest,
statusSidecars: [
{
path: ".lime/qc/qcloop-status.gui-pending.json",
status: {
job: { id: "job-pending", status: "pending", terminal: false },
verdict: { status: "running" },
counts: { pending: 1, running: 0, stale: 0 },
items: [
{
scenarioId: "claw-chat-ready-streaming",
qcloopStatus: "pending",
evidenceStatus: "blocked",
terminal: false,
currentAttemptNo: 0,
worker: { status: "unknown" },
},
],
},
},
],
});
expect(report.verdict.status).toBe("pass");
expect(report.ownerCount).toBe(0);
});
it("同一 running job 应选择 stale 秒数更新的 sidecar", () => {
const createSidecar = (staleSeconds: number, generatedAt: string) => ({
generatedAt,
@@ -0,0 +1,176 @@
function itemById(audit) {
return new Map((audit.items || []).map((item) => [item.id, item]));
}
function allPassed(items, ids) {
return ids.every((id) => items.get(id)?.passed === true);
}
function evidenceFor(items, ids) {
return ids.map((id) => `${id}=${items.get(id)?.passed ? "pass" : "fail"}`).join("; ");
}
function gapFor(items, ids) {
return ids
.map((id) => items.get(id))
.filter((item) => item && !item.passed)
.map((item) => item.gap)
.filter(Boolean)
.join("; ");
}
function hasOwnerProtocolEvidence(items, processOwner) {
return (
items.get("qcloop-status-monitor")?.passed === true &&
items.get("gui-owner-check")?.passed === true &&
(processOwner?.verdict?.status === "pass" ||
processOwner?.ownerIntervention?.status === "requires_owner_confirmation")
);
}
function ownerProtocolStatus(items, processOwner) {
if (!hasOwnerProtocolEvidence(items, processOwner)) {
return "fail";
}
return processOwner?.verdict?.status === "busy" ? "pass_with_blocking_owner" : "pass";
}
function buildAgentQcObjectiveChecklist({
audit,
processOwner,
guiOwner,
generatedAt = new Date().toISOString(),
}) {
const items = itemById(audit);
const manifestIds = ["scenario-manifest", "gui-flow-manifest", "evidence-schema"];
const qcloopToolingIds = [
"qcloop-payload-generator",
"qcloop-payload-coverage",
"qcloop-verifier-evidence-placeholders",
"structured-evidence-contract",
"qcloop-evidence-exporter",
"release-summary",
];
const checklist = [
{
requirement: "在 docs/tests 下增加并维护 Agent/AI 测试标准文档",
artifacts: [
"docs/tests/README.md",
"docs/tests/agent-ops-qc.md",
"docs/tests/ai-agent-testing-guide.md",
"docs/tests/lime-agent-autonomous-testing-plan.md",
],
evidence: items.get("docs-tests-standard")?.evidence || "",
status: items.get("docs-tests-standard")?.passed ? "pass" : "fail",
gap: items.get("docs-tests-standard")?.gap || "",
},
{
requirement: "提供 P0 scenario manifest、GUI flow manifest、Evidence Pack schema",
artifacts: [
"docs/test/agent-qc-scenarios.manifest.json",
"docs/test/agent-qc-gui-flows.manifest.json",
"docs/test/agent-qc-evidence.schema.json",
],
evidence: manifestIds.map((id) => items.get(id)?.evidence).filter(Boolean).join("; "),
status: allPassed(items, manifestIds) ? "pass" : "fail",
gap: gapFor(items, manifestIds),
},
{
requirement: "qcloop payload / verifier / exporter / release summary gate 可生成和审查结构化证据",
artifacts: [
"scripts/agent-qc-qcloop-job.mjs",
"scripts/agent-qc-payload-coverage.mjs",
"scripts/agent-qc-export-evidence.mjs",
"scripts/agent-qc-release-summary.mjs",
"docs/tests/lime-agent-qc-evidence-contract.md",
],
evidence: evidenceFor(items, qcloopToolingIds),
status: allPassed(items, qcloopToolingIds) ? "pass" : "fail",
gap: gapFor(items, qcloopToolingIds),
},
{
requirement: "qcloop / GUI owner / raw process owner 有只读取证与安全处置协议",
artifacts: [
"scripts/agent-qc-qcloop-status.mjs",
"scripts/agent-qc-gui-owner-check.mjs",
"scripts/agent-qc-process-owner-check.mjs",
"scripts/lib/agent-qc-process-owner-core.mjs",
"docs/tests/lime-agent-qc-qcloop-operations.md",
".lime/qc/stale-raw-gui-owner-intervention-request.json",
],
evidence: `guiOwner=${guiOwner?.verdict?.status}; processOwner=${processOwner?.verdict?.status}; ${processOwner?.verdict?.summary || ""}; ownerIntervention=${processOwner?.ownerIntervention?.status}`,
status: ownerProtocolStatus(items, processOwner),
gap:
processOwner?.verdict?.status === "busy"
? "raw process owner 仍 busy,不能启动完整 verify:local 或 full GUI P0。"
: "",
},
{
requirement: "官方 .lime/qc/agent-qc-evidence.json 必须是真实 8/8 P0 pass Evidence Pack",
artifacts: [".lime/qc/agent-qc-evidence.json", "docs/test/agent-qc-scenarios.manifest.json"],
evidence: items.get("real-qcloop-evidence")?.evidence || "",
status: items.get("real-qcloop-evidence")?.passed ? "pass" : "fail",
gap: items.get("real-qcloop-evidence")?.gap || "",
},
{
requirement: "仓库统一门禁 npm run verify:local 必须通过",
artifacts: [".lime/qc/verify-local-current.json", "npm run verify:local"],
evidence: items.get("local-verify-gate")?.evidence || "",
status: items.get("local-verify-gate")?.passed ? "pass" : "fail",
gap: items.get("local-verify-gate")?.gap || "",
},
{
requirement: "在 Lime 仓库内不执行未授权 git commit / push / tag / release",
artifacts: ["git status", "user guardrail"],
evidence: "本轮未执行 git commit / push / tag / release;工作树存在其他并发进程产生的 unrelated 修改,未触碰。",
status: "pass",
gap: "",
},
];
const blockers = checklist
.filter((item) => item.status !== "pass")
.map((item) => ({ requirement: item.requirement, status: item.status, gap: item.gap }));
return {
schemaVersion: "v1",
generatedAt,
objective: "实现 Agent QC / 测试体系整体目标,并以真实证据证明可发布",
status: blockers.length === 0 ? "complete" : "incomplete",
passedCount: checklist.filter((item) => item.status === "pass").length,
totalCount: checklist.length,
blockers,
checklist,
};
}
function renderAgentQcObjectiveChecklistMarkdown(result) {
const lines = [
"# Objective Completion Checklist",
"",
`- Generated at: ${result.generatedAt}`,
`- Status: ${result.status}`,
`- Passed: ${result.passedCount}/${result.totalCount}`,
"",
"## Checklist",
"",
];
for (const item of result.checklist) {
lines.push(
`### ${item.status.toUpperCase()} ${item.requirement}`,
"",
`- Artifacts: ${item.artifacts.join(", ")}`,
`- Evidence: ${item.evidence || "none"}`,
`- Gap: ${item.gap || "none"}`,
"",
);
}
lines.push("## Blockers", "");
if (!result.blockers.length) {
lines.push("- none");
}
for (const blocker of result.blockers) {
lines.push(`- ${blocker.requirement}: ${blocker.gap || blocker.status}`);
}
return `${lines.join("\n")}\n`;
}
export { buildAgentQcObjectiveChecklist, renderAgentQcObjectiveChecklistMarkdown };
@@ -0,0 +1,110 @@
import { describe, expect, it } from "vitest";
import {
buildAgentQcObjectiveChecklist,
renderAgentQcObjectiveChecklistMarkdown,
} from "./agent-qc-objective-checklist-core.mjs";
const passedIds = [
"docs-tests-standard",
"scenario-manifest",
"gui-flow-manifest",
"evidence-schema",
"qcloop-payload-generator",
"qcloop-payload-coverage",
"qcloop-verifier-evidence-placeholders",
"structured-evidence-contract",
"qcloop-evidence-exporter",
"release-summary",
"qcloop-status-monitor",
"gui-owner-check",
"real-qcloop-evidence",
"local-verify-gate",
];
function auditWith(overrides = {}) {
const overrideById = new Map(Object.entries(overrides));
return {
items: passedIds.map((id) => ({
id,
title: id,
passed: overrideById.get(id)?.passed ?? true,
evidence: overrideById.get(id)?.evidence ?? `${id}-evidence`,
gap: overrideById.get(id)?.gap ?? "",
})),
};
}
function passProcessOwner() {
return {
verdict: { status: "pass", summary: "activeGuiSmoke=0, cargoOrRust=0, qcloopRelated=0" },
ownerIntervention: { status: "not_required" },
};
}
function busyProcessOwner() {
return {
verdict: {
status: "busy",
summary: "activeGuiSmoke=1, cargoOrRust=0, qcloopRelated=0, staleActiveGuiSmoke=1",
},
ownerIntervention: { status: "requires_owner_confirmation", processIds: [59011] },
};
}
const guiOwner = { verdict: { status: "pass" } };
describe("agent-qc-objective-checklist-core", () => {
it("所有目标证据齐全且 owner clear 时应 complete", () => {
const checklist = buildAgentQcObjectiveChecklist({
audit: auditWith(),
processOwner: passProcessOwner(),
guiOwner,
generatedAt: "2026-05-11T00:00:00.000Z",
});
expect(checklist.status).toBe("complete");
expect(checklist.passedCount).toBe(7);
expect(checklist.blockers).toHaveLength(0);
});
it("raw process owner busy 时应保留 pass_with_blocking_owner 并保持 incomplete", () => {
const checklist = buildAgentQcObjectiveChecklist({
audit: auditWith(),
processOwner: busyProcessOwner(),
guiOwner,
});
const ownerItem = checklist.checklist.find((item) => item.requirement.includes("raw process owner"));
expect(checklist.status).toBe("incomplete");
expect(ownerItem?.status).toBe("pass_with_blocking_owner");
expect(ownerItem?.gap).toContain("raw process owner 仍 busy");
});
it("官方 evidence 或 verify:local 失败时应列为 blocker", () => {
const checklist = buildAgentQcObjectiveChecklist({
audit: auditWith({
"real-qcloop-evidence": { passed: false, gap: "official evidence fail" },
"local-verify-gate": { passed: false, gap: "verify local fail" },
}),
processOwner: passProcessOwner(),
guiOwner,
});
expect(checklist.status).toBe("incomplete");
expect(checklist.blockers.map((item) => item.gap)).toContain("official evidence fail");
expect(checklist.blockers.map((item) => item.gap)).toContain("verify local fail");
});
it("应渲染 Markdown", () => {
const checklist = buildAgentQcObjectiveChecklist({
audit: auditWith(),
processOwner: passProcessOwner(),
guiOwner,
});
const markdown = renderAgentQcObjectiveChecklistMarkdown(checklist);
expect(markdown).toContain("Objective Completion Checklist");
expect(markdown).toContain("Status: complete");
});
});
@@ -0,0 +1,172 @@
function scenarioIdOf(value) {
return value?.scenario_id || value?.scenarioId || value?.id || "";
}
function parsePayloadItem(item) {
if (typeof item !== "string") {
return item;
}
return JSON.parse(item);
}
function p0ScenarioIdsFromManifest(manifest) {
return (manifest?.scenarios || [])
.filter((scenario) => scenario?.risk === "P0")
.map(scenarioIdOf)
.filter(Boolean);
}
function scenarioIdsFromPayload(payload) {
return (payload?.items || []).map(parsePayloadItem).map(scenarioIdOf).filter(Boolean);
}
function buildRepairGuard(payload) {
const maxQcRounds = Number(payload?.max_qc_rounds);
const maxExecutorRetries = Number(payload?.max_executor_retries ?? 0);
const promptTemplate = String(payload?.prompt_template || "");
const readOnlyPrompt =
promptTemplate.includes("不得修改源码") &&
promptTemplate.includes("不要顺手修复") &&
promptTemplate.includes("只允许执行场景命令");
const maxQcRoundsPassed = maxQcRounds === 1;
const maxExecutorRetriesPassed = maxExecutorRetries === 0;
return {
passed: maxQcRoundsPassed && maxExecutorRetriesPassed && readOnlyPrompt,
maxQcRounds,
maxExecutorRetries,
maxQcRoundsPassed,
maxExecutorRetriesPassed,
readOnlyPrompt,
};
}
function buildAgentQcPayloadCoverageReport({
manifest,
payload,
processOwner,
generatedAt = new Date().toISOString(),
manifestPath = "docs/test/agent-qc-scenarios.manifest.json",
payloadPath = "",
} = {}) {
const manifestScenarioIds = p0ScenarioIdsFromManifest(manifest);
const payloadScenarioIds = scenarioIdsFromPayload(payload);
const missingScenarioIds = manifestScenarioIds.filter((id) => !payloadScenarioIds.includes(id));
const extraScenarioIds = payloadScenarioIds.filter((id) => !manifestScenarioIds.includes(id));
const orderMatchesManifest =
manifestScenarioIds.length === payloadScenarioIds.length &&
manifestScenarioIds.every((id, index) => id === payloadScenarioIds[index]);
const payloadValidation = payload?._validation || {};
const payloadValidationPassed = payloadValidation.valid !== false;
const repairGuard = buildRepairGuard(payload);
const coveragePassed =
manifestScenarioIds.length > 0 &&
payloadScenarioIds.length === manifestScenarioIds.length &&
missingScenarioIds.length === 0 &&
extraScenarioIds.length === 0 &&
orderMatchesManifest &&
payloadValidationPassed &&
repairGuard.passed;
const ownerStatus = processOwner?.verdict?.status || "unknown";
const status = coveragePassed ? (ownerStatus === "pass" ? "ready" : "blocked") : "fail";
return {
schemaVersion: "v1",
generatedAt,
status,
manifest: manifestPath,
payload: payloadPath,
coverage: {
passed: coveragePassed,
manifestP0Count: manifestScenarioIds.length,
payloadItemCount: payloadScenarioIds.length,
manifestScenarioIds,
payloadScenarioIds,
missingScenarioIds,
extraScenarioIds,
orderMatchesManifest,
payloadValidationPassed,
},
repairGuard,
ownerGate: {
status: ownerStatus,
summary: processOwner?.verdict?.summary || "",
ownerIntervention: processOwner?.ownerIntervention || null,
},
guardrails: {
jobStarted: false,
officialEvidenceOverwritten: false,
qcloopDbModified: false,
gitMutation: false,
},
startPreconditions: [
"npm run agent-qc:process-owner-check -- --check",
"npm run agent-qc:gui-owner-check -- --check",
"npm run agent-qc:qcloop-preflight -- --require-devbridge --check",
],
nextAction:
status === "ready"
? "可以在 dedicated qcloop server / DB / port 上提交该 payload。"
: status === "blocked"
? "payload 覆盖完整,但 owner gate 未通过;只能等待 owner 释放或按确认协议处理。"
: "修复 payload 与 manifest 的覆盖差异后再继续。",
};
}
function renderAgentQcPayloadCoverageMarkdown(report) {
const lines = [
"# qcloop P0 Payload Coverage",
"",
`- Generated at: ${report.generatedAt}`,
`- Status: ${report.status}`,
`- Manifest: ${report.manifest}`,
`- Payload: ${report.payload}`,
`- Manifest P0 count: ${report.coverage.manifestP0Count}`,
`- Payload item count: ${report.coverage.payloadItemCount}`,
`- Missing scenarios: ${report.coverage.missingScenarioIds.join(", ") || "none"}`,
`- Extra scenarios: ${report.coverage.extraScenarioIds.join(", ") || "none"}`,
`- Order matches manifest: ${report.coverage.orderMatchesManifest}`,
`- Payload validation: ${report.coverage.payloadValidationPassed}`,
"",
"## Scenario Coverage",
"",
];
report.coverage.payloadScenarioIds.forEach((id, index) => {
lines.push(`${index + 1}. \`${id}\``);
});
lines.push(
"",
"## Repair Guard",
"",
`- Status: ${report.repairGuard.passed ? "pass" : "fail"}`,
`- max_qc_rounds: ${report.repairGuard.maxQcRounds}`,
`- max_executor_retries: ${report.repairGuard.maxExecutorRetries}`,
`- Read-only prompt: ${report.repairGuard.readOnlyPrompt}`,
"",
);
lines.push(
"",
"## Owner Gate",
"",
`- Status: ${report.ownerGate.status}`,
`- Summary: ${report.ownerGate.summary || "none"}`,
"",
"## Start Preconditions",
"",
);
for (const command of report.startPreconditions) {
lines.push(`- \`${command}\``);
}
lines.push("", "## Guardrails", "");
for (const [key, value] of Object.entries(report.guardrails)) {
lines.push(`- ${key}: ${String(value)}`);
}
lines.push("", "## Next Action", "", report.nextAction, "");
return `${lines.join("\n")}\n`;
}
export {
buildRepairGuard,
buildAgentQcPayloadCoverageReport,
p0ScenarioIdsFromManifest,
renderAgentQcPayloadCoverageMarkdown,
scenarioIdsFromPayload,
};
@@ -0,0 +1,123 @@
import { describe, expect, it } from "vitest";
import {
buildRepairGuard,
buildAgentQcPayloadCoverageReport,
p0ScenarioIdsFromManifest,
renderAgentQcPayloadCoverageMarkdown,
scenarioIdsFromPayload,
} from "./agent-qc-payload-coverage-core.mjs";
const manifest = {
scenarios: [
{ id: "command-bridge-contract", risk: "P0" },
{ id: "workspace-ready-session-restore", risk: "P0" },
{ id: "p1-only", risk: "P1" },
],
};
function owner(status: string) {
return {
verdict: { status, summary: `owner=${status}` },
ownerIntervention: status === "busy" ? { status: "requires_owner_confirmation" } : null,
};
}
function payload(items: unknown[], overrides = {}) {
return {
_validation: { valid: true },
max_qc_rounds: 1,
max_executor_retries: 0,
prompt_template: "只允许执行场景命令,不要顺手修复;不得修改源码、配置、文档、锁文件或 git 状态。",
items,
...overrides,
};
}
describe("agent-qc-payload-coverage-core", () => {
it("应从 manifest 提取 P0 场景", () => {
expect(p0ScenarioIdsFromManifest(manifest)).toEqual([
"command-bridge-contract",
"workspace-ready-session-restore",
]);
});
it("应解析 qcloop payload 中的字符串 item", () => {
const payload = {
items: [
JSON.stringify({ scenario_id: "command-bridge-contract" }),
JSON.stringify({ scenario_id: "workspace-ready-session-restore" }),
],
};
expect(scenarioIdsFromPayload(payload)).toEqual([
"command-bridge-contract",
"workspace-ready-session-restore",
]);
});
it("coverage 完整且 owner clear 时应 ready", () => {
const report = buildAgentQcPayloadCoverageReport({
manifest,
payload: payload([
JSON.stringify({ scenario_id: "command-bridge-contract" }),
JSON.stringify({ scenario_id: "workspace-ready-session-restore" }),
]),
processOwner: owner("pass"),
generatedAt: "2026-05-11T00:00:00.000Z",
});
expect(report.status).toBe("ready");
expect(report.coverage.passed).toBe(true);
expect(report.coverage.missingScenarioIds).toEqual([]);
expect(report.coverage.extraScenarioIds).toEqual([]);
});
it("coverage 完整但 owner busy 时应 blocked", () => {
const report = buildAgentQcPayloadCoverageReport({
manifest,
payload: payload([
{ scenario_id: "command-bridge-contract" },
{ scenario_id: "workspace-ready-session-restore" },
]),
processOwner: owner("busy"),
});
expect(report.status).toBe("blocked");
expect(report.ownerGate.ownerIntervention?.status).toBe("requires_owner_confirmation");
});
it("缺少 P0 场景时应 fail 并渲染 Markdown", () => {
const report = buildAgentQcPayloadCoverageReport({
manifest,
payload: payload([{ scenario_id: "command-bridge-contract" }]),
processOwner: owner("pass"),
});
const markdown = renderAgentQcPayloadCoverageMarkdown(report);
expect(report.status).toBe("fail");
expect(report.coverage.missingScenarioIds).toEqual(["workspace-ready-session-restore"]);
expect(markdown).toContain("qcloop P0 Payload Coverage");
expect(markdown).toContain("Missing scenarios");
});
it("应阻断会触发 qcloop repair 的发布证据 payload", () => {
const report = buildAgentQcPayloadCoverageReport({
manifest,
payload: payload(
[
{ scenario_id: "command-bridge-contract" },
{ scenario_id: "workspace-ready-session-restore" },
],
{ max_qc_rounds: 3 },
),
processOwner: owner("pass"),
});
expect(buildRepairGuard(payload([], { max_qc_rounds: 3 })).passed).toBe(false);
expect(report.repairGuard.passed).toBe(false);
expect(report.status).toBe("fail");
expect(report.repairGuard.maxQcRoundsPassed).toBe(false);
expect(renderAgentQcPayloadCoverageMarkdown(report)).toContain("Repair Guard");
});
});
+243
View File
@@ -0,0 +1,243 @@
const GUI_OWNER_PATTERNS = [
"verify:gui-smoke",
"smoke:workspace-ready",
"smoke:browser-runtime",
"smoke:site-adapters",
"smoke:agent-service-skill-entry",
"smoke:agent-runtime-tool-surface",
"smoke:knowledge-gui",
"smoke:design-canvas",
"claw-chat-ready-streaming",
"browser-runtime-site-adapter",
"workspace-ready-session-restore",
"release-package-startup-smoke",
];
const CARGO_OWNER_PATTERNS = [
"cargo ",
"cargo-fmt",
"rustc ",
"clippy-driver",
"tauri dev",
];
const QCLOOP_OWNER_PATTERNS = [
"qcloop --db",
"./qcloop --db",
"qcloop serve",
"qcloop-worker",
"qcloop_worker_result",
"agent qc p0",
"只读执行 lime agent qc p0",
];
const OBSERVER_PROCESS_PATTERNS = [
"while ps -p",
"while ps aux | rg",
"ps -eo pid,ppid,etime,pcpu,pmem,command | rg",
"ps aux | rg",
];
const PASSIVE_QCLOOP_SERVER_PATTERNS = [
"qcloop --db",
"./qcloop --db",
"qcloop serve",
];
const ACTIVE_QCLOOP_WORKER_PATTERNS = [
"qcloop-worker",
"qcloop_worker_result",
"agent qc p0",
"只读执行 lime agent qc p0",
];
const PASSIVE_TAURI_RUNTIME_PATTERNS = ["tauri dev"];
function parseEtimeSeconds(value) {
const text = String(value || "").trim();
if (!text) {
return null;
}
const dayParts = text.split("-");
const dayCount = dayParts.length === 2 ? Number(dayParts[0]) : 0;
const timeText = dayParts.length === 2 ? dayParts[1] : dayParts[0];
const parts = timeText.split(":").map((part) => Number(part));
if (parts.some((part) => !Number.isFinite(part))) {
return null;
}
if (parts.length === 3) {
return dayCount * 86400 + parts[0] * 3600 + parts[1] * 60 + parts[2];
}
if (parts.length === 2) {
return dayCount * 86400 + parts[0] * 60 + parts[1];
}
if (parts.length === 1) {
return dayCount * 86400 + parts[0];
}
return null;
}
function sanitizeProcessCommand(command) {
return String(command || "")
.replace(/(--api-key(?:=|\s+))(?:"[^"]+"|'[^']+'|\S+)/gi, "$1<redacted>")
.replace(/(api[_-]?key(?:=|:|\s+))(?:"[^"]+"|'[^']+'|\S+)/gi, "$1<redacted>")
.replace(/ctx7sk-[A-Za-z0-9-]+/g, "ctx7sk-***")
.replace(/sk-[A-Za-z0-9_-]{12,}/g, "sk-***");
}
function commandHasAny(command, patterns) {
const normalized = String(command || "").toLowerCase();
return patterns.some((pattern) => normalized.includes(pattern.toLowerCase()));
}
function isObserverProcess(entry) {
return commandHasAny(entry?.command, OBSERVER_PROCESS_PATTERNS);
}
function isPassiveQcloopServer(entry) {
return (
commandHasAny(entry?.command, PASSIVE_QCLOOP_SERVER_PATTERNS) &&
!commandHasAny(entry?.command, ACTIVE_QCLOOP_WORKER_PATTERNS)
);
}
function isPassiveTauriRuntime(entry) {
return commandHasAny(entry?.command, PASSIVE_TAURI_RUNTIME_PATTERNS);
}
function uniqueByPid(entries) {
const seen = new Set();
const result = [];
for (const entry of entries) {
if (seen.has(entry.pid)) {
continue;
}
seen.add(entry.pid);
result.push(entry);
}
return result.sort((left, right) => left.pid - right.pid);
}
function normalizeProcessEntry(entry) {
const etime = String(entry?.etime || "unknown");
return {
...entry,
pid: Number(entry?.pid || 0),
ppid: Number(entry?.ppid || 0),
pgid: entry?.pgid ?? null,
stat: entry?.stat || "unknown",
etime,
durationSeconds: entry?.durationSeconds ?? parseEtimeSeconds(etime),
command: sanitizeProcessCommand(entry?.command || ""),
};
}
function createAgentQcProcessOwnerReport(processes, {
generatedAt = new Date().toISOString(),
platform = process.platform,
maxActiveGuiSmoke = 0,
maxCargoOrRust = 0,
maxQcloopRelated = 0,
staleMinutes = 30,
} = {}) {
const normalizedProcesses = processes
.map(normalizeProcessEntry)
.filter((entry) => entry.pid > 0 && entry.command.length > 0);
const observerProcesses = uniqueByPid(normalizedProcesses.filter(isObserverProcess));
const ownerCandidateProcesses = normalizedProcesses.filter((entry) => !isObserverProcess(entry));
const passiveQcloopServerProcesses = uniqueByPid(
ownerCandidateProcesses.filter(isPassiveQcloopServer),
);
const passiveTauriRuntimeProcesses = uniqueByPid(
ownerCandidateProcesses.filter(isPassiveTauriRuntime),
);
const activeGuiSmokeProcesses = uniqueByPid(
ownerCandidateProcesses.filter((entry) => commandHasAny(entry.command, GUI_OWNER_PATTERNS)),
);
const qcloopProcesses = uniqueByPid(
ownerCandidateProcesses.filter(
(entry) => commandHasAny(entry.command, QCLOOP_OWNER_PATTERNS) && !isPassiveQcloopServer(entry),
),
);
const cargoProcesses = uniqueByPid(
ownerCandidateProcesses.filter(
(entry) => commandHasAny(entry.command, CARGO_OWNER_PATTERNS) && !isPassiveTauriRuntime(entry),
),
);
const staleThresholdSeconds = Math.max(0, Number(staleMinutes) || 0) * 60;
const staleActiveGuiSmokeProcesses = uniqueByPid(
activeGuiSmokeProcesses.filter(
(entry) => Number(entry.durationSeconds || 0) >= staleThresholdSeconds,
),
);
const counts = {
activeGuiSmoke: activeGuiSmokeProcesses.length,
cargoOrRust: cargoProcesses.length,
qcloopRelated: qcloopProcesses.length,
staleActiveGuiSmoke: staleActiveGuiSmokeProcesses.length,
passiveQcloopServer: passiveQcloopServerProcesses.length,
passiveTauriRuntime: passiveTauriRuntimeProcesses.length,
observer: observerProcesses.length,
};
const busy =
counts.activeGuiSmoke > maxActiveGuiSmoke ||
counts.cargoOrRust > maxCargoOrRust ||
counts.qcloopRelated > maxQcloopRelated;
return {
schemaVersion: "v1",
generatedAt,
platform,
maxActiveGuiSmoke,
maxCargoOrRust,
maxQcloopRelated,
staleMinutes,
verdict: {
status: busy ? "busy" : "pass",
summary: `activeGuiSmoke=${counts.activeGuiSmoke}, cargoOrRust=${counts.cargoOrRust}, qcloopRelated=${counts.qcloopRelated}, staleActiveGuiSmoke=${counts.staleActiveGuiSmoke}, passiveQcloopServer=${counts.passiveQcloopServer}, passiveTauriRuntime=${counts.passiveTauriRuntime}, observer=${counts.observer}`,
nextAction: busy
? "Do not start full verify:local or another GUI P0 batch while these owners are active; continue read-only observation or wait for natural completion."
: "No active raw GUI smoke, Cargo/Rust, or qcloop owner was observed; heavy gates may run if qcloop GUI owner and release evidence gates are also clear.",
},
ownerIntervention: staleActiveGuiSmokeProcesses.length
? {
status: "requires_owner_confirmation",
processIds: staleActiveGuiSmokeProcesses.map((entry) => entry.pid),
requiredConfirmationText: `确认处理 stale raw GUI owner PID ${staleActiveGuiSmokeProcesses.map((entry) => entry.pid).join(", ")},可以终止这些进程并记录 sidecar。`,
prohibitedUntilConfirmed: [
"kill / pause / interrupt stale raw GUI owner",
"start full verify:local",
"start another full GUI P0 batch",
"overwrite .lime/qc/agent-qc-evidence.json",
"git commit / push / tag / release",
],
nextAction: "等待这些进程自然释放,或由 owner 明确确认后再处理。",
}
: {
status: "not_required",
processIds: [],
requiredConfirmationText: "",
prohibitedUntilConfirmed: [],
nextAction: busy ? "等待 active owner 自然释放。" : "无需 stale raw owner 处置。",
},
activeGuiSmokeProcesses,
qcloopProcesses,
cargoProcesses,
staleActiveGuiSmokeProcesses,
passiveQcloopServerProcesses,
passiveTauriRuntimeProcesses,
observerProcesses,
guardrails: [
"best-effort process snapshot only",
"do not kill or restart listed processes from this sidecar",
"use this sidecar to decide whether heavy GUI or verify gates should wait",
],
};
}
export {
createAgentQcProcessOwnerReport,
parseEtimeSeconds,
sanitizeProcessCommand,
};
@@ -0,0 +1,104 @@
import { describe, expect, it } from "vitest";
import {
createAgentQcProcessOwnerReport,
parseEtimeSeconds,
sanitizeProcessCommand,
} from "./agent-qc-process-owner-core.mjs";
describe("agent-qc-process-owner-core", () => {
it("解析 ps etime 为秒", () => {
expect(parseEtimeSeconds("07:04:53")).toBe(25493);
expect(parseEtimeSeconds("10:05")).toBe(605);
expect(parseEtimeSeconds("2-01:00:00")).toBe(176400);
expect(parseEtimeSeconds("unknown")).toBeNull();
});
it("脱敏进程命令中的常见密钥", () => {
expect(sanitizeProcessCommand("cmd --api-key sk-1234567890abcdef api_key=ctx7sk-secret-value")).toContain(
"--api-key <redacted>",
);
expect(sanitizeProcessCommand("cmd --api-key sk-1234567890abcdef api_key=ctx7sk-secret-value")).not.toContain(
"sk-1234567890abcdef",
);
});
it("区分 active owner、passive runtime 与 observer", () => {
const report = createAgentQcProcessOwnerReport(
[
{
pid: 10,
ppid: 1,
pgid: 10,
stat: "S",
etime: "07:04:53",
command: "npm run smoke:design-canvas -- --timeout-ms 600000",
},
{
pid: 20,
ppid: 1,
pgid: 20,
stat: "S",
etime: "10:00:00",
command: "./qcloop --db .lime/qc/example.db serve --addr 127.0.0.1:18080 --workers 1",
},
{
pid: 30,
ppid: 1,
pgid: 30,
stat: "S",
etime: "10:00:00",
command: "node node_modules/.bin/tauri dev --config src-tauri/tauri.conf.headless.json",
},
{
pid: 40,
ppid: 1,
pgid: 40,
stat: "S",
etime: "00:01:00",
command: "while ps -p 123 >/dev/null 2>&1; do sleep 10; done; ps aux | rg verify:gui-smoke",
},
],
{ generatedAt: "2026-05-11T00:00:00.000Z", platform: "darwin" },
);
expect(report.verdict.status).toBe("busy");
expect(report.activeGuiSmokeProcesses.map((entry) => entry.pid)).toEqual([10]);
expect(report.staleActiveGuiSmokeProcesses.map((entry) => entry.pid)).toEqual([10]);
expect(report.passiveQcloopServerProcesses.map((entry) => entry.pid)).toEqual([20]);
expect(report.passiveTauriRuntimeProcesses.map((entry) => entry.pid)).toEqual([30]);
expect(report.observerProcesses.map((entry) => entry.pid)).toEqual([40]);
expect(report.qcloopProcesses).toHaveLength(0);
expect(report.cargoProcesses).toHaveLength(0);
expect(report.ownerIntervention.status).toBe("requires_owner_confirmation");
});
it("active qcloop worker 与 Cargo build 仍会阻断", () => {
const report = createAgentQcProcessOwnerReport(
[
{
pid: 50,
ppid: 1,
pgid: 50,
stat: "S",
etime: "00:05:00",
command: "node codex exec 只读执行 Lime Agent QC P0 场景",
},
{
pid: 60,
ppid: 1,
pgid: 60,
stat: "S",
etime: "00:05:00",
command: "cargo test --manifest-path src-tauri/Cargo.toml",
},
],
{ generatedAt: "2026-05-11T00:00:00.000Z", platform: "darwin" },
);
expect(report.verdict.status).toBe("busy");
expect(report.qcloopProcesses.map((entry) => entry.pid)).toEqual([50]);
expect(report.cargoProcesses.map((entry) => entry.pid)).toEqual([60]);
expect(report.ownerIntervention.status).toBe("not_required");
});
});
+312 -124
View File
File diff suppressed because it is too large Load Diff
+11 -11
View File
@@ -9,7 +9,7 @@ exclude = [
resolver = "2"
[workspace.package]
version = "1.33.0"
version = "1.34.0"
edition = "2021"
authors = ["coso"]
repository = "https://github.com/aiclientproxy/lime"
@@ -139,14 +139,14 @@ rmcp = { version = "0.12.0", features = ["client", "transport-io", "transport-ch
# Tauri
tauri = { version = "2.10.3", features = ["tray-icon", "image-png", "unstable", "macos-private-api"] }
tauri-build = { version = "2", features = [] }
tauri-plugin-shell = "2.3"
tauri-plugin-autostart = "2.3"
tauri-plugin-dialog = "2.5.0"
tauri-plugin-single-instance = "2.3"
tauri-plugin-global-shortcut = "2.3"
tauri-plugin-updater = "2"
tauri = { version = "2.11.1", features = ["tray-icon", "image-png", "unstable", "macos-private-api"] }
tauri-build = { version = "2.6.1", features = [] }
tauri-plugin-shell = "2.3.5"
tauri-plugin-autostart = "2.5.1"
tauri-plugin-dialog = "2.7.1"
tauri-plugin-single-instance = "2.4.2"
tauri-plugin-global-shortcut = "2.3.1"
tauri-plugin-updater = "2.10.1"
# 测试
proptest = "1"
@@ -194,11 +194,11 @@ version = "0.26"
version = "0.2"
[workspace.dependencies.tauri-plugin-deep-link]
version = "2.4"
version = "2.4.9"
[package]
name = "lime"
version = "1.33.0"
version = "1.34.0"
description = "AI API Proxy Desktop App"
authors = ["you"]
edition = "2021"
@@ -1,6 +1,6 @@
//! Tool Policy 系统模块
//!
//! 本模块实现了 OpenClaw 风格的 Tool Policy 系统,提供:
//! 本模块实现 Agent 工具策略系统,提供:
//! - Profile 预设配置(minimal, coding, messaging, full, custom)
//! - Tool Groups 工具分组(group:fs, group:runtime, group:memory, group:web, group:session)
//! - 多层策略合并(Profile → Global → Agent → Session)
@@ -9,7 +9,7 @@
//!
//! ## 设计参考
//!
//! 类型设计参考 OpenClaw 的 Cron/Scheduler 系统
//! 类型设计参考通用 Cron/Scheduler 系统
use chrono::{DateTime, Utc};
use chrono_tz::Tz;
+2 -6
View File
@@ -869,7 +869,7 @@ impl Default for NavigationConfig {
}
}
const CURRENT_CONFIGURABLE_NAV_ITEM_IDS: &[&str] = &["plugins", "openclaw", "companion"];
const CURRENT_CONFIGURABLE_NAV_ITEM_IDS: &[&str] = &["plugins", "companion"];
fn normalize_navigation_enabled_items(items: &[String]) -> Vec<String> {
let mut normalized = Vec::new();
@@ -2898,7 +2898,6 @@ mod unit_tests {
"video".to_string(),
"image-gen".to_string(),
"automation".to_string(),
"openclaw".to_string(),
"resources".to_string(),
"memory".to_string(),
];
@@ -2906,10 +2905,7 @@ mod unit_tests {
let changed = config.normalize_workspace_preferences();
assert!(changed);
assert_eq!(
config.navigation.enabled_items,
vec!["openclaw".to_string()]
);
assert_eq!(config.navigation.enabled_items, Vec::<String>::new());
}
#[test]
-1
View File
@@ -59,7 +59,6 @@ pub mod event_emit;
// 网络工具
pub mod network;
pub mod openclaw_install;
// 凭证清理(敏感信息过滤)
pub mod sanitizer;
@@ -1,458 +0,0 @@
use std::path::{Path, PathBuf};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ShellPlatform {
Windows,
Unix,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum OpenClawInstallDependencyKind {
Node,
Git,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum WindowsDependencyInstallPlan {
Winget { package_id: &'static str },
OfficialInstaller,
ManualDownload,
}
pub fn command_bin_dir_for(platform: ShellPlatform, binary_path: &str) -> Option<String> {
let separators: &[char] = match platform {
ShellPlatform::Windows => &['\\', '/'],
ShellPlatform::Unix => &['/'],
};
let index = binary_path.rfind(separators)?;
if index == 0 {
Some(binary_path[..1].to_string())
} else {
Some(binary_path[..index].to_string())
}
}
fn shell_escape(value: &str) -> String {
format!("'{}'", value.replace('\'', "'\"'\"'"))
}
pub fn shell_command_escape_for(platform: ShellPlatform, value: &str) -> String {
match platform {
ShellPlatform::Windows => format!("\"{}\"", value.replace('"', "\"\"")),
ShellPlatform::Unix => shell_escape(value),
}
}
pub fn shell_npm_prefix_assignment_for(platform: ShellPlatform, value: &str) -> String {
match platform {
ShellPlatform::Windows => {
format!(
"set \"NPM_CONFIG_PREFIX={}\" && ",
value.replace('"', "\"\"")
)
}
ShellPlatform::Unix => format!("NPM_CONFIG_PREFIX={} ", shell_escape(value)),
}
}
pub fn shell_path_assignment_for(platform: ShellPlatform, binary_path: &str) -> String {
let Some(bin_dir) = command_bin_dir_for(platform, binary_path) else {
return String::new();
};
match platform {
ShellPlatform::Windows => {
format!("set \"PATH={};%PATH%\" && ", bin_dir.replace('"', "\"\""))
}
ShellPlatform::Unix => format!("PATH={}:$PATH ", shell_escape(&bin_dir)),
}
}
pub fn shell_command_invocation_prefix_for(platform: ShellPlatform, binary_path: &str) -> String {
match platform {
ShellPlatform::Windows if windows_shell_requires_call(binary_path) => "call ".to_string(),
_ => String::new(),
}
}
fn windows_shell_requires_call(binary_path: &str) -> bool {
Path::new(binary_path)
.extension()
.and_then(|ext| ext.to_str())
.map(|ext| matches!(ext.to_ascii_lowercase().as_str(), "cmd" | "bat"))
.unwrap_or(false)
}
fn shell_environment_prefix(
platform: ShellPlatform,
binary_path: &str,
npm_prefix: Option<&str>,
) -> String {
format!(
"{}{}",
shell_path_assignment_for(platform, binary_path),
npm_prefix
.map(|prefix| shell_npm_prefix_assignment_for(platform, prefix))
.unwrap_or_default()
)
}
pub fn build_openclaw_cleanup_command(
platform: ShellPlatform,
npm_path: &str,
npm_prefix: Option<&str>,
) -> String {
format!(
"{}{}{} uninstall -g openclaw @qingchencloud/openclaw-zh",
shell_environment_prefix(platform, npm_path, npm_prefix),
shell_command_invocation_prefix_for(platform, npm_path),
shell_command_escape_for(platform, npm_path)
)
}
pub fn build_openclaw_install_command(
platform: ShellPlatform,
npm_path: &str,
npm_prefix: Option<&str>,
package: &str,
registry: Option<&str>,
) -> String {
let registry_suffix = registry
.map(|value| format!(" --registry={value}"))
.unwrap_or_default();
format!(
"{}{}{} install -g {}{}",
shell_environment_prefix(platform, npm_path, npm_prefix),
shell_command_invocation_prefix_for(platform, npm_path),
shell_command_escape_for(platform, npm_path),
package,
registry_suffix
)
}
pub fn resolve_windows_dependency_install_plan(
dependency: OpenClawInstallDependencyKind,
has_winget: bool,
) -> WindowsDependencyInstallPlan {
match (dependency, has_winget) {
(OpenClawInstallDependencyKind::Node, true) => WindowsDependencyInstallPlan::Winget {
package_id: "OpenJS.NodeJS.LTS",
},
(OpenClawInstallDependencyKind::Node, false) => {
WindowsDependencyInstallPlan::OfficialInstaller
}
(OpenClawInstallDependencyKind::Git, true) => WindowsDependencyInstallPlan::Winget {
package_id: "Git.Git",
},
(OpenClawInstallDependencyKind::Git, false) => WindowsDependencyInstallPlan::ManualDownload,
}
}
pub fn build_winget_install_command(winget_path: &str, package_id: &str) -> String {
format!(
"{}{} install --id {} -e --accept-source-agreements --accept-package-agreements",
shell_path_assignment_for(ShellPlatform::Windows, winget_path),
shell_command_escape_for(ShellPlatform::Windows, winget_path),
package_id
)
}
pub fn windows_manual_install_message(dependency: OpenClawInstallDependencyKind) -> &'static str {
match dependency {
OpenClawInstallDependencyKind::Node => {
"当前系统缺少 winget,暂时无法一键安装 Node.js,请点击“手动下载 Node.js”完成安装后重试。"
}
OpenClawInstallDependencyKind::Git => {
"当前系统缺少 winget,暂时无法一键安装 Git,请点击“手动下载 Git”完成安装后重试。"
}
}
}
fn command_path_rank(path: &Path) -> u8 {
match path
.extension()
.and_then(|ext| ext.to_str())
.map(|ext| ext.to_ascii_lowercase())
.as_deref()
{
Some("exe") => 0,
Some("cmd") => 1,
Some("bat") => 2,
_ => 3,
}
}
pub fn select_preferred_path_candidate(candidates: Vec<PathBuf>) -> Option<PathBuf> {
candidates
.into_iter()
.min_by_key(|path| command_path_rank(path))
}
fn is_better_semver_candidate(
current_best: Option<&(PathBuf, (u64, u64, u64))>,
candidate_path: &Path,
candidate_version: (u64, u64, u64),
) -> bool {
let Some((best_path, best_version)) = current_best else {
return true;
};
candidate_version > *best_version
|| (candidate_version == *best_version
&& command_path_rank(candidate_path) < command_path_rank(best_path))
}
type SemVer = (u64, u64, u64);
pub fn select_best_semver_candidate(
candidates: Vec<(PathBuf, Option<SemVer>)>,
min_version: SemVer,
) -> Option<PathBuf> {
let fallback =
select_preferred_path_candidate(candidates.iter().map(|(path, _)| path.clone()).collect());
let mut best_supported: Option<(PathBuf, (u64, u64, u64))> = None;
let mut best_any: Option<(PathBuf, (u64, u64, u64))> = None;
for (path, version) in candidates {
let Some(version) = version else {
continue;
};
if is_better_semver_candidate(best_any.as_ref(), &path, version) {
best_any = Some((path.clone(), version));
}
if version >= min_version
&& is_better_semver_candidate(best_supported.as_ref(), &path, version)
{
best_supported = Some((path, version));
}
}
best_supported
.or(best_any)
.map(|(path, _)| path)
.or(fallback)
}
#[cfg(test)]
mod tests {
use super::{
build_openclaw_cleanup_command, build_openclaw_install_command,
build_winget_install_command, command_bin_dir_for, resolve_windows_dependency_install_plan,
select_best_semver_candidate, select_preferred_path_candidate, shell_command_escape_for,
shell_command_invocation_prefix_for, shell_npm_prefix_assignment_for,
shell_path_assignment_for, windows_manual_install_message, OpenClawInstallDependencyKind,
ShellPlatform, WindowsDependencyInstallPlan,
};
use std::path::PathBuf;
const OPENCLAW_CN_PACKAGE: &str = "@qingchencloud/openclaw-zh@latest";
const OPENCLAW_DEFAULT_PACKAGE: &str = "openclaw@latest";
const NPM_MIRROR_CN: &str = "https://registry.npmmirror.com";
#[test]
fn windows_command_bin_dir_supports_backslash_paths() {
assert_eq!(
command_bin_dir_for(ShellPlatform::Windows, r"C:\Program Files\nodejs\npm.cmd"),
Some(r"C:\Program Files\nodejs".to_string())
);
}
#[test]
fn windows_shell_command_escape_keeps_cmd_compatible_quotes() {
assert_eq!(
shell_command_escape_for(ShellPlatform::Windows, r#"C:\Program Files\nodejs\npm.cmd"#),
r#""C:\Program Files\nodejs\npm.cmd""#
);
assert_eq!(
shell_command_escape_for(ShellPlatform::Windows, "C:\\demo\\na\"me\\npm.cmd"),
r#""C:\demo\na""me\npm.cmd""#
);
}
#[test]
fn windows_shell_npm_prefix_assignment_uses_set_syntax() {
assert_eq!(
shell_npm_prefix_assignment_for(
ShellPlatform::Windows,
r"C:\Users\demo\AppData\Roaming\npm"
),
r#"set "NPM_CONFIG_PREFIX=C:\Users\demo\AppData\Roaming\npm" && "#
);
}
#[test]
fn windows_shell_path_assignment_prepends_binary_directory() {
assert_eq!(
shell_path_assignment_for(ShellPlatform::Windows, r"C:\Program Files\nodejs\npm.cmd"),
r#"set "PATH=C:\Program Files\nodejs;%PATH%" && "#
);
}
#[test]
fn windows_cmd_scripts_use_call_invocation_prefix() {
assert_eq!(
shell_command_invocation_prefix_for(
ShellPlatform::Windows,
r"C:\Program Files\nodejs\npm.cmd"
),
"call "
);
assert!(shell_command_invocation_prefix_for(
ShellPlatform::Windows,
r"C:\Users\demo\AppData\Local\Microsoft\WindowsApps\winget.exe"
)
.is_empty());
}
#[test]
fn windows_cleanup_command_uses_cmd_compatible_syntax_without_true_fallback() {
let command = build_openclaw_cleanup_command(
ShellPlatform::Windows,
r"C:\Program Files\nodejs\npm.cmd",
Some(r"C:\Users\demo\AppData\Roaming\npm"),
);
assert_eq!(
command,
concat!(
"set \"PATH=C:\\Program Files\\nodejs;%PATH%\" && ",
"set \"NPM_CONFIG_PREFIX=C:\\Users\\demo\\AppData\\Roaming\\npm\" && ",
"call \"C:\\Program Files\\nodejs\\npm.cmd\" uninstall -g openclaw @qingchencloud/openclaw-zh"
)
);
assert!(!command.contains("|| true"));
}
#[test]
fn windows_install_command_adds_registry_when_using_china_package() {
let command = build_openclaw_install_command(
ShellPlatform::Windows,
r"C:\Program Files\nodejs\npm.cmd",
Some(r"C:\Users\demo\AppData\Roaming\npm"),
OPENCLAW_CN_PACKAGE,
Some(NPM_MIRROR_CN),
);
assert_eq!(
command,
concat!(
"set \"PATH=C:\\Program Files\\nodejs;%PATH%\" && ",
"set \"NPM_CONFIG_PREFIX=C:\\Users\\demo\\AppData\\Roaming\\npm\" && ",
"call \"C:\\Program Files\\nodejs\\npm.cmd\" install -g @qingchencloud/openclaw-zh@latest ",
"--registry=https://registry.npmmirror.com"
)
);
}
#[test]
fn windows_install_command_omits_registry_for_default_package() {
let command = build_openclaw_install_command(
ShellPlatform::Windows,
r"C:\Program Files\nodejs\npm.cmd",
None,
OPENCLAW_DEFAULT_PACKAGE,
None,
);
assert_eq!(
command,
concat!(
"set \"PATH=C:\\Program Files\\nodejs;%PATH%\" && ",
"call \"C:\\Program Files\\nodejs\\npm.cmd\" install -g openclaw@latest"
)
);
assert!(!command.contains("--registry="));
}
#[test]
fn preferred_path_candidate_prioritizes_windows_executable_extensions() {
let preferred = select_preferred_path_candidate(vec![
PathBuf::from(r"C:\nvm4w\nodejs\openclaw"),
PathBuf::from(r"C:\nvm4w\nodejs\openclaw.bat"),
PathBuf::from(r"C:\nvm4w\nodejs\openclaw.cmd"),
PathBuf::from(r"C:\nvm4w\nodejs\openclaw.exe"),
]);
assert_eq!(
preferred,
Some(PathBuf::from(r"C:\nvm4w\nodejs\openclaw.exe"))
);
}
#[test]
fn semver_selection_prefers_windows_launcher_over_bare_file_when_versions_equal() {
let preferred = select_best_semver_candidate(
vec![
(PathBuf::from(r"C:\nvm4w\nodejs\openclaw"), Some((23, 1, 0))),
(
PathBuf::from(r"C:\nvm4w\nodejs\openclaw.cmd"),
Some((23, 1, 0)),
),
],
(22, 0, 0),
);
assert_eq!(
preferred,
Some(PathBuf::from(r"C:\nvm4w\nodejs\openclaw.cmd"))
);
}
#[test]
fn windows_node_prefers_winget_when_available() {
assert_eq!(
resolve_windows_dependency_install_plan(OpenClawInstallDependencyKind::Node, true),
WindowsDependencyInstallPlan::Winget {
package_id: "OpenJS.NodeJS.LTS"
}
);
}
#[test]
fn windows_node_falls_back_to_official_installer_without_winget() {
assert_eq!(
resolve_windows_dependency_install_plan(OpenClawInstallDependencyKind::Node, false),
WindowsDependencyInstallPlan::OfficialInstaller
);
}
#[test]
fn windows_git_prefers_winget_when_available() {
assert_eq!(
resolve_windows_dependency_install_plan(OpenClawInstallDependencyKind::Git, true),
WindowsDependencyInstallPlan::Winget {
package_id: "Git.Git"
}
);
}
#[test]
fn windows_git_requires_manual_download_without_winget() {
assert_eq!(
resolve_windows_dependency_install_plan(OpenClawInstallDependencyKind::Git, false),
WindowsDependencyInstallPlan::ManualDownload
);
assert_eq!(
windows_manual_install_message(OpenClawInstallDependencyKind::Git),
"当前系统缺少 winget,暂时无法一键安装 Git,请点击“手动下载 Git”完成安装后重试。"
);
}
#[test]
fn winget_install_command_uses_expected_windows_flags() {
assert_eq!(
build_winget_install_command(
r"C:\Users\demo\AppData\Local\Microsoft\WindowsApps\winget.exe",
"OpenJS.NodeJS.LTS"
),
concat!(
"set \"PATH=C:\\Users\\demo\\AppData\\Local\\Microsoft\\WindowsApps;%PATH%\" && ",
"\"C:\\Users\\demo\\AppData\\Local\\Microsoft\\WindowsApps\\winget.exe\" install ",
"--id OpenJS.NodeJS.LTS -e --accept-source-agreements --accept-package-agreements"
)
);
}
}
+1 -28
View File
@@ -265,7 +265,6 @@ pub fn run() {
.manage(lime_gateway::wechat::WechatGatewayState::default())
.manage(lime_gateway::wechat::WechatLoginState::default())
.manage(gateway_tunnel_state)
.manage(crate::services::openclaw_service::OpenClawServiceState::default())
.manage(commands::telegram_remote_cmd::TelegramRemoteState::default())
.on_window_event(move |window, event| {
// 处理窗口关闭事件
@@ -1051,32 +1050,6 @@ pub fn run() {
commands::companion_cmd::companion_get_pet_status,
commands::companion_cmd::companion_launch_pet,
commands::companion_cmd::companion_send_pet_command,
// OpenClaw commands
commands::openclaw_cmd::openclaw_check_installed,
commands::openclaw_cmd::openclaw_get_environment_status,
commands::openclaw_cmd::openclaw_check_node_version,
commands::openclaw_cmd::openclaw_check_git_available,
commands::openclaw_cmd::openclaw_get_node_download_url,
commands::openclaw_cmd::openclaw_get_git_download_url,
commands::openclaw_cmd::openclaw_get_command_preview,
commands::openclaw_cmd::openclaw_get_progress_logs,
commands::openclaw_cmd::openclaw_list_runtime_candidates,
commands::openclaw_cmd::openclaw_install,
commands::openclaw_cmd::openclaw_install_dependency,
commands::openclaw_cmd::openclaw_check_update,
commands::openclaw_cmd::openclaw_uninstall,
commands::openclaw_cmd::openclaw_perform_update,
commands::openclaw_cmd::openclaw_set_preferred_runtime,
commands::openclaw_cmd::openclaw_cleanup_temp_artifacts,
commands::openclaw_cmd::openclaw_start_gateway,
commands::openclaw_cmd::openclaw_stop_gateway,
commands::openclaw_cmd::openclaw_restart_gateway,
commands::openclaw_cmd::openclaw_get_status,
commands::openclaw_cmd::openclaw_check_health,
commands::openclaw_cmd::openclaw_get_dashboard_url,
commands::openclaw_cmd::openclaw_get_channels,
commands::openclaw_cmd::openclaw_sync_provider_config,
commands::openclaw_cmd::openclaw_install_event,
// MCP commands
commands::mcp_cmd::get_mcp_servers,
commands::mcp_cmd::add_mcp_server,
@@ -1710,7 +1683,7 @@ mod tests {
#[test]
fn should_only_minimize_main_window_to_tray() {
assert!(should_minimize_to_tray("main", true));
assert!(!should_minimize_to_tray("openclaw-dashboard", true));
assert!(!should_minimize_to_tray("secondary-window", true));
assert!(!should_minimize_to_tray("main", false));
}
-1
View File
@@ -39,7 +39,6 @@ pub mod memory_search_cmd;
pub(crate) mod modality_runtime_contracts;
pub mod model_registry_cmd;
pub mod models_cmd;
pub mod openclaw_cmd;
pub mod persona_cmd;
pub mod plugin_cmd;
pub mod plugin_install_cmd;
-262
View File
@@ -1,262 +0,0 @@
use crate::commands::api_key_provider_cmd::ApiKeyProviderServiceState;
use crate::database::DbConnection;
use crate::services::openclaw_service::{
openclaw_install_event_name, ActionResult, BinaryAvailabilityStatus, BinaryInstallStatus,
ChannelInfo, CommandPreview, EnvironmentStatus, GatewayStatusInfo, HealthInfo,
InstallProgressEvent, NodeCheckResult, OpenClawRuntimeCandidate, OpenClawServiceState,
SyncModelEntry, UpdateInfo,
};
use serde::{Deserialize, Serialize};
use tauri::{AppHandle, State};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OpenClawSyncConfigRequest {
pub provider_id: String,
pub primary_model_id: String,
#[serde(default)]
pub models: Vec<SyncModelEntry>,
}
#[tauri::command]
pub async fn openclaw_check_installed(
service: State<'_, OpenClawServiceState>,
) -> Result<BinaryInstallStatus, String> {
let service = service.0.lock().await;
service.check_installed().await
}
#[tauri::command]
pub async fn openclaw_get_environment_status(
service: State<'_, OpenClawServiceState>,
) -> Result<EnvironmentStatus, String> {
let service = service.0.lock().await;
service.get_environment_status().await
}
#[tauri::command]
pub async fn openclaw_check_node_version(
service: State<'_, OpenClawServiceState>,
) -> Result<NodeCheckResult, String> {
let service = service.0.lock().await;
service.check_node_version().await
}
#[tauri::command]
pub async fn openclaw_check_git_available(
service: State<'_, OpenClawServiceState>,
) -> Result<BinaryAvailabilityStatus, String> {
let service = service.0.lock().await;
service.check_git_available().await
}
#[tauri::command]
pub async fn openclaw_get_node_download_url(
service: State<'_, OpenClawServiceState>,
) -> Result<String, String> {
let service = service.0.lock().await;
Ok(service.get_node_download_url())
}
#[tauri::command]
pub async fn openclaw_get_git_download_url(
service: State<'_, OpenClawServiceState>,
) -> Result<String, String> {
let service = service.0.lock().await;
Ok(service.get_git_download_url())
}
#[tauri::command]
pub async fn openclaw_get_command_preview(
app: AppHandle,
service: State<'_, OpenClawServiceState>,
operation: String,
port: Option<u16>,
) -> Result<CommandPreview, String> {
let mut service = service.0.lock().await;
service.get_command_preview(&app, &operation, port).await
}
#[tauri::command]
pub async fn openclaw_install(
app: AppHandle,
service: State<'_, OpenClawServiceState>,
) -> Result<ActionResult, String> {
let mut service = service.0.lock().await;
service.clear_progress_logs();
service.install(&app).await
}
#[tauri::command]
pub async fn openclaw_install_dependency(
app: AppHandle,
service: State<'_, OpenClawServiceState>,
kind: String,
) -> Result<ActionResult, String> {
let mut service = service.0.lock().await;
service.clear_progress_logs();
service.install_dependency(&app, &kind).await
}
#[tauri::command]
pub async fn openclaw_cleanup_temp_artifacts(
app: AppHandle,
service: State<'_, OpenClawServiceState>,
) -> Result<ActionResult, String> {
let mut service = service.0.lock().await;
service.cleanup_temp_artifacts(Some(&app)).await
}
#[tauri::command]
pub async fn openclaw_check_update(
service: State<'_, OpenClawServiceState>,
) -> Result<UpdateInfo, String> {
let service = service.0.lock().await;
service.check_update().await
}
#[tauri::command]
pub async fn openclaw_uninstall(
app: AppHandle,
service: State<'_, OpenClawServiceState>,
) -> Result<ActionResult, String> {
let mut service = service.0.lock().await;
service.clear_progress_logs();
service.uninstall(&app).await
}
#[tauri::command]
pub async fn openclaw_perform_update(
app: AppHandle,
service: State<'_, OpenClawServiceState>,
) -> Result<ActionResult, String> {
let mut service = service.0.lock().await;
service.clear_progress_logs();
service.perform_update(&app).await
}
#[tauri::command]
pub async fn openclaw_start_gateway(
app: AppHandle,
port: Option<u16>,
service: State<'_, OpenClawServiceState>,
) -> Result<ActionResult, String> {
let mut service = service.0.lock().await;
service.clear_progress_logs();
service.start_gateway(Some(&app), port).await
}
#[tauri::command]
pub async fn openclaw_stop_gateway(
app: AppHandle,
service: State<'_, OpenClawServiceState>,
) -> Result<ActionResult, String> {
let mut service = service.0.lock().await;
service.clear_progress_logs();
service.stop_gateway(Some(&app)).await
}
#[tauri::command]
pub async fn openclaw_restart_gateway(
app: AppHandle,
service: State<'_, OpenClawServiceState>,
) -> Result<ActionResult, String> {
let mut service = service.0.lock().await;
service.clear_progress_logs();
service.restart_gateway(&app).await
}
#[tauri::command]
pub async fn openclaw_get_progress_logs(
service: State<'_, OpenClawServiceState>,
) -> Result<Vec<InstallProgressEvent>, String> {
let service = service.0.lock().await;
Ok(service.get_progress_logs())
}
#[tauri::command]
pub async fn openclaw_list_runtime_candidates(
service: State<'_, OpenClawServiceState>,
) -> Result<Vec<OpenClawRuntimeCandidate>, String> {
let service = service.0.lock().await;
service.list_runtime_candidates().await
}
#[tauri::command]
pub async fn openclaw_set_preferred_runtime(
runtime_id: Option<String>,
service: State<'_, OpenClawServiceState>,
) -> Result<ActionResult, String> {
let service = service.0.lock().await;
service.set_preferred_runtime(runtime_id.as_deref()).await
}
#[tauri::command]
pub async fn openclaw_get_status(
service: State<'_, OpenClawServiceState>,
) -> Result<GatewayStatusInfo, String> {
let mut service = service.0.lock().await;
service.get_status().await
}
#[tauri::command]
pub async fn openclaw_check_health(
service: State<'_, OpenClawServiceState>,
) -> Result<HealthInfo, String> {
let mut service = service.0.lock().await;
service.check_health().await
}
#[tauri::command]
pub async fn openclaw_get_dashboard_url(
service: State<'_, OpenClawServiceState>,
) -> Result<String, String> {
let mut service = service.0.lock().await;
Ok(service.get_dashboard_url())
}
#[tauri::command]
pub async fn openclaw_get_channels(
service: State<'_, OpenClawServiceState>,
) -> Result<Vec<ChannelInfo>, String> {
let mut service = service.0.lock().await;
service.get_channels().await
}
#[tauri::command]
pub async fn openclaw_sync_provider_config(
request: OpenClawSyncConfigRequest,
db: State<'_, DbConnection>,
api_key_service: State<'_, ApiKeyProviderServiceState>,
service: State<'_, OpenClawServiceState>,
) -> Result<ActionResult, String> {
let provider = api_key_service
.0
.get_provider(&db, &request.provider_id)?
.ok_or_else(|| "未找到指定 Provider。".to_string())?;
if !provider.provider.enabled {
return Ok(ActionResult {
success: false,
message: "该 Provider 已被禁用。".to_string(),
});
}
let api_key = api_key_service
.0
.get_next_api_key(&db, &request.provider_id)?
.unwrap_or_default();
let mut service = service.0.lock().await;
service.sync_provider_config(
&provider.provider,
&api_key,
&request.primary_model_id,
&request.models,
)
}
#[tauri::command]
pub fn openclaw_install_event() -> String {
openclaw_install_event_name().to_string()
}
-5
View File
@@ -18,7 +18,6 @@ mod media_tasks;
mod memory;
mod memory_runtime;
mod models;
mod openclaw;
mod plugins;
mod project_resources;
mod providers;
@@ -159,10 +158,6 @@ pub async fn handle_command(
return Ok(result);
}
if let Some(result) = openclaw::try_handle(state, cmd, args.as_ref()).await? {
return Ok(result);
}
if let Some(result) = plugins::try_handle(state, cmd, args.as_ref()).await? {
return Ok(result);
}
@@ -1,38 +0,0 @@
use crate::dev_bridge::DevBridgeState;
use serde_json::Value as JsonValue;
use std::sync::Arc;
use tauri::{AppHandle, Manager};
use tokio::sync::Mutex;
mod operations;
mod queries;
type DynError = Box<dyn std::error::Error>;
type OpenClawServiceHandle = Arc<Mutex<crate::services::openclaw_service::OpenClawService>>;
fn openclaw_service_handle(app_handle: &AppHandle) -> OpenClawServiceHandle {
app_handle
.state::<crate::services::openclaw_service::OpenClawServiceState>()
.0
.clone()
}
fn openclaw_context(
state: &DevBridgeState,
) -> Result<(AppHandle, OpenClawServiceHandle), DynError> {
let app_handle = super::require_app_handle(state)?;
let service = openclaw_service_handle(&app_handle);
Ok((app_handle, service))
}
pub(super) async fn try_handle(
state: &DevBridgeState,
cmd: &str,
args: Option<&JsonValue>,
) -> Result<Option<JsonValue>, DynError> {
if let Some(result) = queries::try_handle(state, cmd, args).await? {
return Ok(Some(result));
}
operations::try_handle(state, cmd, args).await
}
@@ -1,129 +0,0 @@
use super::super::{args_or_default, get_db, get_string_arg, parse_nested_arg};
use super::{openclaw_context, DynError};
use crate::dev_bridge::DevBridgeState;
use serde_json::Value as JsonValue;
pub(super) async fn try_handle(
state: &DevBridgeState,
cmd: &str,
args: Option<&JsonValue>,
) -> Result<Option<JsonValue>, DynError> {
let result = match cmd {
"openclaw_get_command_preview" => {
let (app_handle, service) = openclaw_context(state)?;
let args = args_or_default(args);
let operation = get_string_arg(&args, "operation", "operation")?;
let port = args
.get("port")
.and_then(|value| value.as_u64())
.map(|value| value as u16);
let mut service = service.lock().await;
serde_json::to_value(
service
.get_command_preview(&app_handle, &operation, port)
.await?,
)?
}
"openclaw_install" => {
let (app_handle, service) = openclaw_context(state)?;
let mut service = service.lock().await;
service.clear_progress_logs();
serde_json::to_value(service.install(&app_handle).await?)?
}
"openclaw_install_dependency" => {
let (app_handle, service) = openclaw_context(state)?;
let args = args_or_default(args);
let kind = get_string_arg(&args, "kind", "kind")?;
let mut service = service.lock().await;
service.clear_progress_logs();
serde_json::to_value(service.install_dependency(&app_handle, &kind).await?)?
}
"openclaw_uninstall" => {
let (app_handle, service) = openclaw_context(state)?;
let mut service = service.lock().await;
service.clear_progress_logs();
serde_json::to_value(service.uninstall(&app_handle).await?)?
}
"openclaw_check_update" => {
let (_app_handle, service) = openclaw_context(state)?;
let service = service.lock().await;
serde_json::to_value(service.check_update().await?)?
}
"openclaw_cleanup_temp_artifacts" => {
let (app_handle, service) = openclaw_context(state)?;
let mut service = service.lock().await;
serde_json::to_value(service.cleanup_temp_artifacts(Some(&app_handle)).await?)?
}
"openclaw_perform_update" => {
let (app_handle, service) = openclaw_context(state)?;
let mut service = service.lock().await;
service.clear_progress_logs();
serde_json::to_value(service.perform_update(&app_handle).await?)?
}
"openclaw_set_preferred_runtime" => {
let (_app_handle, service) = openclaw_context(state)?;
let args = args_or_default(args);
let runtime_id = args
.get("runtimeId")
.and_then(|value| value.as_str())
.map(str::to_string);
let service = service.lock().await;
serde_json::to_value(service.set_preferred_runtime(runtime_id.as_deref()).await?)?
}
"openclaw_start_gateway" => {
let (app_handle, service) = openclaw_context(state)?;
let port = args
.and_then(|value| value.get("port"))
.and_then(|value| value.as_u64())
.map(|value| value as u16);
let mut service = service.lock().await;
service.clear_progress_logs();
serde_json::to_value(service.start_gateway(Some(&app_handle), port).await?)?
}
"openclaw_stop_gateway" => {
let (app_handle, service) = openclaw_context(state)?;
let mut service = service.lock().await;
service.clear_progress_logs();
serde_json::to_value(service.stop_gateway(Some(&app_handle)).await?)?
}
"openclaw_restart_gateway" => {
let (app_handle, service) = openclaw_context(state)?;
let mut service = service.lock().await;
service.clear_progress_logs();
serde_json::to_value(service.restart_gateway(&app_handle).await?)?
}
"openclaw_sync_provider_config" => {
let (_app_handle, service) = openclaw_context(state)?;
let args = args_or_default(args);
let request: crate::commands::openclaw_cmd::OpenClawSyncConfigRequest =
parse_nested_arg(&args, "request")?;
let db = get_db(state)?;
let provider = state
.api_key_provider_service
.get_provider(db, &request.provider_id)?
.ok_or_else(|| "未找到指定 Provider。".to_string())?;
if !provider.provider.enabled {
return Ok(Some(serde_json::json!({
"success": false,
"message": "该 Provider 已被禁用。"
})));
}
let api_key = state
.api_key_provider_service
.get_next_api_key(db, &request.provider_id)?
.unwrap_or_default();
let mut service = service.lock().await;
serde_json::to_value(service.sync_provider_config(
&provider.provider,
&api_key,
&request.primary_model_id,
&request.models,
)?)?
}
_ => return Ok(None),
};
Ok(Some(result))
}
@@ -1,82 +0,0 @@
use super::{openclaw_context, DynError};
use crate::dev_bridge::DevBridgeState;
use serde_json::Value as JsonValue;
pub(super) async fn try_handle(
state: &DevBridgeState,
cmd: &str,
_args: Option<&JsonValue>,
) -> Result<Option<JsonValue>, DynError> {
let result = match cmd {
"openclaw_check_installed" => {
let (_app_handle, service) = openclaw_context(state)?;
let service = service.lock().await;
let result = service.check_installed().await?;
serde_json::to_value(result)?
}
"openclaw_get_environment_status" => {
let (_app_handle, service) = openclaw_context(state)?;
let service = service.lock().await;
let result = service.get_environment_status().await?;
serde_json::to_value(result)?
}
"openclaw_check_node_version" => {
let (_app_handle, service) = openclaw_context(state)?;
let service = service.lock().await;
let result = service.check_node_version().await?;
serde_json::to_value(result)?
}
"openclaw_check_git_available" => {
let (_app_handle, service) = openclaw_context(state)?;
let service = service.lock().await;
let result = service.check_git_available().await?;
serde_json::to_value(result)?
}
"openclaw_get_node_download_url" => {
let (_app_handle, service) = openclaw_context(state)?;
let service = service.lock().await;
let result = service.get_node_download_url();
serde_json::json!(result)
}
"openclaw_get_git_download_url" => {
let (_app_handle, service) = openclaw_context(state)?;
let service = service.lock().await;
let result = service.get_git_download_url();
serde_json::json!(result)
}
"openclaw_get_status" => {
let (_app_handle, service) = openclaw_context(state)?;
let mut service = service.lock().await;
serde_json::to_value(service.get_status().await?)?
}
"openclaw_check_health" => {
let (_app_handle, service) = openclaw_context(state)?;
let mut service = service.lock().await;
serde_json::to_value(service.check_health().await?)?
}
"openclaw_get_dashboard_url" => {
let (_app_handle, service) = openclaw_context(state)?;
let mut service = service.lock().await;
serde_json::json!(service.get_dashboard_url())
}
"openclaw_get_channels" => {
let (_app_handle, service) = openclaw_context(state)?;
let mut service = service.lock().await;
serde_json::to_value(service.get_channels().await?)?
}
"openclaw_get_progress_logs" => {
let (_app_handle, service) = openclaw_context(state)?;
let service = service.lock().await;
let result = service.get_progress_logs();
serde_json::to_value(result)?
}
"openclaw_list_runtime_candidates" => {
let (_app_handle, service) = openclaw_context(state)?;
let service = service.lock().await;
serde_json::to_value(service.list_runtime_candidates().await?)?
}
_ => return Ok(None),
};
Ok(Some(result))
}
-1
View File
@@ -30,7 +30,6 @@ pub mod memory_import_parser_service;
pub mod memory_profile_prompt_service;
pub mod memory_rules_loader_service;
pub mod memory_source_resolver_service;
pub mod openclaw_service;
pub mod runtime_agents_template_service;
pub mod runtime_analysis_handoff_service;
pub mod runtime_auxiliary_projection_service;
@@ -1,341 +0,0 @@
use super::*;
impl OpenClawService {
pub fn sync_provider_config(
&mut self,
provider: &ApiKeyProvider,
api_key: &str,
primary_model_id: &str,
models: &[SyncModelEntry],
) -> Result<ActionResult, String> {
if api_key.trim().is_empty() && provider.provider_type != ApiProviderType::Ollama {
return Ok(ActionResult {
success: false,
message: "该 Provider 没有可用的 API Key。".to_string(),
});
}
let api_type = determine_api_type(provider.provider_type)?;
let base_url = format_provider_base_url(provider)?;
let provider_key = format!("lime-{}", provider.id);
let normalized_models = if models.is_empty() {
vec![SyncModelEntry {
id: primary_model_id.to_string(),
name: primary_model_id.to_string(),
context_window: None,
}]
} else {
let mut items = models.to_vec();
if !items.iter().any(|item| item.id == primary_model_id) {
items.insert(
0,
SyncModelEntry {
id: primary_model_id.to_string(),
name: primary_model_id.to_string(),
context_window: None,
},
);
}
items
};
self.ensure_runtime_config(
Some((
&provider_key,
json!({
"baseUrl": base_url,
"apiKey": api_key,
"api": api_type,
"models": normalized_models
.iter()
.map(sync_model_entry_to_config_value)
.collect::<Vec<_>>()
}),
)),
Some(format!("{provider_key}/{primary_model_id}")),
)?;
Ok(ActionResult {
success: true,
message: format!("已同步 Provider“{}”到 OpenClaw。", provider.name),
})
}
pub(crate) fn ensure_runtime_config(
&mut self,
provider_entry: Option<(&str, Value)>,
primary_model: Option<String>,
) -> Result<(), String> {
let config_dir = openclaw_config_dir();
std::fs::create_dir_all(&config_dir).map_err(|e| format!("创建配置目录失败: {e}"))?;
let lime_config_path = openclaw_lime_config_path();
let mut config = read_base_openclaw_config()?;
sanitize_runtime_config(&mut config);
if self.gateway_auth_token.is_empty() {
self.gateway_auth_token = generate_auth_token();
}
apply_gateway_runtime_defaults(&mut config, self.gateway_port, &self.gateway_auth_token);
if let Some((provider_key, provider_value)) = provider_entry {
set_json_path(
&mut config,
&["models", "mode"],
Value::String("merge".to_string()),
);
set_json_path(
&mut config,
&["models", "providers", provider_key],
provider_value,
);
}
if let Some(primary) = primary_model {
set_json_path(
&mut config,
&["agents", "defaults", "model", "primary"],
Value::String(primary),
);
}
let content =
serde_json::to_string_pretty(&config).map_err(|e| format!("序列化配置失败: {e}"))?;
std::fs::write(lime_config_path, content).map_err(|e| format!("写入配置失败: {e}"))?;
Ok(())
}
}
pub(crate) fn openclaw_config_dir() -> PathBuf {
home_dir()
.or_else(data_dir)
.unwrap_or_else(|| PathBuf::from("."))
.join(".openclaw")
}
pub(crate) fn openclaw_original_config_path() -> PathBuf {
openclaw_config_dir().join("openclaw.json")
}
pub(crate) fn openclaw_lime_config_path() -> PathBuf {
openclaw_config_dir().join("openclaw.lime.json")
}
pub(crate) fn read_base_openclaw_config() -> Result<Value, String> {
let lime_path = openclaw_lime_config_path();
if lime_path.exists() {
return read_json_file(&lime_path);
}
let original_path = openclaw_original_config_path();
if original_path.exists() {
return read_json_file(&original_path);
}
Ok(json!({}))
}
pub(crate) fn read_json_file(path: &Path) -> Result<Value, String> {
let content = std::fs::read_to_string(path)
.map_err(|e| format!("读取配置文件失败({}): {e}", path.display()))?;
serde_json::from_str(&content).map_err(|e| format!("解析配置文件失败({}): {e}", path.display()))
}
pub(crate) fn ensure_path_object<'a>(
root: &'a mut Value,
path: &[&str],
) -> &'a mut Map<String, Value> {
let mut current = root;
for segment in path {
let object = ensure_value_object(current);
current = object
.entry((*segment).to_string())
.or_insert_with(|| Value::Object(Map::new()));
}
ensure_value_object(current)
}
pub(crate) fn set_json_path(root: &mut Value, path: &[&str], value: Value) {
if path.is_empty() {
*root = value;
return;
}
let parent = ensure_path_object(root, &path[..path.len() - 1]);
parent.insert(path[path.len() - 1].to_string(), value);
}
pub(crate) fn sync_model_entry_to_config_value(model: &SyncModelEntry) -> Value {
let mut entry = Map::new();
entry.insert("id".to_string(), Value::String(model.id.clone()));
entry.insert("name".to_string(), Value::String(model.name.clone()));
if let Some(context_window) = model.context_window {
entry.insert(
"contextWindow".to_string(),
Value::Number(context_window.into()),
);
}
Value::Object(entry)
}
pub(crate) fn sanitize_runtime_config(config: &mut Value) {
let Some(providers) = config
.get_mut("models")
.and_then(|models| models.get_mut("providers"))
.and_then(Value::as_object_mut)
else {
return;
};
for provider in providers.values_mut() {
let Some(models) = provider.get_mut("models").and_then(Value::as_array_mut) else {
continue;
};
for model in models {
let Some(entry) = model.as_object_mut() else {
continue;
};
if matches!(entry.get("contextWindow"), Some(Value::Null)) {
entry.remove("contextWindow");
}
}
}
}
pub(crate) fn apply_gateway_runtime_defaults(
config: &mut Value,
gateway_port: u16,
gateway_auth_token: &str,
) {
ensure_path_object(config, &["gateway"]);
set_json_path(
config,
&["gateway", "mode"],
Value::String("local".to_string()),
);
set_json_path(
config,
&["gateway", "bind"],
Value::String("loopback".to_string()),
);
set_json_path(
config,
&["gateway", "port"],
Value::Number(gateway_port.into()),
);
set_json_path(
config,
&["gateway", "auth", "mode"],
Value::String("token".to_string()),
);
set_json_path(
config,
&["gateway", "auth", "token"],
Value::String(gateway_auth_token.to_string()),
);
set_json_path(
config,
&["gateway", "remote", "token"],
Value::String(gateway_auth_token.to_string()),
);
}
pub(crate) fn ensure_value_object(value: &mut Value) -> &mut Map<String, Value> {
if !value.is_object() {
*value = Value::Object(Map::new());
}
value.as_object_mut().expect("value should be object")
}
pub(crate) fn extract_gateway_auth_token(config: &Value) -> Option<String> {
config
.get("gateway")
.and_then(|gateway| {
gateway
.get("auth")
.and_then(|auth| auth.get("token"))
.or_else(|| gateway.get("remote").and_then(|remote| remote.get("token")))
})
.and_then(Value::as_str)
.map(str::trim)
.filter(|token| !token.is_empty())
.map(ToString::to_string)
}
pub(crate) fn determine_api_type(provider_type: ApiProviderType) -> Result<&'static str, String> {
match provider_type {
ApiProviderType::Anthropic | ApiProviderType::AnthropicCompatible => {
Ok("anthropic-messages")
}
ApiProviderType::OpenaiResponse => Ok("openai-responses"),
ApiProviderType::Openai
| ApiProviderType::Codex
| ApiProviderType::Gemini
| ApiProviderType::Ollama
| ApiProviderType::Fal
| ApiProviderType::NewApi
| ApiProviderType::Gateway => Ok("openai-completions"),
ApiProviderType::AzureOpenai | ApiProviderType::Vertexai | ApiProviderType::AwsBedrock => {
Err("当前暂不支持将该 Provider 同步到 OpenClaw。".to_string())
}
}
}
pub(crate) fn format_provider_base_url(provider: &ApiKeyProvider) -> Result<String, String> {
let api_host = trim_trailing_slash(&provider.api_host);
match provider.provider_type {
ApiProviderType::Anthropic | ApiProviderType::AnthropicCompatible => Ok(api_host),
ApiProviderType::Gemini => {
if api_host.contains("generativelanguage.googleapis.com") {
if api_host.ends_with("/v1beta/openai") {
Ok(api_host)
} else {
Ok(format!("{api_host}/v1beta/openai"))
}
} else if has_api_version(&api_host) {
Ok(api_host)
} else {
Ok(format!("{api_host}/v1"))
}
}
ApiProviderType::Gateway => {
if api_host.ends_with("/v1/ai") {
Ok(api_host.trim_end_matches("/ai").to_string())
} else if has_api_version(&api_host) {
Ok(api_host)
} else {
Ok(format!("{api_host}/v1"))
}
}
ApiProviderType::Openai
| ApiProviderType::OpenaiResponse
| ApiProviderType::Codex
| ApiProviderType::Ollama
| ApiProviderType::Fal
| ApiProviderType::NewApi => {
if has_api_version(&api_host) {
Ok(api_host)
} else {
Ok(format!("{api_host}/v1"))
}
}
ApiProviderType::AzureOpenai | ApiProviderType::Vertexai | ApiProviderType::AwsBedrock => {
Err("当前暂不支持将该 Provider 同步到 OpenClaw。".to_string())
}
}
}
pub(crate) fn trim_trailing_slash(value: &str) -> String {
value.trim().trim_end_matches('/').to_string()
}
pub(crate) fn has_api_version(url: &str) -> bool {
static VERSION_RE: OnceLock<Regex> = OnceLock::new();
VERSION_RE
.get_or_init(|| Regex::new(r"/v\d+(?:[./]|$)").expect("regex should compile"))
.is_match(url)
}
@@ -1,482 +0,0 @@
use super::*;
impl OpenClawService {
pub async fn get_environment_status(&self) -> Result<EnvironmentStatus, String> {
let node = inspect_node_dependency_status().await?;
let git = inspect_git_dependency_status().await?;
let openclaw = inspect_openclaw_dependency_status().await?;
let diagnostics = collect_environment_diagnostics().await;
Ok(build_environment_status(node, git, openclaw, diagnostics))
}
pub async fn check_installed(&self) -> Result<BinaryInstallStatus, String> {
let openclaw = inspect_openclaw_dependency_status().await?;
Ok(BinaryInstallStatus {
installed: openclaw.status == "ok",
path: openclaw.path,
})
}
pub async fn check_git_available(&self) -> Result<BinaryAvailabilityStatus, String> {
let git = inspect_git_dependency_status().await?;
Ok(BinaryAvailabilityStatus {
available: git.status == "ok",
path: git.path,
})
}
pub async fn check_node_version(&self) -> Result<NodeCheckResult, String> {
let node = inspect_node_dependency_status().await?;
Ok(NodeCheckResult {
status: match node.status.as_str() {
"missing" => "not_found".to_string(),
other => other.to_string(),
},
version: node.version,
path: node.path,
})
}
pub fn get_node_download_url(&self) -> String {
if cfg!(target_os = "windows") {
"https://nodejs.org/en/download".to_string()
} else if cfg!(target_os = "macos") {
"https://nodejs.org/en/download".to_string()
} else if cfg!(target_os = "linux") {
"https://nodejs.org/en/download".to_string()
} else {
"https://nodejs.org/en/download".to_string()
}
}
pub fn get_git_download_url(&self) -> String {
if cfg!(target_os = "windows") {
"https://git-scm.com/download/win".to_string()
} else if cfg!(target_os = "macos") {
"https://git-scm.com/download/mac".to_string()
} else if cfg!(target_os = "linux") {
"https://git-scm.com/download/linux".to_string()
} else {
"https://git-scm.com/downloads".to_string()
}
}
}
#[cfg(any(target_os = "windows", test))]
pub(crate) fn windows_dependency_setup_message(
dependency: DependencyKind,
status: &DependencyStatus,
) -> String {
let guidance = match dependency {
DependencyKind::Node => format!(
"Windows 下请先从 nodejs.org 安装或升级 Node.js {}+,完成后点击“重新检测”,再安装 OpenClaw。",
NODE_MIN_VERSION.0
),
DependencyKind::Git => {
"Windows 下请先从 git-scm.com 安装 Git(安装时请勾选加入 PATH),完成后点击“重新检测”,再安装 OpenClaw。"
.to_string()
}
};
format!("{} {}", status.message, guidance)
}
#[cfg(any(target_os = "windows", test))]
pub(crate) fn windows_dependency_action_result(
dependency: DependencyKind,
status: &DependencyStatus,
) -> ActionResult {
ActionResult {
success: false,
message: windows_dependency_setup_message(dependency, status),
}
}
#[cfg(any(target_os = "windows", test))]
pub(crate) fn windows_install_block_result(
node_status: &DependencyStatus,
git_status: &DependencyStatus,
) -> Option<ActionResult> {
if node_status.status != "ok" {
return Some(windows_dependency_action_result(
DependencyKind::Node,
node_status,
));
}
if git_status.status != "ok" {
return Some(windows_dependency_action_result(
DependencyKind::Git,
git_status,
));
}
None
}
pub(crate) fn dependency_setup_summary(dependency: DependencyKind) -> String {
if cfg!(target_os = "windows") {
return match dependency {
DependencyKind::Node => format!(
"当前缺少可用的 Node.js {}+ 运行时,Windows 下请先手动安装 Node.js,完成后点击“重新检测”,再安装 OpenClaw。",
NODE_MIN_VERSION.0
),
DependencyKind::Git => {
"当前缺少可用的 Git,Windows 下请先手动安装 Git(安装时请勾选加入 PATH),完成后点击“重新检测”,再安装 OpenClaw。"
.to_string()
}
};
}
if cfg!(target_os = "macos") {
return match dependency {
DependencyKind::Node => format!(
"当前缺少可用的 Node.js {}+ 运行时,建议先一键安装或修复 Node.js。",
format_semver(NODE_MIN_VERSION)
),
DependencyKind::Git => "当前缺少可用的 Git,建议先一键安装或修复 Git。".to_string(),
};
}
match dependency {
DependencyKind::Node => format!(
"当前缺少可用的 Node.js {}+ 运行时,请先手动安装后重新检测。",
format_semver(NODE_MIN_VERSION)
),
DependencyKind::Git => "当前缺少可用的 Git,请先手动安装后重新检测。".to_string(),
}
}
pub(crate) fn build_environment_status(
node: DependencyStatus,
git: DependencyStatus,
mut openclaw: DependencyStatus,
diagnostics: EnvironmentDiagnostics,
) -> EnvironmentStatus {
let node_ready = node.status == "ok";
let git_ready = git.status == "ok";
openclaw.auto_install_supported = node_ready && git_ready;
let (recommended_action, summary) = if !node_ready {
(
"install_node".to_string(),
dependency_setup_summary(DependencyKind::Node),
)
} else if !git_ready {
(
"install_git".to_string(),
dependency_setup_summary(DependencyKind::Git),
)
} else if openclaw.status == "needs_reload" {
(
"refresh_openclaw_env".to_string(),
"已检测到 OpenClaw 包,但命令尚未生效;请点击“重新检测”,必要时重启 Lime。".to_string(),
)
} else if openclaw.status != "ok" {
(
"install_openclaw".to_string(),
"运行环境已就绪,可以继续一键安装 OpenClaw。".to_string(),
)
} else {
(
"ready".to_string(),
"Node.js、Git 和 OpenClaw 均已就绪,可以继续配置与启动。".to_string(),
)
};
EnvironmentStatus {
node,
git,
openclaw,
recommended_action,
summary,
diagnostics,
temp_artifacts: collect_temp_artifact_paths(None)
.into_iter()
.filter(|path| path.exists())
.map(|path| path.display().to_string())
.collect(),
}
}
pub(crate) async fn inspect_node_dependency_status() -> Result<DependencyStatus, String> {
let Some(path) = find_command_in_shell("node").await? else {
return Ok(DependencyStatus {
status: "missing".to_string(),
version: None,
path: None,
message: format!(
"未检测到 Node.js,需要安装 {}+。",
format_semver(NODE_MIN_VERSION)
),
auto_install_supported: cfg!(target_os = "macos"),
});
};
let version_text = read_command_version_text(&path, &["--version"]).await?;
let Some(version) = parse_semver_from_text(&version_text) else {
return Ok(DependencyStatus {
status: "version_low".to_string(),
version: Some(version_text.clone()),
path: Some(path),
message: format!(
"检测到 Node.js,但无法识别版本:{version_text}。请安装 {}+。",
format_semver(NODE_MIN_VERSION)
),
auto_install_supported: cfg!(target_os = "macos"),
});
};
let normalized = format_semver(version);
if version >= NODE_MIN_VERSION {
Ok(DependencyStatus {
status: "ok".to_string(),
version: Some(normalized.clone()),
path: Some(path),
message: format!("Node.js 已就绪:{normalized}"),
auto_install_supported: cfg!(target_os = "macos"),
})
} else {
Ok(DependencyStatus {
status: "version_low".to_string(),
version: Some(normalized.clone()),
path: Some(path),
message: format!(
"Node.js 版本过低:{normalized},需要 {}+。",
format_semver(NODE_MIN_VERSION)
),
auto_install_supported: cfg!(target_os = "macos"),
})
}
}
pub(crate) async fn inspect_git_dependency_status() -> Result<DependencyStatus, String> {
let Some(path) = find_command_in_shell("git").await? else {
return Ok(DependencyStatus {
status: "missing".to_string(),
version: None,
path: None,
message: "未检测到 Git。".to_string(),
auto_install_supported: git_auto_install_supported().await?,
});
};
let version_text = read_command_version_text(&path, &["--version"]).await?;
let version = parse_semver_from_text(&version_text).map(format_semver);
let detail = version.clone().unwrap_or(version_text);
Ok(DependencyStatus {
status: "ok".to_string(),
version,
path: Some(path),
message: format!("Git 已就绪:{detail}"),
auto_install_supported: git_auto_install_supported().await?,
})
}
pub(crate) async fn inspect_openclaw_dependency_status() -> Result<DependencyStatus, String> {
let Some(command) = resolve_openclaw_command().await? else {
if let Some(status) = inspect_openclaw_package_reload_status().await? {
return Ok(status);
}
return Ok(DependencyStatus {
status: "missing".to_string(),
version: None,
path: None,
message: "未检测到 OpenClaw,可在环境就绪后一键安装。".to_string(),
auto_install_supported: false,
});
};
let version_text = read_openclaw_version_from_command(&command)
.await?
.unwrap_or_default();
Ok(DependencyStatus {
status: "ok".to_string(),
version: if version_text.is_empty() {
None
} else {
Some(version_text.clone())
},
path: Some(command.install_path_display()),
message: if matches!(command, ResolvedOpenClawCommand::NodeCli { .. }) {
if version_text.is_empty() {
"已检测到 OpenClaw 包,Lime 将通过当前 Node 运行时直接启动。".to_string()
} else {
format!("已检测到 OpenClaw 包,Lime 将通过当前 Node 运行时直接启动:{version_text}")
}
} else if version_text.is_empty() {
"已检测到 OpenClaw。".to_string()
} else {
format!("已检测到 OpenClaw:{version_text}")
},
auto_install_supported: false,
})
}
pub(crate) async fn inspect_openclaw_package_reload_status(
) -> Result<Option<DependencyStatus>, String> {
let Some(npm_path) = find_command_in_standard_locations("npm").await? else {
return Ok(None);
};
let Some(prefix) = detect_npm_global_prefix(&npm_path).await else {
return Ok(None);
};
let Some(package) = find_installed_openclaw_package_details(&prefix) else {
return Ok(None);
};
let version_suffix = package
.version
.as_deref()
.map(|item| format!("({item})"))
.unwrap_or_default();
Ok(Some(DependencyStatus {
status: "needs_reload".to_string(),
version: package.version.clone(),
path: Some(prefix.clone()),
message: format!(
"已在 npm 全局目录检测到 {}{},但当前进程尚未解析到 openclaw 命令。请点击“重新检测”;若仍失败,请重启 Lime,或确认 {prefix} 已加入 PATH。", package.name, version_suffix
),
auto_install_supported: false,
}))
}
pub(crate) async fn git_auto_install_supported() -> Result<bool, String> {
#[cfg(target_os = "macos")]
{
Ok(true)
}
#[cfg(not(target_os = "macos"))]
{
Ok(false)
}
}
pub(crate) async fn read_command_version_text(
command_path: &str,
args: &[&str],
) -> Result<String, String> {
let mut command = Command::new(command_path);
apply_binary_runtime_path(&mut command, command_path);
for arg in args {
command.arg(arg);
}
let output = command
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await
.map_err(|e| format!("执行命令失败({command_path}): {e}"))?;
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
if !stdout.is_empty() {
Ok(stdout)
} else {
Ok(stderr)
}
}
pub(crate) async fn collect_environment_diagnostics() -> EnvironmentDiagnostics {
let npm_path = find_command_in_standard_locations("npm")
.await
.ok()
.flatten();
let npm_global_prefix = match npm_path.as_deref() {
Some(path) => detect_npm_global_prefix(path).await,
None => None,
};
#[cfg(target_os = "windows")]
let where_candidates = find_commands_via_where("openclaw")
.await
.unwrap_or_default()
.into_iter()
.map(|path| path.display().to_string())
.collect();
#[cfg(not(target_os = "windows"))]
let where_candidates = Vec::new();
let supplemental_search_dirs =
collect_supplemental_openclaw_search_dirs(npm_global_prefix.as_deref());
let supplemental_command_candidates =
find_all_commands_in_paths("openclaw", &supplemental_search_dirs)
.into_iter()
.map(|path| path.display().to_string())
.collect();
let openclaw_package_path = npm_global_prefix
.as_deref()
.and_then(find_installed_openclaw_package_details)
.map(|package| package.path.display().to_string());
#[cfg(target_os = "windows")]
let git_where_candidates = find_commands_via_where("git")
.await
.unwrap_or_default()
.into_iter()
.map(|path| path.display().to_string())
.collect();
#[cfg(not(target_os = "windows"))]
let git_where_candidates = Vec::new();
let git_supplemental_search_dirs = collect_supplemental_git_search_dirs();
let git_supplemental_command_candidates =
find_all_commands_in_paths("git", &git_supplemental_search_dirs)
.into_iter()
.map(|path| path.display().to_string())
.collect();
EnvironmentDiagnostics {
npm_path,
npm_global_prefix,
openclaw_package_path,
where_candidates,
supplemental_search_dirs: supplemental_search_dirs
.into_iter()
.map(|path| path.display().to_string())
.collect(),
supplemental_command_candidates,
git_where_candidates,
git_supplemental_search_dirs: git_supplemental_search_dirs
.into_iter()
.map(|path| path.display().to_string())
.collect(),
git_supplemental_command_candidates,
}
}
pub(crate) fn collect_supplemental_openclaw_search_dirs(
npm_global_prefix: Option<&str>,
) -> Vec<PathBuf> {
let mut dirs = Vec::new();
#[cfg(target_os = "windows")]
{
dirs.extend(windows_known_command_dirs_from_env());
}
if let Some(prefix) = npm_global_prefix {
dirs.extend(npm_global_command_dirs(prefix));
}
collect_existing_unique_dirs(dirs)
}
pub(crate) fn collect_supplemental_git_search_dirs() -> Vec<PathBuf> {
#[cfg(target_os = "windows")]
{
return collect_existing_unique_dirs(windows_known_git_command_dirs_from_env());
}
#[cfg(not(target_os = "windows"))]
{
Vec::new()
}
}
@@ -1,871 +0,0 @@
use super::*;
impl OpenClawService {
pub async fn start_gateway(
&mut self,
app: Option<&AppHandle>,
port: Option<u16>,
) -> Result<ActionResult, String> {
if let Some(next_port) = port {
self.gateway_port = next_port.max(1);
}
if let Some(app) = app {
emit_install_progress(
app,
&format!("准备启动 Gateway,目标端口 {}。", self.gateway_port),
"info",
);
}
self.ensure_runtime_config(None, None)?;
self.refresh_process_state().await?;
if self.gateway_status == GatewayStatus::Running {
if let Some(app) = app {
emit_install_progress(
app,
&format!("检测到 Gateway 已在端口 {} 运行。", self.gateway_port),
"info",
);
}
return Ok(ActionResult {
success: true,
message: format!("Gateway 已在端口 {} 运行", self.gateway_port),
});
}
let Some(openclaw_command) = resolve_openclaw_command().await? else {
self.gateway_status = GatewayStatus::Error;
if let Some(app) = app {
emit_install_progress(app, "未检测到 OpenClaw 可执行文件,请先安装。", "error");
}
return Ok(ActionResult {
success: false,
message: "未检测到 OpenClaw 可执行文件,请先安装。".to_string(),
});
};
self.gateway_status = GatewayStatus::Starting;
let config_path = openclaw_lime_config_path();
if let Some(app) = app {
emit_install_progress(
app,
&format!("使用配置文件启动 Gateway: {}", config_path.display()),
"info",
);
}
let start_args = gateway_start_args(self.gateway_port, &self.gateway_auth_token);
let mut command = openclaw_command.build_command_with_args(&start_args);
command
.env(OPENCLAW_CONFIG_ENV, &config_path)
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = command
.spawn()
.map_err(|e| format!("启动 Gateway 失败: {e}"))?;
let gateway_error_lines = Arc::new(StdMutex::new(Vec::<String>::new()));
if let Some(stdout) = child.stdout.take() {
let app = app.cloned();
tokio::spawn(async move {
let mut lines = BufReader::new(stdout).lines();
while let Ok(Some(line)) = lines.next_line().await {
tracing::info!(target: "openclaw", "Gateway stdout: {}", line);
if let Some(app) = app.as_ref() {
emit_install_progress(app, &line, classify_progress_level(&line, "info"));
}
}
});
}
if let Some(stderr) = child.stderr.take() {
let app = app.cloned();
let gateway_error_lines = gateway_error_lines.clone();
tokio::spawn(async move {
let mut lines = BufReader::new(stderr).lines();
while let Ok(Some(line)) = lines.next_line().await {
tracing::warn!(target: "openclaw", "Gateway stderr: {}", line);
if let Ok(mut slot) = gateway_error_lines.lock() {
push_gateway_error_line(&mut slot, &line);
}
if let Some(app) = app.as_ref() {
emit_install_progress(app, &line, classify_progress_level(&line, "warn"));
}
}
});
}
self.gateway_process = Some(child);
self.gateway_started_at = Some(SystemTime::now());
if let Some(app) = app {
emit_install_progress(app, "Gateway 进程已拉起,等待服务就绪。", "info");
}
let start_at = tokio::time::Instant::now();
while start_at.elapsed() < Duration::from_secs(30) {
sleep(Duration::from_millis(300)).await;
self.refresh_process_state().await?;
let gateway_error_lines = gateway_error_lines
.lock()
.ok()
.map(|slot| slot.clone())
.unwrap_or_default();
if self.gateway_process.is_none() && self.gateway_status == GatewayStatus::Error {
let message = format_gateway_start_failure_message(
select_gateway_start_failure_detail(&gateway_error_lines),
);
if let Some(app) = app {
emit_install_progress(app, &message, "error");
}
return Ok(ActionResult {
success: false,
message,
});
}
if self.gateway_status == GatewayStatus::Running {
if let Some(app) = app {
emit_install_progress(
app,
&format!("Gateway 启动成功,监听端口 {}。", self.gateway_port),
"info",
);
}
return Ok(ActionResult {
success: true,
message: format!("Gateway 已启动,端口 {}", self.gateway_port),
});
}
if self.check_port_open().await {
self.gateway_status = GatewayStatus::Running;
if let Some(app) = app {
emit_install_progress(
app,
&format!("Gateway 探测成功,监听端口 {}。", self.gateway_port),
"info",
);
}
return Ok(ActionResult {
success: true,
message: format!("Gateway 已启动,端口 {}", self.gateway_port),
});
}
}
self.gateway_status = GatewayStatus::Error;
let gateway_error_lines = gateway_error_lines
.lock()
.ok()
.map(|slot| slot.clone())
.unwrap_or_default();
let message = format_gateway_start_failure_message(select_gateway_start_failure_detail(
&gateway_error_lines,
));
if let Some(app) = app {
emit_install_progress(app, &message, "error");
}
Ok(ActionResult {
success: false,
message,
})
}
pub async fn stop_gateway(&mut self, app: Option<&AppHandle>) -> Result<ActionResult, String> {
if let Some(app) = app {
emit_install_progress(app, "准备停止 Gateway。", "info");
}
self.restore_auth_token_from_config();
if let Some(mut child) = self.gateway_process.take() {
if let Some(app) = app {
emit_install_progress(app, "正在终止当前托管的 Gateway 子进程。", "info");
}
let _ = child.kill().await;
let _ = timeout(Duration::from_secs(3), child.wait()).await;
}
if self
.wait_for_gateway_shutdown(Duration::from_secs(3))
.await?
{
if let Some(app) = app {
emit_install_progress(app, "Gateway 已停止。", "info");
}
return Ok(ActionResult {
success: true,
message: "Gateway 已停止。".to_string(),
});
}
let stop_commands = self.collect_gateway_stop_commands().await?;
if stop_commands.is_empty() {
if let Some(app) = app {
emit_install_progress(
app,
"未检测到可用的 OpenClaw 停止命令,将尝试按端口回收旧 Gateway。",
"warn",
);
}
} else {
for command_spec in &stop_commands {
self.request_gateway_stop_via_command(command_spec, app)
.await;
if self
.wait_for_gateway_shutdown(Duration::from_secs(4))
.await?
{
if let Some(app) = app {
emit_install_progress(app, "Gateway 已停止。", "info");
}
return Ok(ActionResult {
success: true,
message: "Gateway 已停止。".to_string(),
});
}
}
}
let reclaimed_by_pid = self.force_stop_gateway_listener_processes(app).await?;
if reclaimed_by_pid
&& self
.wait_for_gateway_shutdown(Duration::from_secs(5))
.await?
{
if let Some(app) = app {
emit_install_progress(app, "Gateway 已停止。", "info");
}
return Ok(ActionResult {
success: true,
message: "Gateway 已停止。".to_string(),
});
}
self.refresh_process_state().await?;
let message = if self.check_port_open().await {
format!(
"Gateway 停止失败:端口 {} 仍被旧进程占用,升级已中止。请使用“立即重启生效”或结束旧 OpenClaw 进程后重试。",
self.gateway_port
)
} else {
"Gateway 停止流程已结束,但未能确认运行态完全退出,请重试。".to_string()
};
if let Some(app) = app {
emit_install_progress(app, &message, "error");
}
Ok(ActionResult {
success: false,
message,
})
}
pub async fn restart_gateway(&mut self, app: &AppHandle) -> Result<ActionResult, String> {
emit_install_progress(app, "开始重启 Gateway。", "info");
let stop_result = self.stop_gateway(Some(app)).await?;
if !stop_result.success {
return Ok(stop_result);
}
emit_install_progress(app, "Gateway 停止阶段结束,开始重新启动。", "info");
self.start_gateway(Some(app), Some(self.gateway_port)).await
}
pub async fn get_status(&mut self) -> Result<GatewayStatusInfo, String> {
self.refresh_process_state().await?;
Ok(GatewayStatusInfo {
status: self.gateway_status.clone(),
port: self.gateway_port,
})
}
pub async fn check_health(&mut self) -> Result<HealthInfo, String> {
self.refresh_process_state().await?;
self.restore_auth_token_from_config();
let health_snapshot = self.fetch_authenticated_gateway_health_json().await;
let healthy = self.gateway_status == GatewayStatus::Running
&& self.check_port_open().await
&& health_snapshot
.as_ref()
.and_then(|value| value.get("ok").and_then(Value::as_bool))
.unwrap_or(false);
let version = self.read_openclaw_version().await.ok().flatten();
let uptime = self.gateway_started_at.and_then(|start| {
SystemTime::now()
.duration_since(start)
.ok()
.map(|elapsed| elapsed.as_secs())
});
Ok(HealthInfo {
status: if healthy { "healthy" } else { "unhealthy" }.to_string(),
gateway_port: self.gateway_port,
uptime,
version,
})
}
pub fn get_dashboard_url(&mut self) -> String {
self.restore_auth_token_from_config();
let mut url = format!("http://127.0.0.1:{}", self.gateway_port);
if !self.gateway_auth_token.is_empty() {
url.push_str(&format!(
"/#token={}",
urlencoding::encode(&self.gateway_auth_token)
));
}
url
}
pub async fn get_channels(&mut self) -> Result<Vec<ChannelInfo>, String> {
self.refresh_process_state().await?;
if self.gateway_status != GatewayStatus::Running {
return Ok(Vec::new());
}
self.restore_auth_token_from_config();
let Some(body) = self.fetch_authenticated_gateway_health_json().await else {
return Ok(Vec::new());
};
let channels_map = body
.get("channels")
.and_then(Value::as_object)
.cloned()
.unwrap_or_default();
let labels = body
.get("channelLabels")
.and_then(Value::as_object)
.cloned()
.unwrap_or_default();
let ordered_ids = body
.get("channelOrder")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
let mut ordered = Vec::new();
for channel_id in ordered_ids.iter().filter_map(Value::as_str) {
if let Some(entry) = channels_map.get(channel_id) {
ordered.push(build_channel_info(
channel_id,
entry,
labels.get(channel_id),
));
}
}
if ordered.is_empty() {
ordered = channels_map
.iter()
.map(|(channel_id, entry)| {
build_channel_info(channel_id, entry, labels.get(channel_id))
})
.collect();
}
Ok(ordered)
}
async fn wait_for_gateway_shutdown(&mut self, max_wait: Duration) -> Result<bool, String> {
let start_at = tokio::time::Instant::now();
while start_at.elapsed() < max_wait {
self.refresh_process_state().await?;
if !self.check_port_open().await {
self.clear_gateway_runtime_state();
return Ok(true);
}
sleep(Duration::from_millis(250)).await;
}
self.refresh_process_state().await?;
if !self.check_port_open().await {
self.clear_gateway_runtime_state();
return Ok(true);
}
Ok(false)
}
async fn collect_gateway_stop_commands(&self) -> Result<Vec<ResolvedOpenClawCommand>, String> {
let mut commands = Vec::new();
if let Some(command) = resolve_openclaw_command().await? {
commands.push(command);
}
let mut runtime_candidates = list_openclaw_runtime_candidates().await?;
runtime_candidates.sort_by(compare_openclaw_runtime_candidates);
commands.extend(
runtime_candidates
.iter()
.filter_map(resolve_openclaw_command_from_runtime_candidate),
);
Ok(dedupe_openclaw_commands(commands))
}
async fn request_gateway_stop_via_command(
&self,
command_spec: &ResolvedOpenClawCommand,
app: Option<&AppHandle>,
) {
let binary_label = command_spec.invocation_display();
if let Some(app) = app {
emit_install_progress(
app,
&format!("尝试通过 {} 停止 Gateway。", binary_label),
"info",
);
}
let stop_args = vec![
"gateway".to_string(),
"stop".to_string(),
"--url".to_string(),
self.gateway_ws_url(),
"--token".to_string(),
self.gateway_auth_token.clone(),
];
let mut command = command_spec.build_command_with_args(&stop_args);
let output = timeout(
Duration::from_secs(8),
command
.env(OPENCLAW_CONFIG_ENV, openclaw_lime_config_path())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output(),
)
.await;
match output {
Ok(Ok(result)) => {
for line in command_output_lines(&result.stdout) {
if let Some(app) = app {
emit_install_progress(app, &line, classify_progress_level(&line, "info"));
}
}
for line in command_output_lines(&result.stderr) {
if let Some(app) = app {
emit_install_progress(app, &line, classify_progress_level(&line, "warn"));
}
}
if let Some(app) = app {
if result.status.success() {
emit_install_progress(app, "已发送 Gateway 停止命令。", "info");
} else {
emit_install_progress(
app,
&format!("Gateway 停止命令返回异常状态: {:?}", result.status.code()),
"warn",
);
}
}
}
Ok(Err(error)) => {
if let Some(app) = app {
emit_install_progress(
app,
&format!("执行 Gateway 停止命令失败: {error}"),
"warn",
);
}
}
Err(_) => {
if let Some(app) = app {
emit_install_progress(
app,
"Gateway 停止命令超时,继续尝试自动回收旧进程。",
"warn",
);
}
}
}
}
async fn force_stop_gateway_listener_processes(
&mut self,
app: Option<&AppHandle>,
) -> Result<bool, String> {
let listener_pids = collect_listening_port_pids(self.gateway_port).await;
if listener_pids.is_empty() {
return Ok(false);
}
let mut system = System::new_all();
system.refresh_all();
let target_pids = collect_openclaw_process_family_pids(&system, &listener_pids);
if target_pids.is_empty() {
if let Some(app) = app {
emit_install_progress(
app,
&format!(
"检测到端口 {} 仍被占用,但监听进程不是 OpenClaw,未执行自动终止。",
self.gateway_port
),
"warn",
);
}
return Ok(false);
}
if let Some(app) = app {
emit_install_progress(
app,
&format!(
"检测到旧 Gateway 仍占用端口 {},准备回收进程:{}。",
self.gateway_port,
target_pids
.iter()
.map(|pid| pid.as_u32().to_string())
.collect::<Vec<_>>()
.join(", ")
),
"warn",
);
}
terminate_sysinfo_processes(&mut system, &target_pids).await;
Ok(true)
}
pub(crate) async fn refresh_process_state(&mut self) -> Result<(), String> {
let mut process_exited = false;
if let Some(child) = self.gateway_process.as_mut() {
match child.try_wait() {
Ok(Some(status)) => {
tracing::info!(target: "openclaw", "Gateway 进程已退出: {}", status);
process_exited = true;
}
Ok(None) => {}
Err(error) => {
tracing::warn!(target: "openclaw", "检查 Gateway 进程状态失败: {}", error);
process_exited = true;
}
}
}
if process_exited {
self.gateway_process = None;
self.gateway_started_at = None;
}
let openclaw_command = resolve_openclaw_command().await?;
let running = self.check_port_open().await
|| self.check_gateway_status(openclaw_command.as_ref()).await?;
self.gateway_status = if running {
GatewayStatus::Running
} else if self.gateway_status == GatewayStatus::Starting {
GatewayStatus::Error
} else {
GatewayStatus::Stopped
};
if !running {
self.gateway_process = None;
self.gateway_started_at = None;
}
Ok(())
}
async fn check_port_open(&self) -> bool {
timeout(
Duration::from_secs(2),
TcpStream::connect(("127.0.0.1", self.gateway_port)),
)
.await
.map(|result| result.is_ok())
.unwrap_or(false)
}
async fn check_gateway_status(
&self,
command_spec: Option<&ResolvedOpenClawCommand>,
) -> Result<bool, String> {
let Some(command_spec) = command_spec else {
return Ok(false);
};
let status_args = vec![
"gateway".to_string(),
"status".to_string(),
"--url".to_string(),
self.gateway_ws_url(),
"--token".to_string(),
self.gateway_auth_token.clone(),
];
let mut command = command_spec.build_command_with_args(&status_args);
let output = command
.env(OPENCLAW_CONFIG_ENV, openclaw_lime_config_path())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await;
match output {
Ok(result) => {
let stdout = String::from_utf8_lossy(&result.stdout).to_lowercase();
let stderr = String::from_utf8_lossy(&result.stderr).to_lowercase();
Ok(result.status.success()
&& (stdout.contains("listening")
|| stdout.contains("running")
|| stderr.contains("listening")))
}
Err(_) => Ok(false),
}
}
pub(crate) async fn read_openclaw_version(&self) -> Result<Option<String>, String> {
let Some(command_spec) = resolve_openclaw_command().await? else {
return Ok(None);
};
read_openclaw_version_from_command(&command_spec).await
}
pub(crate) fn gateway_ws_url(&self) -> String {
format!("ws://127.0.0.1:{}", self.gateway_port)
}
pub(crate) fn restore_auth_token_from_config(&mut self) {
if !self.gateway_auth_token.is_empty() {
return;
}
match read_base_openclaw_config()
.ok()
.and_then(|config| extract_gateway_auth_token(&config))
{
Some(token) => {
self.gateway_auth_token = token;
}
None => {
tracing::warn!(
target: "openclaw",
"未能从 OpenClaw 配置恢复 gateway token,Dashboard 访问可能鉴权失败"
);
}
}
}
async fn fetch_authenticated_gateway_health_json(&self) -> Option<Value> {
if self.gateway_auth_token.is_empty() {
return None;
}
let Some(command_spec) = resolve_openclaw_command().await.ok().flatten() else {
return None;
};
let health_args = vec![
"gateway".to_string(),
"health".to_string(),
"--url".to_string(),
self.gateway_ws_url(),
"--token".to_string(),
self.gateway_auth_token.clone(),
"--json".to_string(),
];
let mut command = command_spec.build_command_with_args(&health_args);
let output = command
.env(OPENCLAW_CONFIG_ENV, openclaw_lime_config_path())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await;
match output {
Ok(output) if output.status.success() => {
serde_json::from_slice::<Value>(&output.stdout)
.map_err(|error| {
tracing::warn!(
target: "openclaw",
"解析 Gateway 官方健康检查结果失败: {}",
error
);
error
})
.ok()
}
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
tracing::warn!(
target: "openclaw",
"Gateway 官方健康检查失败: {}",
stderr.trim()
);
None
}
Err(error) => {
tracing::warn!(target: "openclaw", "执行 Gateway 官方健康检查失败: {}", error);
None
}
}
}
}
pub(crate) fn gateway_start_args(gateway_port: u16, gateway_auth_token: &str) -> Vec<String> {
vec![
"gateway".to_string(),
"--allow-unconfigured".to_string(),
"--bind".to_string(),
"loopback".to_string(),
"--auth".to_string(),
"token".to_string(),
"--token".to_string(),
gateway_auth_token.to_string(),
"--port".to_string(),
gateway_port.to_string(),
]
}
pub(crate) fn push_gateway_error_line(lines: &mut Vec<String>, line: &str) {
let trimmed = line.trim();
if trimmed.is_empty() {
return;
}
if lines.len() >= 32 {
lines.remove(0);
}
lines.push(trimmed.to_string());
}
pub(crate) fn select_gateway_start_failure_detail<'a>(lines: &'a [String]) -> Option<&'a str> {
lines
.iter()
.filter_map(|line| {
let trimmed = line.trim();
let score = gateway_failure_line_score(trimmed);
(score > 0).then_some((score, trimmed))
})
.max_by_key(|(score, _)| *score)
.map(|(_, line)| line)
}
pub(crate) fn gateway_failure_line_score(line: &str) -> u8 {
if line.is_empty() {
return 0;
}
let normalized = line.to_ascii_lowercase();
if normalized.starts_with("run: openclaw doctor")
|| normalized == "config invalid"
|| normalized.starts_with("file:")
|| normalized == "problem:"
{
return 0;
}
if normalized.contains("invalid config") {
return 100;
}
if normalized.contains("contextwindow") && normalized.contains("received null") {
return 95;
}
if normalized.contains("address already in use")
|| normalized.contains("eaddrinuse")
|| normalized.contains("resolved to non-loopback host")
{
return 90;
}
if normalized.contains("missing config")
|| normalized.contains("gateway.mode=local")
|| normalized.contains("gateway.auth.mode")
{
return 85;
}
if normalized.starts_with("- ") {
return 60;
}
20
}
pub(crate) fn format_gateway_start_failure_message(detail: Option<&str>) -> String {
let Some(detail) = detail.map(str::trim).filter(|value| !value.is_empty()) else {
return "Gateway 启动超时,请检查配置或端口占用。".to_string();
};
let normalized = detail.to_ascii_lowercase();
if normalized.contains("invalid config") || normalized.contains("config invalid") {
if normalized.contains("contextwindow") && normalized.contains("received null") {
return "Gateway 启动失败:当前 OpenClaw 配置包含空的 contextWindow 字段。Lime 已修正后续配置写入,请重新启动;如仍失败,请重新同步模型配置。"
.to_string();
}
return "Gateway 启动失败:OpenClaw 配置文件无效,请重新同步模型配置后再试。".to_string();
}
if normalized.contains("missing config") || normalized.contains("gateway.mode=local") {
return "Gateway 启动失败:OpenClaw 本地网关配置缺失,已自动补齐默认配置,请重试。"
.to_string();
}
if normalized.contains("gateway.auth.mode") {
return "Gateway 启动失败:缺少网关认证模式,已自动切换为 token 模式,请重试。".to_string();
}
if normalized.contains("address already in use") || normalized.contains("eaddrinuse") {
return "Gateway 启动失败:目标端口已被占用,请更换端口或停止占用进程。".to_string();
}
if normalized.contains("resolved to non-loopback host") {
return "Gateway 启动失败:当前环境无法绑定到本地回环地址 127.0.0.1,请检查本机网络或代理配置。".to_string();
}
if normalized.contains("allowedorigins") || normalized.contains("host-header origin fallback") {
return "Gateway 启动失败:当前绑定方式需要配置 Control UI 允许来源,请检查 gateway.controlUi.allowedOrigins。".to_string();
}
if normalized.contains("doctor --fix") {
return "Gateway 启动失败:OpenClaw 检测到本地环境或配置异常,请先在安装页执行“重新检测”或“修复环境”后再试。"
.to_string();
}
format!("Gateway 启动失败:{detail}")
}
pub(crate) fn build_channel_info(
channel_id: &str,
entry: &Value,
label: Option<&Value>,
) -> ChannelInfo {
ChannelInfo {
id: channel_id.to_string(),
name: entry
.get("name")
.and_then(Value::as_str)
.or_else(|| label.and_then(Value::as_str))
.unwrap_or("未命名通道")
.to_string(),
channel_type: entry
.get("type")
.and_then(Value::as_str)
.unwrap_or("unknown")
.to_string(),
status: entry
.get("status")
.and_then(Value::as_str)
.unwrap_or("unknown")
.to_string(),
}
}
@@ -1,810 +0,0 @@
use super::*;
impl OpenClawService {
pub async fn install(&mut self, app: &AppHandle) -> Result<ActionResult, String> {
emit_install_progress(app, "开始准备 OpenClaw 环境。", "info");
#[cfg(target_os = "windows")]
{
let node_status = self.inspect_dependency_status(DependencyKind::Node).await?;
let git_status = self.inspect_dependency_status(DependencyKind::Git).await?;
if let Some(result) = windows_install_block_result(&node_status, &git_status) {
emit_install_progress(app, &result.message, "warn");
return Ok(result);
}
}
let node_result = self
.ensure_dependency_ready(app, DependencyKind::Node)
.await?;
if !node_result.success {
return Ok(node_result);
}
let git_result = self
.ensure_dependency_ready(app, DependencyKind::Git)
.await?;
if !git_result.success {
return Ok(git_result);
}
let (_, npm_path, npm_prefix, cleanup_command, install_command) =
self.resolve_install_commands(app).await?;
emit_install_progress(app, &format!("使用 npm: {npm_path}"), "info");
if let Some(prefix) = npm_prefix {
emit_install_progress(app, &format!("npm 全局前缀: {prefix}"), "info");
}
emit_install_progress(app, "安装前先清理已有 OpenClaw 全局包。", "info");
let cleanup_result = run_shell_command_with_progress(app, &cleanup_command).await?;
if !cleanup_result.success {
emit_install_progress(
app,
&format!(
"清理旧版 OpenClaw 失败,继续尝试安装:{}",
cleanup_result.message
),
"warn",
);
}
emit_install_progress(app, &format!("执行安装命令: {install_command}"), "info");
let result = run_shell_command_with_progress(app, &install_command).await?;
if !result.success {
return Ok(result);
}
let installed = self.check_installed().await?;
if installed.installed {
emit_install_progress(app, "已检测到 OpenClaw 可执行文件。", "info");
return Ok(ActionResult {
success: true,
message: installed
.path
.map(|path| format!("OpenClaw 安装完成:{path}"))
.unwrap_or_else(|| "OpenClaw 安装完成。".to_string()),
});
}
Ok(ActionResult {
success: false,
message:
"安装命令执行完成,但仍未检测到 OpenClaw 可执行文件,请检查 npm 全局目录或权限设置。"
.to_string(),
})
}
pub async fn install_dependency(
&mut self,
app: &AppHandle,
kind: &str,
) -> Result<ActionResult, String> {
let dependency = match kind {
"node" => DependencyKind::Node,
"git" => DependencyKind::Git,
_ => return Err(format!("不支持的依赖类型: {kind}")),
};
#[cfg(target_os = "windows")]
{
let status = self.inspect_dependency_status(dependency).await?;
if status.status == "ok" {
emit_install_progress(
app,
&format!(
"{} 已就绪{}。",
dependency.label(),
status
.version
.as_deref()
.map(|version| format!(" · {version}"))
.unwrap_or_default()
),
"info",
);
return Ok(ActionResult {
success: true,
message: format!("{} 已满足要求。", dependency.label()),
});
}
let result = windows_dependency_action_result(dependency, &status);
emit_install_progress(app, &result.message, "warn");
return Ok(result);
}
#[cfg(not(target_os = "windows"))]
{
self.ensure_dependency_ready(app, dependency).await
}
}
pub async fn cleanup_temp_artifacts(
&mut self,
app: Option<&AppHandle>,
) -> Result<ActionResult, String> {
let mut removed = Vec::new();
let mut failed = Vec::new();
for target in collect_temp_artifact_paths(app) {
if !target.exists() {
continue;
}
let result = if target.is_dir() {
std::fs::remove_dir_all(&target)
} else {
std::fs::remove_file(&target)
};
match result {
Ok(_) => {
if let Some(app) = app {
emit_install_progress(
app,
&format!("已清理临时文件:{}", target.display()),
"info",
);
}
removed.push(target.display().to_string());
}
Err(error) => {
if let Some(app) = app {
emit_install_progress(
app,
&format!("清理临时文件失败({}): {error}", target.display()),
"warn",
);
}
failed.push(format!("{}: {error}", target.display()));
}
}
}
if failed.is_empty() {
Ok(ActionResult {
success: true,
message: if removed.is_empty() {
"未发现需要清理的 OpenClaw 临时文件。".to_string()
} else {
format!("已清理 {} 项临时文件。", removed.len())
},
})
} else {
Ok(ActionResult {
success: false,
message: format!("部分临时文件清理失败:{}", failed.join(";")),
})
}
}
pub async fn uninstall(&mut self, app: &AppHandle) -> Result<ActionResult, String> {
if self.gateway_status == GatewayStatus::Running || self.gateway_process.is_some() {
let _ = self.stop_gateway(None).await;
}
let (npm_path, npm_prefix, command) = self.resolve_uninstall_command().await?;
emit_install_progress(app, &format!("使用 npm: {npm_path}"), "info");
if let Some(prefix) = npm_prefix {
emit_install_progress(app, &format!("npm 全局前缀: {prefix}"), "info");
}
emit_install_progress(app, &format!("执行卸载命令: {command}"), "info");
run_shell_command_with_progress(app, &command).await
}
async fn ensure_dependency_ready(
&mut self,
app: &AppHandle,
dependency: DependencyKind,
) -> Result<ActionResult, String> {
let status = self.inspect_dependency_status(dependency).await?;
if status.status == "ok" {
emit_install_progress(
app,
&format!(
"{} 已就绪{}。",
dependency.label(),
status
.version
.as_deref()
.map(|version| format!(" · {version}"))
.unwrap_or_default()
),
"info",
);
return Ok(ActionResult {
success: true,
message: format!("{} 已满足要求。", dependency.label()),
});
}
emit_install_progress(
app,
&format!("{},开始修复 {} 环境。", status.message, dependency.label()),
"warn",
);
match dependency {
DependencyKind::Node => self.install_node_runtime(app).await,
DependencyKind::Git => self.install_git_runtime(app).await,
}
}
async fn inspect_dependency_status(
&self,
dependency: DependencyKind,
) -> Result<DependencyStatus, String> {
match dependency {
DependencyKind::Node => inspect_node_dependency_status().await,
DependencyKind::Git => inspect_git_dependency_status().await,
}
}
async fn install_node_runtime(&mut self, app: &AppHandle) -> Result<ActionResult, String> {
#[cfg(target_os = "windows")]
{
let winget_path = find_command_in_shell("winget").await?;
match resolve_windows_dependency_install_plan(
DependencyKind::Node,
winget_path.is_some(),
) {
WindowsDependencyInstallPlan::Winget { package_id } => {
let winget_path = winget_path.expect("winget path should exist");
emit_install_progress(
app,
"检测到 winget,准备通过 winget 安装 Node.js。",
"info",
);
let command = build_winget_install_command(&winget_path, package_id);
let result = run_shell_command_with_progress(app, &command).await?;
if !result.success {
return Ok(result);
}
return self
.verify_dependency_after_install(app, DependencyKind::Node)
.await;
}
WindowsDependencyInstallPlan::OfficialInstaller => {
emit_install_progress(
app,
"未检测到 winget,准备下载官方 Node.js 安装器。",
"warn",
);
let asset = resolve_node_installer_asset().await?;
let installer_path = download_installer_asset(app, &asset).await?;
launch_installer(&installer_path)?;
return self
.wait_for_dependency_ready(app, DependencyKind::Node, 900)
.await;
}
WindowsDependencyInstallPlan::ManualDownload => {
unreachable!("Node.js 在 Windows 上不应返回手动下载计划")
}
}
}
#[cfg(target_os = "macos")]
{
if let Some(brew_path) = find_command_in_shell("brew").await? {
emit_install_progress(
app,
"检测到 Homebrew,准备通过 Homebrew 安装 Node.js。",
"info",
);
let brew_cmd = shell_command_escape(&brew_path);
let path_env = shell_path_assignment(&brew_path);
let command = format!(
"{path_env}{brew_cmd} install node || {path_env}{brew_cmd} upgrade node"
);
let result = run_shell_command_with_progress(app, &command).await?;
if !result.success {
return Ok(result);
}
return self
.verify_dependency_after_install(app, DependencyKind::Node)
.await;
}
emit_install_progress(
app,
"未检测到 Homebrew,准备下载官方 Node.js 安装器。",
"warn",
);
let asset = resolve_node_installer_asset().await?;
let installer_path = download_installer_asset(app, &asset).await?;
launch_installer(&installer_path)?;
return self
.wait_for_dependency_ready(app, DependencyKind::Node, 900)
.await;
}
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
{
let message = "当前平台暂不支持应用内自动安装 Node.js,请手动安装 Node.js 22+ 后重试。"
.to_string();
emit_install_progress(app, &message, "warn");
Ok(ActionResult {
success: false,
message,
})
}
}
async fn install_git_runtime(&mut self, app: &AppHandle) -> Result<ActionResult, String> {
#[cfg(target_os = "windows")]
{
let winget_path = find_command_in_shell("winget").await?;
match resolve_windows_dependency_install_plan(
DependencyKind::Git,
winget_path.is_some(),
) {
WindowsDependencyInstallPlan::Winget { package_id } => {
let winget_path = winget_path.expect("winget path should exist");
emit_install_progress(app, "检测到 winget,准备通过 winget 安装 Git。", "info");
let command = build_winget_install_command(&winget_path, package_id);
let result = run_shell_command_with_progress(app, &command).await?;
if !result.success {
return Ok(result);
}
return self
.verify_dependency_after_install(app, DependencyKind::Git)
.await;
}
WindowsDependencyInstallPlan::OfficialInstaller => {
unreachable!("Git 在 Windows 上不应返回官方安装器计划")
}
WindowsDependencyInstallPlan::ManualDownload => {
let message = windows_manual_install_message(DependencyKind::Git).to_string();
emit_install_progress(app, &message, "warn");
return Ok(ActionResult {
success: false,
message,
});
}
}
}
#[cfg(target_os = "macos")]
{
if let Some(brew_path) = find_command_in_shell("brew").await? {
emit_install_progress(app, "检测到 Homebrew,准备通过 Homebrew 安装 Git。", "info");
let brew_cmd = shell_command_escape(&brew_path);
let path_env = shell_path_assignment(&brew_path);
let command =
format!("{path_env}{brew_cmd} install git || {path_env}{brew_cmd} upgrade git");
let result = run_shell_command_with_progress(app, &command).await?;
if !result.success {
return Ok(result);
}
return self
.verify_dependency_after_install(app, DependencyKind::Git)
.await;
}
emit_install_progress(
app,
"未检测到 Homebrew,准备拉起 macOS Command Line Tools 安装器。",
"warn",
);
let trigger_result = trigger_macos_command_line_tools_install().await?;
emit_install_progress(app, &trigger_result, "info");
return self
.wait_for_dependency_ready(app, DependencyKind::Git, 1200)
.await;
}
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
{
let message = "当前平台暂不支持应用内自动安装 Git,请使用系统包管理器手动安装后重试。"
.to_string();
emit_install_progress(app, &message, "warn");
Ok(ActionResult {
success: false,
message,
})
}
}
async fn verify_dependency_after_install(
&self,
app: &AppHandle,
dependency: DependencyKind,
) -> Result<ActionResult, String> {
// 在 Windows 上刷新 PATH 环境变量
#[cfg(target_os = "windows")]
{
if let Err(e) = refresh_windows_path_from_registry() {
emit_install_progress(app, &format!("刷新环境变量失败: {}", e), "warn");
} else {
emit_install_progress(app, "已刷新系统环境变量。", "info");
}
}
let status = self.inspect_dependency_status(dependency).await?;
if status.status == "ok" {
emit_install_progress(
app,
&format!(
"{} 已准备完成{}。",
dependency.label(),
status
.version
.as_deref()
.map(|version| format!(" · {version}"))
.unwrap_or_default()
),
"info",
);
return Ok(ActionResult {
success: true,
message: format!("{} 已安装完成。", dependency.label()),
});
}
Ok(ActionResult {
success: false,
message: format!(
"{} 安装完成后仍未通过校验:{}",
dependency.label(),
status.message
),
})
}
async fn wait_for_dependency_ready(
&self,
app: &AppHandle,
dependency: DependencyKind,
timeout_secs: u64,
) -> Result<ActionResult, String> {
emit_install_progress(
app,
&format!(
"已拉起 {} 安装器,正在等待安装完成(最长 {} 秒)。",
dependency.label(),
timeout_secs
),
"info",
);
let start = tokio::time::Instant::now();
let mut last_notice_at = 0_u64;
#[cfg(target_os = "windows")]
let mut last_refresh_at = 0_u64;
while start.elapsed() < Duration::from_secs(timeout_secs) {
let elapsed = start.elapsed().as_secs();
// 每 10 秒刷新一次 Windows PATH(因为用户可能在安装过程中)
#[cfg(target_os = "windows")]
if elapsed >= last_refresh_at + 10 {
last_refresh_at = elapsed;
let _ = refresh_windows_path_from_registry();
}
if elapsed >= last_notice_at + 15 {
last_notice_at = elapsed;
emit_install_progress(
app,
&format!("正在等待 {} 安装完成…", dependency.label()),
"info",
);
}
sleep(Duration::from_secs(2)).await;
let status = self.inspect_dependency_status(dependency).await?;
if status.status == "ok" {
emit_install_progress(
app,
&format!(
"{} 已检测通过{}。",
dependency.label(),
status
.version
.as_deref()
.map(|version| format!(" · {version}"))
.unwrap_or_default()
),
"info",
);
return Ok(ActionResult {
success: true,
message: format!("{} 已安装完成。", dependency.label()),
});
}
}
Ok(ActionResult {
success: false,
message: format!(
"等待 {} 安装完成超时,请完成安装后重新点击重试。",
dependency.label()
),
})
}
pub(crate) async fn resolve_install_commands(
&self,
app: &AppHandle,
) -> Result<(String, String, Option<String>, String, String), String> {
let npm_path = find_command_in_shell("npm")
.await?
.ok_or_else(|| "未检测到 npm,可先安装或修复 Node.js 环境。".to_string())?;
let npm_prefix = detect_npm_global_prefix(&npm_path).await;
let use_china_package = should_use_china_package(app).await;
let package = if use_china_package {
OPENCLAW_CN_PACKAGE
} else {
OPENCLAW_DEFAULT_PACKAGE
};
let shell_platform = current_shell_platform();
let cleanup_command =
build_openclaw_cleanup_command(shell_platform, &npm_path, npm_prefix.as_deref());
let install_command = build_openclaw_install_command(
shell_platform,
&npm_path,
npm_prefix.as_deref(),
package,
use_china_package.then_some(NPM_MIRROR_CN),
);
Ok((
package.to_string(),
npm_path,
npm_prefix,
cleanup_command,
install_command,
))
}
pub(crate) async fn resolve_uninstall_command(
&self,
) -> Result<(String, Option<String>, String), String> {
let npm_path = find_command_in_shell("npm")
.await?
.ok_or_else(|| "未检测到 npm,可先安装或修复 Node.js 环境。".to_string())?;
let npm_prefix = detect_npm_global_prefix(&npm_path).await;
let command = build_openclaw_cleanup_command(
current_shell_platform(),
&npm_path,
npm_prefix.as_deref(),
);
Ok((npm_path, npm_prefix, command))
}
}
pub(crate) fn openclaw_installer_download_dir(app: &AppHandle) -> Result<PathBuf, String> {
let _ = app;
let app_data_dir = lime_core::app_paths::preferred_data_dir()
.map_err(|e| format!("无法获取应用数据目录: {e}"))?;
let dir = app_data_dir.join("downloads").join("openclaw-installers");
std::fs::create_dir_all(&dir).map_err(|e| format!("创建 OpenClaw 下载目录失败: {e}"))?;
Ok(dir)
}
pub(crate) fn collect_temp_artifact_paths(app: Option<&AppHandle>) -> Vec<PathBuf> {
let mut targets = Vec::new();
#[cfg(not(target_os = "windows"))]
{
targets.push(PathBuf::from(OPENCLAW_TEMP_CARGO_CHECK_DIR));
}
if let Some(app) = app {
if let Ok(dir) = openclaw_installer_download_dir(app) {
targets.push(dir);
}
}
targets
}
pub(crate) async fn resolve_node_installer_asset() -> Result<InstallerAsset, String> {
let client = reqwest::Client::new();
let response = client
.get("https://nodejs.org/dist/index.json")
.header("User-Agent", OPENCLAW_INSTALLER_USER_AGENT)
.send()
.await
.map_err(|e| format!("请求 Node.js 版本列表失败: {e}"))?;
if !response.status().is_success() {
return Err(format!(
"获取 Node.js 版本列表失败: HTTP {}",
response.status()
));
}
let releases: Vec<Value> = response
.json()
.await
.map_err(|e| format!("解析 Node.js 版本列表失败: {e}"))?;
let select_version = |only_lts: bool| -> Option<String> {
releases.iter().find_map(|release| {
let version = release.get("version")?.as_str()?;
let parsed = parse_semver(version)?;
let is_lts = release
.get("lts")
.map(|value| match value {
Value::Bool(flag) => *flag,
Value::String(text) => !text.trim().is_empty() && text != "false",
_ => false,
})
.unwrap_or(false);
if parsed >= NODE_MIN_VERSION && (!only_lts || is_lts) {
Some(version.to_string())
} else {
None
}
})
};
let version = select_version(true)
.or_else(|| select_version(false))
.ok_or_else(|| "未找到满足要求的 Node.js 官方安装包版本。".to_string())?;
#[cfg(target_os = "windows")]
let filename = {
#[cfg(target_arch = "aarch64")]
{
format!("node-{version}-arm64.msi")
}
#[cfg(not(target_arch = "aarch64"))]
{
format!("node-{version}-x64.msi")
}
};
#[cfg(target_os = "macos")]
let filename = format!("node-{version}.pkg");
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
let filename = String::new();
if filename.is_empty() {
return Err("当前平台暂不支持自动下载官方 Node.js 安装器。".to_string());
}
Ok(InstallerAsset {
download_url: format!("https://nodejs.org/dist/{version}/{filename}"),
filename,
})
}
pub(crate) async fn download_installer_asset(
app: &AppHandle,
asset: &InstallerAsset,
) -> Result<PathBuf, String> {
let download_dir = openclaw_installer_download_dir(app)?;
let installer_path = download_dir.join(&asset.filename);
if installer_path.exists() {
let _ = std::fs::remove_file(&installer_path);
}
emit_install_progress(
app,
&format!("开始下载安装器:{}", asset.download_url),
"info",
);
let client = reqwest::Client::new();
let response = client
.get(&asset.download_url)
.header("User-Agent", OPENCLAW_INSTALLER_USER_AGENT)
.send()
.await
.map_err(|e| format!("下载官方安装器失败: {e}"))?;
if !response.status().is_success() {
return Err(format!("下载安装器失败: HTTP {}", response.status()));
}
let bytes = response
.bytes()
.await
.map_err(|e| format!("读取安装器文件失败: {e}"))?;
std::fs::write(&installer_path, bytes)
.map_err(|e| format!("保存安装器失败({}): {e}", installer_path.display()))?;
emit_install_progress(
app,
&format!("安装器已保存到:{}", installer_path.display()),
"info",
);
Ok(installer_path)
}
pub(crate) fn launch_installer(file_path: &Path) -> Result<(), String> {
let extension = file_path
.extension()
.and_then(|ext| ext.to_str())
.unwrap_or_default()
.to_ascii_lowercase();
match extension.as_str() {
"exe" => {
#[cfg(target_os = "windows")]
{
std::process::Command::new(file_path)
.spawn()
.map_err(|e| format!("启动安装程序失败: {e}"))?;
}
#[cfg(not(target_os = "windows"))]
{
return Err("EXE 安装器只能在 Windows 上运行。".to_string());
}
}
"msi" => {
#[cfg(target_os = "windows")]
{
std::process::Command::new("msiexec")
.arg("/i")
.arg(file_path)
.spawn()
.map_err(|e| format!("启动 MSI 安装程序失败: {e}"))?;
}
#[cfg(not(target_os = "windows"))]
{
return Err("MSI 安装器只能在 Windows 上运行。".to_string());
}
}
"pkg" | "dmg" => {
#[cfg(target_os = "macos")]
{
std::process::Command::new("open")
.arg(file_path)
.spawn()
.map_err(|e| format!("打开 macOS 安装器失败: {e}"))?;
}
#[cfg(not(target_os = "macos"))]
{
return Err("该安装器只能在 macOS 上运行。".to_string());
}
}
_ => return Err(format!("不支持的安装器文件类型: {extension}")),
}
Ok(())
}
#[cfg(target_os = "macos")]
pub(crate) async fn trigger_macos_command_line_tools_install() -> Result<String, String> {
let output = Command::new("/usr/bin/xcode-select")
.arg("--install")
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await
.map_err(|e| format!("拉起 macOS 开发者工具安装器失败: {e}"))?;
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let combined = if !stderr.is_empty() { stderr } else { stdout };
let lower = combined.to_ascii_lowercase();
if output.status.success()
|| lower.contains("install requested")
|| lower.contains("already been requested")
{
return Ok("已拉起 macOS 开发者工具安装器。".to_string());
}
if lower.contains("already installed") {
return Err(
"系统提示 Command Line Tools 已安装,但当前仍未检测到 Git,请先执行系统更新或安装 Homebrew 后重试。"
.to_string(),
);
}
Err(format!("拉起 macOS 开发者工具安装器失败: {combined}"))
}
#[cfg(not(target_os = "macos"))]
#[allow(dead_code)]
pub(crate) async fn trigger_macos_command_line_tools_install() -> Result<String, String> {
Err("当前平台不支持拉起 macOS 开发者工具安装器。".to_string())
}
@@ -1,155 +0,0 @@
use crate::app::AppState;
use crate::database::dao::api_key_provider::{ApiKeyProvider, ApiProviderType};
use dirs::{data_dir, home_dir};
use lime_core::openclaw_install::{
build_openclaw_cleanup_command as core_build_openclaw_cleanup_command,
build_openclaw_install_command as core_build_openclaw_install_command,
build_winget_install_command as core_build_winget_install_command,
command_bin_dir_for as core_command_bin_dir_for,
resolve_windows_dependency_install_plan as core_resolve_windows_dependency_install_plan,
select_best_semver_candidate as core_select_best_semver_candidate,
select_preferred_path_candidate as core_select_preferred_path_candidate,
shell_command_escape_for as core_shell_command_escape_for,
shell_command_invocation_prefix_for as core_shell_command_invocation_prefix_for,
shell_npm_prefix_assignment_for as core_shell_npm_prefix_assignment_for,
shell_path_assignment_for as core_shell_path_assignment_for,
windows_manual_install_message as core_windows_manual_install_message,
OpenClawInstallDependencyKind, ShellPlatform, WindowsDependencyInstallPlan,
};
use rand::{distributions::Alphanumeric, Rng};
use regex::Regex;
use serde::{Deserialize, Serialize};
use serde_json::{json, Map, Value};
use std::cmp::Ordering;
use std::collections::{HashSet, VecDeque};
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::{Arc, Mutex as StdMutex, OnceLock};
use std::time::SystemTime;
use sysinfo::{Pid, Signal, System};
use tauri::{AppHandle, Emitter, Manager};
use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncReadExt, BufReader};
use tokio::net::TcpStream;
use tokio::process::{Child, Command};
use tokio::sync::Mutex;
use tokio::time::{sleep, timeout, Duration};
#[cfg(target_os = "windows")]
use winapi::shared::minwindef::{DWORD, HKEY};
#[cfg(target_os = "windows")]
use winapi::shared::winerror::ERROR_SUCCESS;
#[cfg(target_os = "windows")]
use winapi::um::winreg::{RegOpenKeyExW, RegQueryValueExW, HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE};
const DEFAULT_GATEWAY_PORT: u16 = 18790;
const OPENCLAW_INSTALL_EVENT: &str = "openclaw:install-progress";
const OPENCLAW_CONFIG_ENV: &str = "OPENCLAW_CONFIG_PATH";
fn shell_escape(value: &str) -> String {
format!("'{}'", value.replace('\'', "'\"'\"'"))
}
const OPENCLAW_CN_PACKAGE: &str = "@qingchencloud/openclaw-zh@latest";
const OPENCLAW_DEFAULT_PACKAGE: &str = "openclaw@latest";
const NPM_MIRROR_CN: &str = "https://registry.npmmirror.com";
const NODE_MIN_VERSION: (u64, u64, u64) = (22, 12, 0);
const OPENCLAW_PROGRESS_LOG_LIMIT: usize = 400;
const OPENCLAW_INSTALLER_USER_AGENT: &str = "Lime-OpenClaw";
#[cfg(not(target_os = "windows"))]
const OPENCLAW_TEMP_CARGO_CHECK_DIR: &str = "/tmp/lime-cargo-check";
#[cfg(target_os = "windows")]
const CREATE_NO_WINDOW: u32 = 0x08000000;
static OPENCLAW_PREFERRED_RUNTIME_BIN_DIR: OnceLock<StdMutex<Option<PathBuf>>> = OnceLock::new();
mod config;
mod diagnostics;
mod gateway;
mod install;
mod platform;
mod preview;
mod process;
mod progress;
mod runtime;
#[cfg(test)]
mod tests;
mod types;
mod update;
pub(crate) use self::config::*;
pub(crate) use self::diagnostics::*;
pub(crate) use self::gateway::*;
pub(crate) use self::install::*;
pub(crate) use self::platform::*;
pub(crate) use self::process::*;
pub(crate) use self::progress::*;
pub(crate) use self::runtime::*;
pub use self::types::{
ActionResult, BinaryAvailabilityStatus, BinaryInstallStatus, ChannelInfo, CommandPreview,
DependencyStatus, EnvironmentDiagnostics, EnvironmentStatus, GatewayStatus, GatewayStatusInfo,
HealthInfo, InstallProgressEvent, NodeCheckResult, OpenClawRuntimeCandidate, SyncModelEntry,
UpdateInfo,
};
pub(crate) use self::types::{
DependencyKind, InstallerAsset, OpenClawDirectUpgradePlan, OpenClawDirectUpgradeResult,
OpenClawUpdateExecutionContext, ResolvedOpenClawCommand,
};
#[cfg(test)]
pub(crate) use self::update::*;
#[derive(Debug)]
pub struct OpenClawService {
gateway_process: Option<Child>,
gateway_status: GatewayStatus,
gateway_port: u16,
gateway_auth_token: String,
gateway_started_at: Option<SystemTime>,
progress_logs: VecDeque<InstallProgressEvent>,
}
impl Default for OpenClawService {
fn default() -> Self {
Self {
gateway_process: None,
gateway_status: GatewayStatus::Stopped,
gateway_port: DEFAULT_GATEWAY_PORT,
gateway_auth_token: String::new(),
gateway_started_at: None,
progress_logs: VecDeque::new(),
}
}
}
pub struct OpenClawServiceState(pub std::sync::Arc<Mutex<OpenClawService>>);
impl Default for OpenClawServiceState {
fn default() -> Self {
Self(std::sync::Arc::new(Mutex::new(OpenClawService::default())))
}
}
impl OpenClawService {
pub fn clear_progress_logs(&mut self) {
self.progress_logs.clear();
}
pub fn get_progress_logs(&self) -> Vec<InstallProgressEvent> {
self.progress_logs.iter().cloned().collect()
}
fn clear_gateway_runtime_state(&mut self) {
self.gateway_process = None;
self.gateway_started_at = None;
self.gateway_status = GatewayStatus::Stopped;
}
fn push_progress_log(&mut self, message: String, level: String) {
if self.progress_logs.len() >= OPENCLAW_PROGRESS_LOG_LIMIT {
self.progress_logs.pop_front();
}
self.progress_logs
.push_back(InstallProgressEvent { message, level });
}
}
pub fn openclaw_install_event_name() -> &'static str {
OPENCLAW_INSTALL_EVENT
}
@@ -1,292 +0,0 @@
use super::*;
pub(crate) fn generate_auth_token() -> String {
rand::thread_rng()
.sample_iter(&Alphanumeric)
.take(48)
.map(char::from)
.collect()
}
pub(crate) async fn should_use_china_package(app: &AppHandle) -> bool {
if let Some(app_state) = app.try_state::<AppState>() {
let language = {
let state = app_state.read().await;
state.config.language.clone()
};
if language.starts_with("zh") {
return true;
}
}
let locale = std::env::var("LC_ALL")
.ok()
.or_else(|| std::env::var("LANG").ok())
.unwrap_or_default()
.to_lowercase();
let timezone = std::env::var("TZ").unwrap_or_default().to_lowercase();
locale.contains("zh_cn") || locale.contains("zh-hans") || timezone.contains("shanghai")
}
pub(crate) async fn detect_npm_global_prefix(npm_path: &str) -> Option<String> {
let mut command = Command::new(npm_path);
apply_binary_runtime_path(&mut command, npm_path);
let output = command
.arg("config")
.arg("get")
.arg("prefix")
.stdout(Stdio::piped())
.stderr(Stdio::null())
.output()
.await
.ok()?;
if !output.status.success() {
return None;
}
let prefix = String::from_utf8_lossy(&output.stdout).trim().to_string();
if prefix.is_empty() || prefix.eq_ignore_ascii_case("undefined") {
None
} else {
Some(prefix)
}
}
pub(crate) fn current_shell_platform() -> ShellPlatform {
if cfg!(target_os = "windows") {
ShellPlatform::Windows
} else {
ShellPlatform::Unix
}
}
#[allow(dead_code)]
pub(crate) fn command_bin_dir_for(platform: ShellPlatform, binary_path: &str) -> Option<String> {
core_command_bin_dir_for(platform, binary_path)
}
pub(crate) fn shell_command_escape_for(platform: ShellPlatform, value: &str) -> String {
core_shell_command_escape_for(platform, value)
}
pub(crate) fn shell_command_invocation_prefix_for(
platform: ShellPlatform,
binary_path: &str,
) -> String {
core_shell_command_invocation_prefix_for(platform, binary_path)
}
#[cfg_attr(target_os = "windows", allow(dead_code))]
pub(crate) fn shell_command_escape(value: &str) -> String {
shell_command_escape_for(current_shell_platform(), value)
}
#[allow(dead_code)]
pub(crate) fn shell_npm_prefix_assignment_for(platform: ShellPlatform, value: &str) -> String {
core_shell_npm_prefix_assignment_for(platform, value)
}
pub(crate) fn shell_path_assignment_for(platform: ShellPlatform, binary_path: &str) -> String {
core_shell_path_assignment_for(platform, binary_path)
}
#[cfg_attr(target_os = "windows", allow(dead_code))]
pub(crate) fn shell_path_assignment(binary_path: &str) -> String {
shell_path_assignment_for(current_shell_platform(), binary_path)
}
pub(crate) fn build_openclaw_cleanup_command(
platform: ShellPlatform,
npm_path: &str,
npm_prefix: Option<&str>,
) -> String {
core_build_openclaw_cleanup_command(platform, npm_path, npm_prefix)
}
pub(crate) fn build_openclaw_install_command(
platform: ShellPlatform,
npm_path: &str,
npm_prefix: Option<&str>,
package: &str,
registry: Option<&str>,
) -> String {
core_build_openclaw_install_command(platform, npm_path, npm_prefix, package, registry)
}
pub(crate) fn build_openclaw_pnpm_install_command(
platform: ShellPlatform,
pnpm_path: &str,
package: &str,
registry: Option<&str>,
) -> String {
let mut command = format!(
"{}{}{} add -g {}",
shell_path_assignment_for(platform, pnpm_path),
shell_command_invocation_prefix_for(platform, pnpm_path),
shell_command_escape_for(platform, pnpm_path),
shell_command_escape_for(platform, package),
);
if let Some(registry) = registry {
command.push(' ');
command.push_str("--registry=");
command.push_str(&shell_command_escape_for(platform, registry));
}
command
}
#[allow(dead_code)]
pub(crate) fn resolve_windows_dependency_install_plan(
dependency: DependencyKind,
has_winget: bool,
) -> WindowsDependencyInstallPlan {
core_resolve_windows_dependency_install_plan(
match dependency {
DependencyKind::Node => OpenClawInstallDependencyKind::Node,
DependencyKind::Git => OpenClawInstallDependencyKind::Git,
},
has_winget,
)
}
#[allow(dead_code)]
pub(crate) fn build_winget_install_command(winget_path: &str, package_id: &str) -> String {
core_build_winget_install_command(winget_path, package_id)
}
#[allow(dead_code)]
pub(crate) fn windows_manual_install_message(dependency: DependencyKind) -> &'static str {
core_windows_manual_install_message(match dependency {
DependencyKind::Node => OpenClawInstallDependencyKind::Node,
DependencyKind::Git => OpenClawInstallDependencyKind::Git,
})
}
pub(crate) fn prepend_path(dir: &Path) -> Option<OsString> {
let mut paths = vec![dir.to_path_buf()];
if let Some(current) = std::env::var_os("PATH") {
paths.extend(std::env::split_paths(&current));
}
std::env::join_paths(paths).ok()
}
pub(crate) fn apply_binary_runtime_path(command: &mut Command, binary_path: &str) {
apply_windows_no_window(command);
let Some(bin_dir) = Path::new(binary_path).parent() else {
return;
};
if let Some(path) = prepend_path(bin_dir) {
command.env("PATH", path);
}
}
pub(crate) fn apply_windows_no_window(_command: &mut Command) {
#[cfg(target_os = "windows")]
{
_command.creation_flags(CREATE_NO_WINDOW);
}
}
#[cfg(target_os = "windows")]
pub(crate) fn refresh_windows_path_from_registry() -> Result<(), String> {
unsafe {
let mut combined_path = String::new();
// 读取系统 PATH (HKEY_LOCAL_MACHINE)
if let Ok(system_path) = read_registry_path(HKEY_LOCAL_MACHINE) {
combined_path.push_str(&system_path);
}
// 读取用户 PATH (HKEY_CURRENT_USER)
if let Ok(user_path) = read_registry_path(HKEY_CURRENT_USER) {
if !combined_path.is_empty() {
combined_path.push(';');
}
combined_path.push_str(&user_path);
}
if !combined_path.is_empty() {
std::env::set_var("PATH", combined_path);
}
}
Ok(())
}
#[cfg(target_os = "windows")]
pub(crate) unsafe fn read_registry_path(root_key: HKEY) -> Result<String, String> {
use std::ffi::OsStr;
use std::os::windows::ffi::OsStrExt;
use std::ptr;
let subkey: Vec<u16> = OsStr::new("Environment")
.encode_wide()
.chain(std::iter::once(0))
.collect();
let value_name: Vec<u16> = OsStr::new("Path")
.encode_wide()
.chain(std::iter::once(0))
.collect();
let mut key: HKEY = ptr::null_mut();
let result = RegOpenKeyExW(
root_key,
subkey.as_ptr(),
0,
winapi::um::winnt::KEY_READ,
&mut key,
);
if result != ERROR_SUCCESS as i32 {
return Err(format!("无法打开注册表键: {}", result));
}
let mut buffer_size: DWORD = 0;
let result = RegQueryValueExW(
key,
value_name.as_ptr(),
ptr::null_mut(),
ptr::null_mut(),
ptr::null_mut(),
&mut buffer_size,
);
if result != ERROR_SUCCESS as i32 {
winapi::um::winreg::RegCloseKey(key);
return Err(format!("无法查询注册表值大小: {}", result));
}
let mut buffer: Vec<u16> = vec![0; (buffer_size / 2) as usize + 1];
let result = RegQueryValueExW(
key,
value_name.as_ptr(),
ptr::null_mut(),
ptr::null_mut(),
buffer.as_mut_ptr() as *mut u8,
&mut buffer_size,
);
winapi::um::winreg::RegCloseKey(key);
if result != ERROR_SUCCESS as i32 {
return Err(format!("无法读取注册表值: {}", result));
}
// 移除尾部的 null 字符
if let Some(null_pos) = buffer.iter().position(|&c| c == 0) {
buffer.truncate(null_pos);
}
Ok(String::from_utf16_lossy(&buffer))
}
#[cfg(not(target_os = "windows"))]
#[allow(dead_code)]
pub(crate) fn refresh_windows_path_from_registry() -> Result<(), String> {
Ok(())
}
@@ -1,117 +0,0 @@
use super::*;
impl OpenClawService {
pub async fn get_command_preview(
&mut self,
app: &AppHandle,
operation: &str,
port: Option<u16>,
) -> Result<CommandPreview, String> {
match operation {
"install" => self.build_install_command_preview(app).await,
"uninstall" => self.build_uninstall_command_preview().await,
"restart" => self.build_restart_command_preview(port).await,
"start" => self.build_start_command_preview(port).await,
"stop" => self.build_stop_command_preview(port).await,
_ => Err(format!("不支持的 OpenClaw 操作预览: {operation}")),
}
}
async fn build_install_command_preview(
&self,
app: &AppHandle,
) -> Result<CommandPreview, String> {
let (package, npm_path, npm_prefix, cleanup_command, install_command) =
self.resolve_install_commands(app).await?;
let prefix_note = npm_prefix
.map(|prefix| format!("npm: {npm_path}\nprefix: {prefix}\n"))
.unwrap_or_else(|| format!("npm: {npm_path}\n"));
Ok(CommandPreview {
title: format!("安装 {package}"),
command: format!("{prefix_note}{cleanup_command}\n{install_command}"),
})
}
async fn build_uninstall_command_preview(&self) -> Result<CommandPreview, String> {
let (npm_path, npm_prefix, command) = self.resolve_uninstall_command().await?;
let prefix_note = npm_prefix
.map(|prefix| format!("npm: {npm_path}\nprefix: {prefix}\n"))
.unwrap_or_else(|| format!("npm: {npm_path}\n"));
Ok(CommandPreview {
title: "卸载 OpenClaw".to_string(),
command: format!("{prefix_note}{command}"),
})
}
async fn build_start_command_preview(
&mut self,
port: Option<u16>,
) -> Result<CommandPreview, String> {
if let Some(next_port) = port {
self.gateway_port = next_port.max(1);
}
self.restore_auth_token_from_config();
if self.gateway_auth_token.is_empty() {
self.gateway_auth_token = generate_auth_token();
}
let openclaw_command = resolve_openclaw_command()
.await?
.ok_or_else(|| "未检测到 OpenClaw 可执行文件,请先安装。".to_string())?;
let config_path = openclaw_lime_config_path();
let command = gateway_start_args(self.gateway_port, &self.gateway_auth_token)
.into_iter()
.map(|arg| shell_escape(&arg))
.collect::<Vec<_>>()
.join(" ");
Ok(CommandPreview {
title: "启动 Gateway".to_string(),
command: format!(
"{}OPENCLAW_CONFIG_PATH={} {} {}",
if cfg!(target_os = "windows") {
"set "
} else {
""
},
shell_escape(config_path.to_string_lossy().as_ref()),
openclaw_command.preview_invocation(),
command
),
})
}
async fn build_stop_command_preview(
&mut self,
port: Option<u16>,
) -> Result<CommandPreview, String> {
if let Some(next_port) = port {
self.gateway_port = next_port.max(1);
}
self.restore_auth_token_from_config();
let openclaw_command = resolve_openclaw_command()
.await?
.ok_or_else(|| "未检测到 OpenClaw 可执行文件,请先安装。".to_string())?;
let config_path = openclaw_lime_config_path();
Ok(CommandPreview {
title: "停止 Gateway".to_string(),
command: format!(
"OPENCLAW_CONFIG_PATH={} {} gateway stop --url {} --token {}",
shell_escape(config_path.to_string_lossy().as_ref()),
openclaw_command.preview_invocation(),
self.gateway_ws_url(),
shell_escape(&self.gateway_auth_token)
),
})
}
async fn build_restart_command_preview(
&mut self,
port: Option<u16>,
) -> Result<CommandPreview, String> {
let stop = self.build_stop_command_preview(port).await?;
let start = self.build_start_command_preview(port).await?;
Ok(CommandPreview {
title: "重启 Gateway".to_string(),
command: format!("{}\n{}", stop.command, start.command),
})
}
}
@@ -1,196 +0,0 @@
use super::*;
pub(crate) fn command_output_lines(output: &[u8]) -> Vec<String> {
String::from_utf8_lossy(output)
.lines()
.map(sanitize_progress_line)
.filter(|line| !line.is_empty())
.collect()
}
pub(crate) fn normalize_process_probe_text(value: &str) -> String {
value.replace('\\', "/").to_ascii_lowercase()
}
pub(crate) fn process_looks_like_openclaw_process(
process_name: &str,
exe_path: Option<&Path>,
command_args: &[OsString],
) -> bool {
let process_name = normalize_process_probe_text(process_name);
let exe_path = exe_path
.map(|path| normalize_process_probe_text(&path.display().to_string()))
.unwrap_or_default();
let command_line = normalize_process_probe_text(
&command_args
.iter()
.map(|arg| arg.to_string_lossy())
.collect::<Vec<_>>()
.join(" "),
);
process_name.contains("openclaw")
|| exe_path.contains("openclaw")
|| command_line.contains("openclaw")
}
pub(crate) fn collect_openclaw_process_family_pids(
system: &System,
listener_pids: &[u32],
) -> Vec<Pid> {
let mut target_pids = HashSet::new();
for listener_pid in listener_pids {
let pid = Pid::from_u32(*listener_pid);
let Some(process) = system.process(pid) else {
continue;
};
if !process_looks_like_openclaw_process(
&process.name().to_string_lossy(),
process.exe(),
process.cmd(),
) {
continue;
}
target_pids.insert(pid);
let mut parent_pid = process.parent();
while let Some(next_parent) = parent_pid {
let Some(parent_process) = system.process(next_parent) else {
break;
};
if !process_looks_like_openclaw_process(
&parent_process.name().to_string_lossy(),
parent_process.exe(),
parent_process.cmd(),
) {
break;
}
target_pids.insert(next_parent);
parent_pid = parent_process.parent();
}
}
let mut target_pids = target_pids.into_iter().collect::<Vec<_>>();
target_pids.sort_by_key(|pid| pid.as_u32());
target_pids
}
pub(crate) async fn terminate_sysinfo_processes(system: &mut System, target_pids: &[Pid]) {
for pid in target_pids {
if let Some(process) = system.process(*pid) {
let terminated = process.kill_with(Signal::Term).unwrap_or(false);
if !terminated {
let _ = process.kill();
}
}
}
sleep(Duration::from_millis(900)).await;
system.refresh_all();
for pid in target_pids {
if let Some(process) = system.process(*pid) {
let _ = process.kill();
}
}
}
#[cfg_attr(target_os = "windows", allow(dead_code))]
pub(crate) fn parse_lsof_listener_pids(output: &str) -> Vec<u32> {
let mut pids = output
.lines()
.filter_map(|line| line.trim().parse::<u32>().ok())
.collect::<Vec<_>>();
pids.sort_unstable();
pids.dedup();
pids
}
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
pub(crate) fn parse_windows_netstat_listener_pids(output: &str, port: u16) -> Vec<u32> {
let mut pids = output
.lines()
.filter_map(|line| {
let columns = line.split_whitespace().collect::<Vec<_>>();
if columns.len() < 5 {
return None;
}
let local_address = columns.get(1).copied().unwrap_or_default();
let state = columns.get(3).copied().unwrap_or_default();
if !state.eq_ignore_ascii_case("LISTENING")
|| !local_address.ends_with(&format!(":{port}"))
{
return None;
}
columns.last().and_then(|value| value.parse::<u32>().ok())
})
.collect::<Vec<_>>();
pids.sort_unstable();
pids.dedup();
pids
}
pub(crate) async fn collect_listening_port_pids(port: u16) -> Vec<u32> {
#[cfg(target_os = "windows")]
{
collect_listening_port_pids_windows(port).await
}
#[cfg(not(target_os = "windows"))]
{
collect_listening_port_pids_unix(port).await
}
}
#[cfg(not(target_os = "windows"))]
pub(crate) async fn collect_listening_port_pids_unix(port: u16) -> Vec<u32> {
let mut command = Command::new("lsof");
apply_windows_no_window(&mut command);
let output = timeout(
Duration::from_secs(3),
command
.arg("-nP")
.arg(format!("-iTCP:{port}"))
.arg("-sTCP:LISTEN")
.arg("-t")
.stdout(Stdio::piped())
.stderr(Stdio::null())
.output(),
)
.await;
match output {
Ok(Ok(result)) if result.status.success() => {
parse_lsof_listener_pids(&String::from_utf8_lossy(&result.stdout))
}
_ => Vec::new(),
}
}
#[cfg(target_os = "windows")]
pub(crate) async fn collect_listening_port_pids_windows(port: u16) -> Vec<u32> {
let mut command = Command::new("netstat");
apply_windows_no_window(&mut command);
let output = timeout(
Duration::from_secs(3),
command
.arg("-ano")
.arg("-p")
.arg("tcp")
.stdout(Stdio::piped())
.stderr(Stdio::null())
.output(),
)
.await;
match output {
Ok(Ok(result)) if result.status.success() => {
parse_windows_netstat_listener_pids(&String::from_utf8_lossy(&result.stdout), port)
}
_ => Vec::new(),
}
}
@@ -1,214 +0,0 @@
use super::*;
pub(crate) async fn run_shell_command_with_progress(
app: &AppHandle,
command_line: &str,
) -> Result<ActionResult, String> {
let mut child = spawn_shell_command(command_line)?;
let stdout_task = child.stdout.take().map(|stdout| {
let app = app.clone();
tokio::spawn(async move {
stream_reader_to_progress(app, stdout, "info").await;
})
});
let stderr_task = child.stderr.take().map(|stderr| {
let app = app.clone();
tokio::spawn(async move {
stream_reader_to_progress(app, stderr, "error").await;
})
});
let status = child
.wait()
.await
.map_err(|e| format!("执行命令失败: {e}"))?;
if let Some(task) = stdout_task {
let _ = task.await;
}
if let Some(task) = stderr_task {
let _ = task.await;
}
if status.success() {
emit_install_progress(app, "命令执行成功。", "info");
Ok(ActionResult {
success: true,
message: "操作成功完成。".to_string(),
})
} else {
emit_install_progress(
app,
&format!("命令执行失败,退出码: {:?}", status.code()),
"error",
);
Ok(ActionResult {
success: false,
message: format!("命令执行失败,退出码: {:?}", status.code()),
})
}
}
pub(crate) fn spawn_shell_command(command_line: &str) -> Result<Child, String> {
let mut command = if cfg!(target_os = "windows") {
let mut cmd = Command::new("cmd");
cmd.arg("/C").arg(command_line);
cmd
} else if cfg!(target_os = "macos") {
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string());
let mut cmd = Command::new("script");
cmd.arg("-q")
.arg("/dev/null")
.arg(shell)
.arg("-lc")
.arg(command_line);
cmd
} else {
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string());
let mut cmd = Command::new(shell);
cmd.arg("-lc").arg(command_line);
cmd
};
apply_windows_no_window(&mut command);
command
.env("NO_COLOR", "1")
.env("CLICOLOR", "0")
.env("FORCE_COLOR", "0")
.env("npm_config_color", "false")
.stdout(Stdio::piped())
.stderr(Stdio::piped());
command.spawn().map_err(|e| format!("启动命令失败: {e}"))
}
pub(crate) async fn stream_reader_to_progress<R>(
app: AppHandle,
mut reader: R,
default_level: &'static str,
) where
R: AsyncRead + Unpin,
{
let mut buffer = [0_u8; 2048];
let mut pending = String::new();
loop {
match reader.read(&mut buffer).await {
Ok(0) => break,
Ok(size) => {
pending.push_str(&String::from_utf8_lossy(&buffer[..size]));
flush_progress_chunks(&app, &mut pending, default_level);
}
Err(error) => {
emit_install_progress(&app, &format!("读取命令输出失败: {error}"), "warn");
break;
}
}
}
let tail = pending.trim();
if !tail.is_empty() {
emit_install_progress(&app, tail, classify_progress_level(tail, default_level));
}
}
pub(crate) fn flush_progress_chunks(
app: &AppHandle,
pending: &mut String,
default_level: &'static str,
) {
loop {
let next_break = pending.find(['\n', '\r']);
let Some(index) = next_break else {
break;
};
let mut line = pending[..index].trim().to_string();
let mut consume_len = index + 1;
while pending
.get(consume_len..consume_len + 1)
.is_some_and(|ch| ch == "\n" || ch == "\r")
{
consume_len += 1;
}
pending.drain(..consume_len);
if line.is_empty() {
continue;
}
line = sanitize_progress_line(&line);
if line.is_empty() {
continue;
}
emit_install_progress(app, &line, classify_progress_level(&line, default_level));
}
if pending.len() > 4096 {
let line = sanitize_progress_line(pending.trim());
if !line.is_empty() {
emit_install_progress(app, &line, classify_progress_level(&line, default_level));
}
pending.clear();
}
}
pub(crate) fn sanitize_progress_line(value: &str) -> String {
value
.replace('\u{1b}', "")
.replace("[?25h", "")
.replace("[?25l", "")
.trim()
.to_string()
}
pub(crate) fn classify_progress_level(message: &str, default_level: &'static str) -> &'static str {
let lower = message.to_ascii_lowercase();
if lower.contains("error") || lower.contains("fatal") {
"error"
} else if lower.contains("warn") || lower.contains("warning") {
"warn"
} else {
default_level
}
}
pub(crate) fn emit_install_progress(app: &AppHandle, message: &str, level: &str) {
if let Some(service_state) = app.try_state::<OpenClawServiceState>() {
if let Ok(mut service) = service_state.0.try_lock() {
service.push_progress_log(message.to_string(), level.to_string());
}
}
let payload = InstallProgressEvent {
message: message.to_string(),
level: level.to_string(),
};
let _ = app.emit(OPENCLAW_INSTALL_EVENT, payload);
}
pub(crate) fn parse_semver(value: &str) -> Option<(u64, u64, u64)> {
let sanitized = value.trim().trim_start_matches('v');
let core = sanitized.split(['-', '+']).next()?;
let mut parts = core.split('.');
let major = parts.next()?.parse().ok()?;
let minor = parts.next().unwrap_or("0").parse().ok()?;
let patch = parts.next().unwrap_or("0").parse().ok()?;
Some((major, minor, patch))
}
pub(crate) fn parse_semver_from_text(value: &str) -> Option<(u64, u64, u64)> {
parse_semver(value).or_else(|| {
value
.split(|ch: char| ch.is_whitespace() || ch == ',' || ch == '(' || ch == ')')
.find_map(parse_semver)
})
}
pub(crate) fn format_semver(version: (u64, u64, u64)) -> String {
format!("{}.{}.{}", version.0, version.1, version.2)
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,299 +0,0 @@
use super::*;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BinaryInstallStatus {
pub installed: bool,
pub path: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BinaryAvailabilityStatus {
pub available: bool,
pub path: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NodeCheckResult {
pub status: String,
pub version: Option<String>,
pub path: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ActionResult {
pub success: bool,
pub message: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DependencyStatus {
pub status: String,
pub version: Option<String>,
pub path: Option<String>,
pub message: String,
pub auto_install_supported: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EnvironmentStatus {
pub node: DependencyStatus,
pub git: DependencyStatus,
pub openclaw: DependencyStatus,
pub recommended_action: String,
pub summary: String,
#[serde(default)]
pub diagnostics: EnvironmentDiagnostics,
#[serde(default)]
pub temp_artifacts: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct EnvironmentDiagnostics {
pub npm_path: Option<String>,
pub npm_global_prefix: Option<String>,
pub openclaw_package_path: Option<String>,
#[serde(default)]
pub where_candidates: Vec<String>,
#[serde(default)]
pub supplemental_search_dirs: Vec<String>,
#[serde(default)]
pub supplemental_command_candidates: Vec<String>,
#[serde(default)]
pub git_where_candidates: Vec<String>,
#[serde(default)]
pub git_supplemental_search_dirs: Vec<String>,
#[serde(default)]
pub git_supplemental_command_candidates: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CommandPreview {
pub title: String,
pub command: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GatewayStatusInfo {
pub status: GatewayStatus,
pub port: u16,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum GatewayStatus {
Stopped,
Starting,
Running,
Error,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HealthInfo {
pub status: String,
pub gateway_port: u16,
pub uptime: Option<u64>,
pub version: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateInfo {
pub has_update: bool,
pub current_version: Option<String>,
pub latest_version: Option<String>,
pub channel: Option<String>,
pub install_kind: Option<String>,
pub package_manager: Option<String>,
pub message: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OpenClawRuntimeCandidate {
pub id: String,
pub source: String,
pub bin_dir: String,
pub node_path: String,
pub node_version: Option<String>,
pub npm_path: Option<String>,
pub npm_global_prefix: Option<String>,
pub openclaw_path: Option<String>,
pub openclaw_version: Option<String>,
pub openclaw_package_path: Option<String>,
pub is_active: bool,
pub is_preferred: bool,
}
#[derive(Debug, Default, Clone)]
pub(crate) struct OpenClawUpdateExecutionContext {
pub(crate) root: Option<PathBuf>,
pub(crate) install_kind: Option<String>,
pub(crate) package_manager: Option<String>,
}
#[derive(Debug, Clone)]
pub(crate) struct OpenClawDirectUpgradePlan {
pub(crate) runtime_source: String,
pub(crate) runtime_bin_dir: PathBuf,
pub(crate) package_manager: String,
pub(crate) package_spec: String,
pub(crate) command_line: String,
}
#[derive(Debug, Clone)]
pub(crate) struct OpenClawDirectUpgradeResult {
pub(crate) runtime_source: String,
pub(crate) runtime_bin_dir: PathBuf,
pub(crate) package_manager: String,
pub(crate) package_spec: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum ResolvedOpenClawCommand {
Binary {
binary_path: PathBuf,
},
NodeCli {
node_path: PathBuf,
cli_path: PathBuf,
package_version: Option<String>,
},
}
impl ResolvedOpenClawCommand {
pub(crate) fn build_command_with_args<I, S>(&self, args: I) -> Command
where
I: IntoIterator<Item = S>,
S: AsRef<std::ffi::OsStr>,
{
let command_path = self.command_path();
let command_path_string = command_path.to_string_lossy().to_string();
let mut command = Command::new(command_path);
apply_binary_runtime_path(&mut command, &command_path_string);
if let Self::NodeCli { cli_path, .. } = self {
command.arg(cli_path);
}
command.args(args);
command
}
pub(crate) fn command_path(&self) -> &Path {
match self {
Self::Binary { binary_path } => binary_path.as_path(),
Self::NodeCli { node_path, .. } => node_path.as_path(),
}
}
pub(crate) fn install_path_display(&self) -> String {
match self {
Self::Binary { binary_path } => binary_path.display().to_string(),
Self::NodeCli { cli_path, .. } => cli_path.display().to_string(),
}
}
pub(crate) fn invocation_display(&self) -> String {
match self {
Self::Binary { binary_path } => binary_path.display().to_string(),
Self::NodeCli {
node_path,
cli_path,
..
} => {
format!("{} {}", node_path.display(), cli_path.display())
}
}
}
pub(crate) fn preview_invocation(&self) -> String {
match self {
Self::Binary { binary_path } => shell_escape(binary_path.to_string_lossy().as_ref()),
Self::NodeCli {
node_path,
cli_path,
..
} => format!(
"{} {}",
shell_escape(node_path.to_string_lossy().as_ref()),
shell_escape(cli_path.to_string_lossy().as_ref())
),
}
}
pub(crate) fn fallback_version(&self) -> Option<String> {
match self {
Self::Binary { .. } => None,
Self::NodeCli {
package_version, ..
} => package_version.clone(),
}
}
pub(crate) fn dedupe_key(&self) -> String {
match self {
Self::Binary { binary_path } => format!("binary:{}", binary_path.display()),
Self::NodeCli {
node_path,
cli_path,
..
} => format!("node:{}:{}", node_path.display(), cli_path.display()),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChannelInfo {
pub id: String,
pub name: String,
pub channel_type: String,
pub status: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct InstallProgressEvent {
pub message: String,
pub level: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SyncModelEntry {
pub id: String,
pub name: String,
pub context_window: Option<u64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum DependencyKind {
Node,
Git,
}
impl DependencyKind {
pub(crate) fn label(self) -> &'static str {
match self {
Self::Node => "Node.js",
Self::Git => "Git",
}
}
}
#[derive(Debug, Clone)]
pub(crate) struct InstallerAsset {
pub(crate) filename: String,
pub(crate) download_url: String,
}
@@ -1,747 +0,0 @@
use super::*;
impl OpenClawService {
pub async fn check_update(&self) -> Result<UpdateInfo, String> {
let Some(openclaw_command) = resolve_openclaw_command().await? else {
return Ok(UpdateInfo {
has_update: false,
current_version: None,
latest_version: None,
channel: None,
install_kind: None,
package_manager: None,
message: Some("未检测到 OpenClaw 可执行文件,请先安装。".to_string()),
});
};
let current_version = self
.read_openclaw_version()
.await?
.and_then(|value| parse_openclaw_release_version(&value).or(Some(value)));
let payload = match read_openclaw_update_status_payload(&openclaw_command).await {
Ok(payload) => payload,
Err(message) => {
return Ok(UpdateInfo {
has_update: false,
current_version,
latest_version: None,
channel: None,
install_kind: None,
package_manager: None,
message: Some(message),
});
}
};
Ok(UpdateInfo {
has_update: payload
.pointer("/availability/available")
.and_then(Value::as_bool)
.unwrap_or(false),
current_version,
latest_version: payload
.pointer("/availability/latestVersion")
.and_then(Value::as_str)
.map(str::to_string),
channel: payload
.pointer("/channel/label")
.or_else(|| payload.pointer("/channel/value"))
.and_then(Value::as_str)
.map(str::to_string),
install_kind: payload
.pointer("/update/installKind")
.and_then(Value::as_str)
.map(str::to_string),
package_manager: payload
.pointer("/update/packageManager")
.and_then(Value::as_str)
.map(str::to_string),
message: payload
.pointer("/update/registry/error")
.and_then(Value::as_str)
.map(str::to_string),
})
}
pub async fn perform_update(&mut self, app: &AppHandle) -> Result<ActionResult, String> {
emit_install_progress(app, "开始执行 OpenClaw 升级。", "info");
let Some(openclaw_command) = resolve_openclaw_command().await? else {
return Ok(ActionResult {
success: false,
message: "未检测到 OpenClaw 可执行文件,请先安装。".to_string(),
});
};
let current_runtime_bin_dir = openclaw_command
.command_path()
.parent()
.map(Path::to_path_buf);
self.refresh_process_state().await?;
let gateway_was_running = self.gateway_status == GatewayStatus::Running;
if self.gateway_status == GatewayStatus::Running {
emit_install_progress(
app,
"升级前先停止 Gateway,避免占用正在运行的 OpenClaw。",
"info",
);
let stop_result = self.stop_gateway(Some(app)).await?;
if !stop_result.success {
return Ok(stop_result);
}
}
if let Some(current_version) = self
.read_openclaw_version()
.await
.ok()
.flatten()
.and_then(|value| parse_openclaw_release_version(&value).or(Some(value)))
{
emit_install_progress(
app,
&format!("当前版本 {current_version},开始执行升级命令。"),
"info",
);
}
let update_status_payload =
match read_openclaw_update_status_payload(&openclaw_command).await {
Ok(payload) => payload,
Err(message) => {
emit_install_progress(app, &message, "warn");
match attempt_direct_openclaw_package_upgrade(
app,
current_runtime_bin_dir.as_deref(),
None,
None,
)
.await
{
Ok(result) => {
set_preferred_runtime_bin_dir(Some(result.runtime_bin_dir.clone()));
emit_install_progress(
app,
&format!(
"已自动切换后续执行环境到 {}。",
result.runtime_bin_dir.display()
),
"info",
);
return self
.finalize_successful_openclaw_update(
app,
gateway_was_running,
Some(format!(
"OpenClaw 已通过 {} 的 {} 全局升级完成({})。",
result.runtime_source,
result.package_manager,
result.package_spec
)),
)
.await;
}
Err(fallback_error) => {
emit_install_progress(app, &fallback_error, "error");
return Ok(ActionResult {
success: false,
message,
});
}
}
}
};
let update_context = extract_openclaw_update_execution_context(&update_status_payload);
if let Some(root) = update_context.root.as_ref().filter(|root| root.is_dir()) {
emit_install_progress(
app,
&format!("已切换到 OpenClaw 安装根目录执行升级:{}", root.display()),
"info",
);
}
if let Some(install_kind) = update_context.install_kind.as_deref() {
let package_manager = update_context
.package_manager
.as_deref()
.unwrap_or("默认包管理器");
emit_install_progress(
app,
&format!("检测到安装方式:{install_kind}(包管理器:{package_manager})。"),
"info",
);
}
let mut command = openclaw_command.build_command_with_args(["update", "--yes", "--json"]);
if let Some(root) = update_context.root.as_ref().filter(|root| root.is_dir()) {
command.current_dir(root);
}
let output = command
.env(OPENCLAW_CONFIG_ENV, openclaw_lime_config_path())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await
.map_err(|e| format!("执行 OpenClaw 升级失败: {e}"))?;
let stdout_lines = command_output_lines(&output.stdout);
for line in &stdout_lines {
emit_install_progress(app, &line, classify_progress_level(&line, "info"));
}
let stderr_lines = command_output_lines(&output.stderr);
for line in &stderr_lines {
emit_install_progress(app, line, classify_progress_level(line, "warn"));
}
let stdout_payload = serde_json::from_slice::<Value>(&output.stdout).ok();
if !output.status.success() {
let failure_detail = select_openclaw_update_failure_detail(
stdout_payload.as_ref(),
&stderr_lines,
&stdout_lines,
);
let message = format_openclaw_update_failure_message(failure_detail.as_deref());
emit_install_progress(app, &message, "warn");
match attempt_direct_openclaw_package_upgrade(
app,
current_runtime_bin_dir.as_deref(),
update_context.root.as_deref(),
update_context.package_manager.as_deref(),
)
.await
{
Ok(result) => {
set_preferred_runtime_bin_dir(Some(result.runtime_bin_dir.clone()));
emit_install_progress(
app,
&format!(
"已自动切换后续执行环境到 {}。",
result.runtime_bin_dir.display()
),
"info",
);
return self
.finalize_successful_openclaw_update(
app,
gateway_was_running,
Some(format!(
"OpenClaw 已通过 {} 的 {} 全局升级完成({})。",
result.runtime_source, result.package_manager, result.package_spec
)),
)
.await;
}
Err(fallback_error) => {
emit_install_progress(app, &fallback_error, "error");
return Ok(ActionResult {
success: false,
message,
});
}
}
}
self.finalize_successful_openclaw_update(app, gateway_was_running, None)
.await
}
async fn finalize_successful_openclaw_update(
&mut self,
app: &AppHandle,
gateway_was_running: bool,
success_message_override: Option<String>,
) -> Result<ActionResult, String> {
self.refresh_process_state().await?;
let updated_version = self
.read_openclaw_version()
.await
.ok()
.flatten()
.and_then(|value| parse_openclaw_release_version(&value).or(Some(value)));
if gateway_was_running {
emit_install_progress(app, "升级前 Gateway 处于运行态,开始自动恢复服务。", "info");
let restart_result = self
.start_gateway(Some(app), Some(self.gateway_port))
.await?;
if !restart_result.success {
return Ok(restart_result);
}
}
let message = success_message_override.unwrap_or_else(|| {
updated_version
.as_ref()
.map(|version| format!("OpenClaw 已升级完成,当前版本 {version}。"))
.unwrap_or_else(|| "OpenClaw 已升级完成。".to_string())
});
emit_install_progress(app, &message, "info");
Ok(ActionResult {
success: true,
message,
})
}
}
pub(crate) fn parse_openclaw_release_version(value: &str) -> Option<String> {
static VERSION_RE: OnceLock<Regex> = OnceLock::new();
VERSION_RE
.get_or_init(|| Regex::new(r"(?i)openclaw\s+([0-9]+(?:\.[0-9]+)+)").expect("valid regex"))
.captures(value)
.and_then(|captures| captures.get(1).map(|value| value.as_str().to_string()))
}
pub(crate) async fn read_openclaw_update_status_payload(
command_spec: &ResolvedOpenClawCommand,
) -> Result<Value, String> {
let mut command = command_spec.build_command_with_args(["update", "status", "--json"]);
let output = command
.env(OPENCLAW_CONFIG_ENV, openclaw_lime_config_path())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await
.map_err(|e| format!("检查 OpenClaw 更新失败: {e}"))?;
let stdout_lines = command_output_lines(&output.stdout);
let stderr_lines = command_output_lines(&output.stderr);
let payload = serde_json::from_slice::<Value>(&output.stdout).map_err(|error| {
let detail = select_openclaw_update_failure_detail(None, &stderr_lines, &stdout_lines)
.unwrap_or_else(|| format!("解析更新状态失败: {error}"));
format_openclaw_update_failure_message(Some(detail.as_str()))
})?;
if !output.status.success() {
let detail =
select_openclaw_update_failure_detail(Some(&payload), &stderr_lines, &stdout_lines);
return Err(format_openclaw_update_failure_message(detail.as_deref()));
}
Ok(payload)
}
pub(crate) fn extract_openclaw_update_execution_context(
payload: &Value,
) -> OpenClawUpdateExecutionContext {
OpenClawUpdateExecutionContext {
root: payload
.pointer("/update/root")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(PathBuf::from),
install_kind: payload
.pointer("/update/installKind")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string),
package_manager: payload
.pointer("/update/packageManager")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string),
}
}
pub(crate) fn select_openclaw_update_failure_detail(
payload: Option<&Value>,
stderr_lines: &[String],
stdout_lines: &[String],
) -> Option<String> {
if let Some(payload) = payload {
if let Some(reason) = payload.get("reason").and_then(Value::as_str) {
let root_suffix = payload
.get("root")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|root| format!(" ({root})"))
.unwrap_or_default();
return Some(format!("{reason}{root_suffix}"));
}
if let Some(message) = payload.get("message").and_then(Value::as_str) {
let trimmed = message.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_string());
}
}
if let Some(message) = payload
.pointer("/update/registry/error")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
return Some(message.to_string());
}
}
stderr_lines
.iter()
.chain(stdout_lines.iter())
.filter_map(|line| {
let trimmed = line.trim();
let score = openclaw_update_failure_line_score(trimmed);
(score > 0).then_some((score, trimmed))
})
.max_by_key(|(score, _)| *score)
.map(|(_, line)| line.to_string())
}
pub(crate) fn openclaw_update_failure_line_score(line: &str) -> u8 {
if line.is_empty() {
return 0;
}
let normalized = line.to_ascii_lowercase();
if normalized.starts_with("updating openclaw")
|| normalized.starts_with("update result")
|| normalized.starts_with("total time")
|| normalized.starts_with("root:")
{
return 0;
}
if normalized.contains("not-openclaw-root") {
return 100;
}
if normalized.contains("node.js") && normalized.contains("required") {
return 95;
}
if normalized.contains("fetch failed") || normalized.contains("network") {
return 90;
}
if normalized.contains("pnpm") && normalized.contains("not found")
|| normalized.contains("npm") && normalized.contains("not found")
{
return 85;
}
if normalized.contains("already up to date") || normalized.contains("not modified") {
return 80;
}
if normalized.starts_with("reason:") {
return 70;
}
20
}
pub(crate) fn format_openclaw_update_failure_message(detail: Option<&str>) -> String {
let Some(detail) = detail.map(str::trim).filter(|value| !value.is_empty()) else {
return "OpenClaw 升级失败,请查看日志输出。".to_string();
};
let normalized = detail.to_ascii_lowercase();
if normalized.contains("not-openclaw-root") {
return "OpenClaw 升级失败:未在 OpenClaw 安装根目录执行更新。Lime 会优先切换到安装目录;如仍失败,请重新检测安装状态后重试。"
.to_string();
}
if normalized.contains("node.js") && normalized.contains("required") {
return format!(
"OpenClaw 升级失败:当前用于执行 openclaw 的 Node.js 版本过低,需要 {}+。请切换到满足要求的 Node.js 后重试。",
format_semver(NODE_MIN_VERSION)
);
}
if normalized.contains("fetch failed") || normalized.contains("network") {
return "OpenClaw 升级失败:当前无法访问更新源,请检查网络或代理设置后重试。".to_string();
}
if normalized.contains("not modified") || normalized.contains("already up to date") {
return "OpenClaw 当前已经是最新版本,无需升级。".to_string();
}
if normalized.contains("pnpm") && normalized.contains("not found") {
return "OpenClaw 升级失败:当前安装方式依赖 pnpm,但系统未找到 pnpm。请先修复 Node.js / pnpm 环境后重试。"
.to_string();
}
if normalized.contains("npm") && normalized.contains("not found") {
return "OpenClaw 升级失败:当前安装方式依赖 npm,但系统未找到 npm。请先修复 Node.js / npm 环境后重试。"
.to_string();
}
format!("OpenClaw 升级失败:{detail}")
}
pub(crate) async fn attempt_direct_openclaw_package_upgrade(
app: &AppHandle,
runtime_bin_dir_hint: Option<&Path>,
install_root_hint: Option<&Path>,
package_manager_hint: Option<&str>,
) -> Result<OpenClawDirectUpgradeResult, String> {
let mut runtime_candidates = list_openclaw_runtime_candidates().await?;
runtime_candidates.sort_by(|left, right| {
let left_matches_root = install_root_hint
.map(|root| runtime_candidate_matches_install_root(left, root))
.unwrap_or(false);
let right_matches_root = install_root_hint
.map(|root| runtime_candidate_matches_install_root(right, root))
.unwrap_or(false);
let left_matches_bin = runtime_bin_dir_hint
.map(|hint| Path::new(&left.bin_dir) == hint)
.unwrap_or(false);
let right_matches_bin = runtime_bin_dir_hint
.map(|hint| Path::new(&right.bin_dir) == hint)
.unwrap_or(false);
right_matches_root
.cmp(&left_matches_root)
.then_with(|| right_matches_bin.cmp(&left_matches_bin))
.then_with(|| compare_openclaw_runtime_candidates(left, right))
});
let mut last_error = None;
let mut attempted = 0usize;
if let Some(install_root_hint) = install_root_hint {
emit_install_progress(
app,
&format!(
"官方 updater 报告的安装根目录为 {},将优先匹配该安装来源执行兜底升级。",
install_root_hint.display()
),
"info",
);
}
for candidate in runtime_candidates {
let Some(plan) = resolve_direct_openclaw_upgrade_plan(
app,
&candidate,
runtime_bin_dir_hint,
install_root_hint,
package_manager_hint,
)
.await?
else {
continue;
};
attempted += 1;
emit_install_progress(
app,
&format!(
"官方自更新未能完成,开始尝试全局包升级兜底:{} · {}。",
plan.runtime_source, plan.package_manager
),
"warn",
);
emit_install_progress(
app,
&format!("目标执行环境:{}", plan.runtime_bin_dir.display()),
"info",
);
emit_install_progress(app, &format!("升级包:{}", plan.package_spec), "info");
let result = run_shell_command_with_progress(app, &plan.command_line).await?;
if result.success {
emit_install_progress(
app,
&format!(
"已通过 {} 的 {} 全局安装方式完成兜底升级。",
plan.runtime_source, plan.package_manager
),
"info",
);
return Ok(OpenClawDirectUpgradeResult {
runtime_source: plan.runtime_source,
runtime_bin_dir: plan.runtime_bin_dir,
package_manager: plan.package_manager,
package_spec: plan.package_spec,
});
}
emit_install_progress(
app,
&format!(
"全局包升级兜底失败:{} · {}。",
plan.runtime_source, result.message
),
"warn",
);
last_error = Some(result.message);
}
if attempted == 0 {
return Err("未检测到可用于全局升级的 OpenClaw 安装来源。".to_string());
}
Err(last_error.unwrap_or_else(|| "已自动尝试全局升级兜底,但仍未成功。".to_string()))
}
pub(crate) async fn resolve_direct_openclaw_upgrade_plan(
app: &AppHandle,
candidate: &OpenClawRuntimeCandidate,
runtime_bin_dir_hint: Option<&Path>,
install_root_hint: Option<&Path>,
package_manager_hint: Option<&str>,
) -> Result<Option<OpenClawDirectUpgradePlan>, String> {
let runtime_bin_dir = PathBuf::from(&candidate.bin_dir);
let runtime_matches_hint = runtime_bin_dir_hint
.map(|hint| hint == runtime_bin_dir.as_path())
.unwrap_or(false);
let runtime_matches_install_root = install_root_hint
.map(|root| runtime_candidate_matches_install_root(candidate, root))
.unwrap_or(false);
if runtime_bin_dir_hint.is_some()
&& !runtime_matches_hint
&& !runtime_matches_install_root
&& candidate.openclaw_path.is_none()
&& candidate.openclaw_package_path.is_none()
{
return Ok(None);
}
if candidate.openclaw_path.is_none() && candidate.openclaw_package_path.is_none() {
return Ok(None);
}
let package_spec = resolve_openclaw_upgrade_package_spec(app, candidate).await?;
let registry = package_registry_for_package_spec(&package_spec);
let package_manager_hint = package_manager_hint
.map(|value| value.trim().to_ascii_lowercase())
.filter(|value| !value.is_empty());
let npm_path = candidate
.npm_path
.as_deref()
.map(PathBuf::from)
.or_else(|| find_command_in_bin_dir("npm", &runtime_bin_dir));
let pnpm_path = find_command_in_bin_dir("pnpm", &runtime_bin_dir);
let shell_platform = current_shell_platform();
let (package_manager, command_line) = match package_manager_hint.as_deref() {
Some("pnpm") => {
if let Some(pnpm_path) = pnpm_path.as_ref().and_then(|path| path.to_str()) {
(
"pnpm".to_string(),
build_openclaw_pnpm_install_command(
shell_platform,
pnpm_path,
&package_spec,
registry,
),
)
} else if let Some(npm_path) = npm_path.as_ref().and_then(|path| path.to_str()) {
(
"npm".to_string(),
build_openclaw_install_command(
shell_platform,
npm_path,
candidate.npm_global_prefix.as_deref(),
&package_spec,
registry,
),
)
} else {
return Ok(None);
}
}
_ => {
if let Some(npm_path) = npm_path.as_ref().and_then(|path| path.to_str()) {
(
"npm".to_string(),
build_openclaw_install_command(
shell_platform,
npm_path,
candidate.npm_global_prefix.as_deref(),
&package_spec,
registry,
),
)
} else if let Some(pnpm_path) = pnpm_path.as_ref().and_then(|path| path.to_str()) {
(
"pnpm".to_string(),
build_openclaw_pnpm_install_command(
shell_platform,
pnpm_path,
&package_spec,
registry,
),
)
} else {
return Ok(None);
}
}
};
Ok(Some(OpenClawDirectUpgradePlan {
runtime_source: candidate.source.clone(),
runtime_bin_dir,
package_manager,
package_spec,
command_line,
}))
}
pub(crate) async fn resolve_openclaw_upgrade_package_spec(
app: &AppHandle,
candidate: &OpenClawRuntimeCandidate,
) -> Result<String, String> {
if let Some(prefix) = candidate.npm_global_prefix.as_deref() {
if let Some(package) = find_installed_openclaw_package_details(prefix) {
return Ok(format!("{}@latest", package.name));
}
}
if let Some(package_path) = candidate.openclaw_package_path.as_deref() {
if let Some(package_name) = infer_openclaw_package_name_from_path(Path::new(package_path)) {
return Ok(format!("{package_name}@latest"));
}
}
Ok(if should_use_china_package(app).await {
OPENCLAW_CN_PACKAGE.to_string()
} else {
OPENCLAW_DEFAULT_PACKAGE.to_string()
})
}
pub(crate) fn infer_openclaw_package_name_from_path(path: &Path) -> Option<&'static str> {
let normalized = path
.display()
.to_string()
.replace('\\', "/")
.to_ascii_lowercase();
if normalized.contains("@qingchencloud/openclaw-zh") {
return Some("@qingchencloud/openclaw-zh");
}
if normalized.contains("/openclaw/package.json") {
return Some("openclaw");
}
None
}
pub(crate) fn package_registry_for_package_spec(package_spec: &str) -> Option<&'static str> {
package_spec
.starts_with("@qingchencloud/openclaw-zh@")
.then_some(NPM_MIRROR_CN)
}
pub(crate) fn runtime_candidate_matches_install_root(
candidate: &OpenClawRuntimeCandidate,
install_root_hint: &Path,
) -> bool {
[
Some(candidate.bin_dir.as_str()),
Some(candidate.node_path.as_str()),
candidate.npm_path.as_deref(),
candidate.npm_global_prefix.as_deref(),
candidate.openclaw_path.as_deref(),
candidate.openclaw_package_path.as_deref(),
]
.into_iter()
.flatten()
.map(PathBuf::from)
.any(|path| path.starts_with(install_root_hint))
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Lime",
"version": "1.33.0",
"version": "1.34.0",
"identifier": "com.limecloud.lime.headless",
"build": {
"beforeDevCommand": "npm run dev:web-bridge",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Lime",
"version": "1.33.0",
"version": "1.34.0",
"identifier": "com.limecloud.lime",
"build": {
"beforeDevCommand": "node scripts/start-tauri-dev-server.mjs",
+39 -31
View File
@@ -10,6 +10,7 @@
import React, { Suspense, lazy, useState, useCallback, useEffect } from "react";
import styled from "styled-components";
import { useTranslation } from "react-i18next";
import { withI18nPatch } from "./i18n/withI18nPatch";
import { AppPageContent } from "./components/AppPageContent";
import { SplashScreen } from "./components/SplashScreen";
@@ -132,25 +133,10 @@ const ConnectConfirmDialog = lazy(() =>
default: module.ConnectConfirmDialog,
})),
);
const pageLoadingFallback = (
<div
style={{
flex: 1,
minHeight: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "hsl(var(--muted-foreground))",
fontSize: "14px",
}}
>
页面加载中...
</div>
);
function AppContent() {
startupTracker.mark("AppContent: render start");
const { t } = useTranslation("common");
const hasTauriDesktopRuntime = hasTauriInvokeCapability();
const reserveMacWindowControls = shouldReserveMacWindowControls();
const [showSplash, setShowSplash] = useState(true);
@@ -191,10 +177,12 @@ function AppContent() {
[],
);
const handleResourceManagerNavigationUnsupported = useCallback(() => {
toast.info("当前资源暂时不能自动回跳", {
description: "已记录来源信息,但主窗口还没有对应的业务入口。",
toast.info(t("common.app.resourceNavigation.unsupported.title"), {
description: t(
"common.app.resourceNavigation.unsupported.description",
),
});
}, []);
}, [t]);
useResourceManagerNavigationIntents({
onNavigate: handleNavigate,
onHandled: handleResourceManagerNavigationHandled,
@@ -219,11 +207,15 @@ function AppContent() {
const _handleRequestRecommendation = useCallback(
(shortLabel: string, fullPrompt: string, currentTheme: string) => {
const themeLabels: Record<string, string> = {
general: "对话",
general: t("common.app.project.type.general"),
};
const prefix = themeLabels[currentTheme] || "项目";
const projectName = `${prefix}:${shortLabel}`;
const prefix =
themeLabels[currentTheme] || t("common.app.project.type.fallback");
const projectName = t("common.app.project.recommendedName", {
prefix,
shortLabel,
});
setPendingRecommendation({
shortLabel,
@@ -233,7 +225,7 @@ function AppContent() {
});
setProjectDialogOpen(true);
},
[],
[t],
);
const handleCreateProjectFromRecommendation = async (
@@ -271,7 +263,7 @@ function AppContent() {
});
}
toast.success("项目创建成功");
toast.success(t("common.app.project.created"));
};
const handleOpenBrowserConnectorSettings = useCallback(
@@ -281,12 +273,12 @@ function AppContent() {
});
if (enable) {
toast.info("已打开连接器设置", {
description: "在“连接器”页中开启浏览器连接器或重新同步扩展。",
toast.info(t("common.app.browserConnector.opened.title"), {
description: t("common.app.browserConnector.opened.description"),
});
}
},
[handleNavigate],
[handleNavigate, t],
);
const handleOpenWebsiteDeepLink = useCallback(
@@ -294,16 +286,17 @@ function AppContent() {
const resolved = resolveWebsiteOpenNavigation(payload);
if (!resolved) {
toast.error("无法打开这个官网入口", {
description:
"当前 slug 没有对应到桌面端可用能力,请同步官网与客户端目录。",
toast.error(t("common.app.websiteDeepLink.unsupported.title"), {
description: t(
"common.app.websiteDeepLink.unsupported.description",
),
});
return;
}
handleNavigate(resolved.page, resolved.params);
},
[handleNavigate],
[handleNavigate, t],
);
const {
@@ -332,6 +325,21 @@ function AppContent() {
pageParams,
agentHasMessages,
});
const pageLoadingFallback = (
<div
style={{
flex: 1,
minHeight: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "hsl(var(--muted-foreground))",
fontSize: "14px",
}}
>
{t("common.app.loadingPage")}
</div>
);
const handleSplashComplete = useCallback(() => {
startupTracker.mark("SplashScreen: complete");
-25
View File
@@ -16,7 +16,6 @@ import type {
BrowserRuntimePageParams,
KnowledgePageParams,
MemoryPageParams,
OpenClawPageParams,
Page,
PageParams,
ResourcesPageParams,
@@ -61,10 +60,6 @@ const loadPluginsPage = () =>
import("./plugins/PluginsPage").then((module) => ({
default: module.PluginsPage,
}));
const loadOpenClawPage = () =>
import("./openclaw").then((module) => ({
default: module.OpenClawPage,
}));
const loadSkillsWorkspacePage = () =>
import("./skills").then((module) => ({
default: module.SkillsWorkspacePage,
@@ -130,7 +125,6 @@ function scheduleAgentChatPagePreload(): void {
const ResourcesPage = lazy(loadResourcesPage);
const MemoryPage = lazy(loadMemoryPage);
const PluginsPage = lazy(loadPluginsPage);
const OpenClawPage = lazy(loadOpenClawPage);
const SkillsWorkspacePage = lazy(loadSkillsWorkspacePage);
const KnowledgePage = lazy(loadKnowledgePage);
const BrowserRuntimeWorkspace = lazy(loadBrowserRuntimeWorkspace);
@@ -403,25 +397,6 @@ export function AppPageContent({
return wrapWithSceneApps(content);
}
if (activePage === "openclaw") {
const content = (
<div
style={{
...columnPageStyle,
overflowY: "auto",
}}
>
<OpenClawPage
onNavigate={onNavigate}
pageParams={activePageParams as OpenClawPageParams}
isActive={true}
/>
</div>
);
return wrapWithSceneApps(content);
}
if (activePage === "skills") {
const content = (
<div style={columnPageStyle}>
+126 -42
View File
@@ -277,6 +277,33 @@ function buildMockReferralDashboard() {
};
}
function seedCloudSessionWithReferral(options?: {
referralEnabled?: boolean;
referral?: ReturnType<typeof buildMockReferralDashboard> | null;
}) {
setStoredOemCloudSessionState({
token: "session-token",
tenant: { id: "tenant-0001", name: "Lime Cloud" },
user: {
id: "user-001",
displayName: "晚风",
email: "wanfeng@example.com",
},
session: { id: "session-001", provider: "google" },
});
setOemCloudBootstrapSnapshot({
session: {
tenant: { id: "tenant-0001", name: "Lime Cloud" },
},
features: {
referralEnabled: options?.referralEnabled ?? true,
},
...(options?.referral !== null
? { referral: options?.referral ?? buildMockReferralDashboard() }
: {}),
});
}
describe("AppSidebar", () => {
beforeEach(async () => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
@@ -497,7 +524,6 @@ describe("AppSidebar", () => {
expect(container.textContent).not.toContain("持续流程");
expect(container.textContent).not.toContain("消息渠道");
expect(container.textContent).not.toContain("插件中心");
expect(container.textContent).not.toContain("OpenClaw");
expect(container.textContent).not.toContain("桌宠");
expect(container.textContent).not.toContain("支撑");
expect(container.textContent).not.toContain("技能");
@@ -730,25 +756,7 @@ describe("AppSidebar", () => {
configurable: true,
value: { writeText },
});
setStoredOemCloudSessionState({
token: "session-token",
tenant: { id: "tenant-0001", name: "Lime Cloud" },
user: {
id: "user-001",
displayName: "晚风",
email: "wanfeng@example.com",
},
session: { id: "session-001", provider: "google" },
});
setOemCloudBootstrapSnapshot({
session: {
tenant: { id: "tenant-0001", name: "Lime Cloud" },
},
features: {
referralEnabled: true,
},
referral: buildMockReferralDashboard(),
});
seedCloudSessionWithReferral();
const container = mountSidebarContainer({
currentPage: "agent",
@@ -798,24 +806,61 @@ describe("AppSidebar", () => {
expect(mockToastSuccess).toHaveBeenCalledWith("已复制邀请文案");
});
it("缓存的云端邀请开关关闭时不应展示头部邀请入口", async () => {
setStoredOemCloudSessionState({
token: "session-token",
tenant: { id: "tenant-0001", name: "Lime Cloud" },
user: {
id: "user-001",
displayName: "晚风",
email: "wanfeng@example.com",
},
session: { id: "session-001", provider: "google" },
it("邀请入口应使用 navigation 命名空间资源", async () => {
await changeLimeLocale("en-US");
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText },
});
setOemCloudBootstrapSnapshot({
session: {
tenant: { id: "tenant-0001", name: "Lime Cloud" },
},
features: {
referralEnabled: false,
},
seedCloudSessionWithReferral();
const container = mountSidebarContainer({
currentPage: "agent",
currentPageParams: {
agentEntry: "new-task",
} as AgentPageParams,
});
await flushEffects(2);
const inviteButton = container.querySelector<HTMLButtonElement>(
'[data-testid="app-sidebar-invite-button"]',
);
expect(inviteButton?.textContent).toContain("Invite friends");
await act(async () => {
inviteButton?.click();
await Promise.resolve();
});
await flushEffects(4);
const dialog = document.body.querySelector(
'[data-testid="app-sidebar-invite-dialog"]',
);
expect(dialog?.textContent).toContain("Lime Invite");
expect(dialog?.textContent).toContain("Invite code");
expect(dialog?.textContent).toContain("480 credits");
expect(dialog?.textContent).toContain("120 credits");
const copyShareButton = Array.from(
document.body.querySelectorAll("button"),
).find((button) => button.textContent?.includes("Copy invite message"));
await act(async () => {
copyShareButton?.click();
await Promise.resolve();
});
expect(writeText).toHaveBeenCalledWith(
"邀请你体验Lime,让AI做牛做马,我们来做牛人!前往 https://limeai.run 下载客户端,复制邀请码 LIME-2026 激活并注册账号参与内测",
);
expect(mockToastSuccess).toHaveBeenCalledWith("Invite message copied");
});
it("缓存的云端邀请开关关闭时不应展示头部邀请入口", async () => {
seedCloudSessionWithReferral({
referralEnabled: false,
referral: null,
});
const container = mountSidebarContainer({
@@ -2587,7 +2632,7 @@ describe("AppSidebar", () => {
it("显式开启后应显示可选系统扩展入口", async () => {
mockGetConfig.mockResolvedValue({
navigation: {
enabled_items: ["plugins", "openclaw", "companion"],
enabled_items: ["plugins", "companion"],
},
});
@@ -2599,7 +2644,6 @@ describe("AppSidebar", () => {
await flushEffects(2);
expect(container.textContent).not.toContain("插件中心");
expect(container.textContent).not.toContain("OpenClaw");
expect(container.textContent).not.toContain("桌宠");
await act(async () => {
@@ -2618,7 +2662,6 @@ describe("AppSidebar", () => {
expect(accountMenu?.textContent).toContain("持续流程");
expect(accountMenu?.textContent).toContain("消息渠道");
expect(accountMenu?.textContent).toContain("插件中心");
expect(accountMenu?.textContent).toContain("OpenClaw");
expect(accountMenu?.textContent).toContain("桌宠");
});
@@ -2718,7 +2761,7 @@ describe("AppSidebar", () => {
it("旧的 enabled-items 本地缓存不应再复活历史导航", async () => {
localStorage.setItem(
APP_SIDEBAR_ENABLED_ITEMS_STORAGE_KEY,
JSON.stringify(["plugins", "openclaw", "companion", "video"]),
JSON.stringify(["plugins", "companion", "video"]),
);
mockGetConfig.mockImplementation(() => new Promise(() => undefined));
@@ -2730,7 +2773,6 @@ describe("AppSidebar", () => {
await flushEffects();
expect(container.textContent).not.toContain("插件中心");
expect(container.textContent).not.toContain("OpenClaw");
expect(container.textContent).not.toContain("桌宠");
});
@@ -2797,6 +2839,48 @@ describe("AppSidebar", () => {
expect(document.documentElement.classList.contains("dark")).toBe(true);
});
it("外观快捷面板应使用 navigation 命名空间资源", async () => {
await changeLimeLocale("en-US");
const container = mountSidebarContainer({
currentPageParams: {
agentEntry: "new-task",
} as AgentPageParams,
});
await flushEffects(2);
const trigger = container.querySelector<HTMLButtonElement>(
'button[aria-label="Quick appearance switch"]',
);
expect(trigger).not.toBeNull();
await act(async () => {
trigger?.click();
await Promise.resolve();
});
const popover = container.querySelector(
'[data-testid="app-sidebar-appearance-popover"]',
);
expect(popover?.textContent).toContain("Appearance");
expect(popover?.textContent).toContain("Follow system · Ink Green");
expect(popover?.textContent).toContain("Theme");
expect(popover?.textContent).toContain("Color");
expect(popover?.textContent).toContain("Light");
expect(popover?.textContent).toContain("Dark");
expect(popover?.textContent).toContain("Follow system");
expect(popover?.textContent).toContain("Random");
expect(popover?.textContent).toContain("Ocean");
expect(
container.querySelector('button[aria-label="Switch theme to Dark"]'),
).not.toBeNull();
expect(
container.querySelector(
'button[aria-label="Switch color scheme to Ocean"]',
),
).not.toBeNull();
});
it("外观弹层的随机配色应持久化到一个真实预设", async () => {
const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0);
+368 -62
View File
@@ -196,7 +196,6 @@ const SIDEBAR_NAV_LABEL_KEYS: Record<string, string> = {
"home-general": "navigation.sidebar.items.homeGeneral",
knowledge: "navigation.sidebar.items.knowledge",
memory: "navigation.sidebar.items.memory",
openclaw: "navigation.sidebar.items.openclaw",
plugins: "navigation.sidebar.items.plugins",
settings: "navigation.sidebar.items.settings",
skills: "navigation.sidebar.items.skills",
@@ -2316,14 +2315,6 @@ function resolveCloudBrandLabel(
: `${appName} ${cloudSuffixLabel}`;
}
function formatReferralCredits(value: number | undefined): string {
if (typeof value !== "number" || value <= 0) {
return "按当前策略发放";
}
return `${value.toLocaleString("zh-CN")} 积分`;
}
export function AppSidebar({
currentPage,
currentPageParams,
@@ -2402,6 +2393,10 @@ export function AppSidebar({
"navigation.sidebar.account.cloudSuffix",
"云端",
);
const inviteLoadErrorLabel = t(
"navigation.sidebar.invite.feedback.loadFailed",
"加载邀请信息失败",
);
const activePage = requestedPage ?? currentPage;
const activePageParams = requestedPageParams ?? currentPageParams;
const activeNavigationTarget = {
@@ -2846,7 +2841,7 @@ export function AppSidebar({
const message =
error instanceof Error && error.message.trim()
? error.message.trim()
: "加载邀请信息失败";
: inviteLoadErrorLabel;
setInviteError(message);
})
.finally(() => {
@@ -2864,6 +2859,7 @@ export function AppSidebar({
inviteTenantId,
canLoadReferralDashboard,
cachedInviteDashboard,
inviteLoadErrorLabel,
]);
useEffect(() => {
@@ -4062,9 +4058,191 @@ export function AppSidebar({
);
const currentColorScheme = getLimeColorScheme(colorSchemeId);
const appearanceEntryLabel = t(
"navigation.sidebar.appearance.entry.label",
"快速切换外观",
);
const appearanceTitleLabel = t(
"navigation.sidebar.appearance.dialog.title",
"外观",
);
const appearanceThemeGroupLabel = t(
"navigation.sidebar.appearance.theme.group",
"主题",
);
const appearanceColorSchemeGroupLabel = t(
"navigation.sidebar.appearance.colorScheme.group",
"配色",
);
const appearanceRandomColorSchemeLabel = t(
"navigation.sidebar.appearance.colorScheme.random.label",
"随机",
);
const appearanceRandomColorSchemeAriaLabel = t(
"navigation.sidebar.appearance.colorScheme.random.ariaLabel",
"随机切换配色",
);
const appearanceRandomColorSchemeTitle = t(
"navigation.sidebar.appearance.colorScheme.random.title",
"随机切换一个颜色主题",
);
const appearanceThemeCopy = {
light: {
label: t("navigation.sidebar.appearance.theme.light.label", "浅色"),
description: t(
"navigation.sidebar.appearance.theme.light.description",
"适合白天和高亮环境。",
),
},
dark: {
label: t("navigation.sidebar.appearance.theme.dark.label", "深色"),
description: t(
"navigation.sidebar.appearance.theme.dark.description",
"降低夜间使用时的眩光。",
),
},
system: {
label: t("navigation.sidebar.appearance.theme.system.label", "跟随系统"),
description: t(
"navigation.sidebar.appearance.theme.system.description",
"自动同步系统外观。",
),
},
} satisfies Record<LimeThemeMode, { label: string; description: string }>;
const appearanceColorSchemeCopy = {
"lime-classic": {
label: t(
"navigation.sidebar.appearance.colorScheme.limeClassic.label",
"墨绿",
),
description: t(
"navigation.sidebar.appearance.colorScheme.limeClassic.description",
"经典深绿,温暖米色背景。",
),
},
"lime-forest": {
label: t(
"navigation.sidebar.appearance.colorScheme.limeForest.label",
"自然",
),
description: t(
"navigation.sidebar.appearance.colorScheme.limeForest.description",
"舒适放松的清新自然风。",
),
},
"lime-ocean": {
label: t(
"navigation.sidebar.appearance.colorScheme.limeOcean.label",
"海洋",
),
description: t(
"navigation.sidebar.appearance.colorScheme.limeOcean.description",
"沉静专业的蓝色调。",
),
},
"lime-sand": {
label: t(
"navigation.sidebar.appearance.colorScheme.limeSand.label",
"复古",
),
description: t(
"navigation.sidebar.appearance.colorScheme.limeSand.description",
"温暖怀旧的琥珀色调。",
),
},
"lime-neon": {
label: t(
"navigation.sidebar.appearance.colorScheme.limeNeon.label",
"霓虹",
),
description: t(
"navigation.sidebar.appearance.colorScheme.limeNeon.description",
"赛博明亮的粉紫色调。",
),
},
"lime-citron": {
label: t(
"navigation.sidebar.appearance.colorScheme.limeCitron.label",
"青柠",
),
description: t(
"navigation.sidebar.appearance.colorScheme.limeCitron.description",
"活力清新的黄绿配紫。",
),
},
"lime-dusk": {
label: t(
"navigation.sidebar.appearance.colorScheme.limeDusk.label",
"黄昏",
),
description: t(
"navigation.sidebar.appearance.colorScheme.limeDusk.description",
"柔和温暖的暮色调。",
),
},
"lime-minimal": {
label: t(
"navigation.sidebar.appearance.colorScheme.limeMinimal.label",
"极简",
),
description: t(
"navigation.sidebar.appearance.colorScheme.limeMinimal.description",
"清晰专业的深蓝商务风。",
),
},
"lime-vivid": {
label: t(
"navigation.sidebar.appearance.colorScheme.limeVivid.label",
"活力",
),
description: t(
"navigation.sidebar.appearance.colorScheme.limeVivid.description",
"时尚有冲击力的现代科技风。",
),
},
"lime-literary": {
label: t(
"navigation.sidebar.appearance.colorScheme.limeLiterary.label",
"文艺",
),
description: t(
"navigation.sidebar.appearance.colorScheme.limeLiterary.description",
"宁静高雅的灰蓝文艺风。",
),
},
"lime-luxury": {
label: t(
"navigation.sidebar.appearance.colorScheme.limeLuxury.label",
"奢华",
),
description: t(
"navigation.sidebar.appearance.colorScheme.limeLuxury.description",
"尊贵权威的黑金商务风。",
),
},
} satisfies Record<LimeColorSchemeId, { label: string; description: string }>;
const appearanceThemeOptions = LIME_THEME_MODE_OPTIONS.map((option) => ({
...option,
...appearanceThemeCopy[option.id],
}));
const appearanceColorSchemes = LIME_COLOR_SCHEMES.map((scheme) => ({
...scheme,
...appearanceColorSchemeCopy[scheme.id],
}));
const currentThemeLabel =
LIME_THEME_MODE_OPTIONS.find((option) => option.id === themeState.themeMode)
?.label ?? "跟随系统";
appearanceThemeCopy[themeState.themeMode]?.label ??
appearanceThemeCopy.system.label;
const currentColorSchemeLabel =
appearanceColorSchemeCopy[currentColorScheme.id]?.label ??
currentColorScheme.label;
const appearanceSummaryLabel = t(
"navigation.sidebar.appearance.dialog.summary",
{
theme: currentThemeLabel,
colorScheme: currentColorSchemeLabel,
defaultValue: "{{theme}} · {{colorScheme}}",
},
);
const currentLanguageLabel = resolveLocaleOptionLabel(language);
const accountDisplayName = resolveAccountDisplayName(
cloudSessionState,
@@ -4263,10 +4441,123 @@ export function AppSidebar({
: accountLocalTooltipLabel;
const inviteShare = inviteDashboard?.share;
const invitePolicy = inviteDashboard?.policy;
const inviteHeadline = inviteShare?.headline?.trim() || "邀请好友加入内测";
const inviteEntryLabel = t(
"navigation.sidebar.invite.entry.label",
"邀请好友",
);
const inviteCloseDialogLabel = t(
"navigation.sidebar.invite.dialog.close",
"关闭邀请弹窗",
);
const inviteBrandName = inviteShare?.brandName ?? accountTenantLabel ?? "Lime";
const inviteEyebrowLabel = t("navigation.sidebar.invite.dialog.eyebrow", {
brand: inviteBrandName,
defaultValue: "{{brand}} 邀请",
});
const inviteDialogTitleLabel = t(
"navigation.sidebar.invite.dialog.title",
"邀请好友",
);
const inviteHeadline =
inviteShare?.headline?.trim() ||
t("navigation.sidebar.invite.dialog.headlineFallback", "邀请好友加入内测");
const inviteRules =
inviteShare?.rules?.trim() ||
"通过云端邀请策略自动发放奖励,具体到账以当前品牌云端配置为准。";
t(
"navigation.sidebar.invite.dialog.rulesFallback",
"通过云端邀请策略自动发放奖励,具体到账以当前品牌云端配置为准。",
);
const inviteDescriptionLabel =
!inviteLoading && !inviteError
? t("navigation.sidebar.invite.dialog.descriptionWithRules", {
headline: inviteHeadline,
rules: inviteRules,
defaultValue: "{{headline}}。{{rules}}",
})
: inviteHeadline;
const inviteDisconnectedLabel = t(
"navigation.sidebar.invite.status.disconnected",
{
brand: cloudBrandLabel,
defaultValue:
"连接 {{brand}} 后会生成专属邀请码,并自动读取当前品牌云端的域名和奖励策略。",
},
);
const inviteConnectAccountLabel = t(
"navigation.sidebar.invite.actions.connectCloudAccount",
"连接云端账号",
);
const inviteLoadingLabel = t(
"navigation.sidebar.invite.status.loading",
"正在从云端同步邀请信息...",
);
const inviteRetryLabel = t(
"navigation.sidebar.invite.actions.retry",
"重试",
);
const inviteCodeLabel = t("navigation.sidebar.invite.fields.code", "邀请码");
const inviteCopyLabel = t("navigation.sidebar.invite.actions.copy", "复制");
const inviteDownloadUrlLabel = t(
"navigation.sidebar.invite.fields.downloadUrl",
"下载地址",
);
const inviteLandingUrlLabel = t(
"navigation.sidebar.invite.fields.landingUrl",
"邀请链接",
);
const inviteReferrerRewardLabel = t(
"navigation.sidebar.invite.fields.referrerReward",
"邀请人奖励",
);
const inviteInviteeRewardLabel = t(
"navigation.sidebar.invite.fields.inviteeReward",
"被邀请人奖励",
);
const inviteCopyShareTextLabel = t(
"navigation.sidebar.invite.actions.copyShareText",
"复制邀请文案",
);
const inviteCopyLandingUrlLabel = t(
"navigation.sidebar.invite.actions.copyLandingUrl",
"复制邀请链接",
);
const inviteCopyCodeSuccessLabel = t(
"navigation.sidebar.invite.feedback.codeCopied",
"已复制邀请码",
);
const inviteCopyShareTextSuccessLabel = t(
"navigation.sidebar.invite.feedback.shareTextCopied",
"已复制邀请文案",
);
const inviteCopyLandingUrlSuccessLabel = t(
"navigation.sidebar.invite.feedback.landingUrlCopied",
"已复制邀请链接",
);
const inviteCopyEmptyLabel = t(
"navigation.sidebar.invite.feedback.emptyCopy",
"暂无可复制内容",
);
const inviteCopyFailedLabel = t(
"navigation.sidebar.invite.feedback.copyFailed",
"复制失败,请检查剪贴板权限",
);
const inviteRewardCurrentPolicyLabel = t(
"navigation.sidebar.invite.reward.currentPolicy",
"按当前策略发放",
);
const formatInviteReferralCredits = useCallback(
(value: number | undefined): string => {
if (typeof value !== "number" || value <= 0) {
return inviteRewardCurrentPolicyLabel;
}
return t("navigation.sidebar.invite.reward.credits", {
amount: value.toLocaleString(i18n.language),
defaultValue: "{{amount}} 积分",
});
},
[i18n.language, inviteRewardCurrentPolicyLabel, t],
);
const handleThemeModeChange = useCallback((nextThemeMode: LimeThemeMode) => {
const themeMode = persistLimeThemeMode(nextThemeMode);
@@ -4378,7 +4669,7 @@ export function AppSidebar({
async (value: string | undefined, successMessage: string) => {
const text = value?.trim();
if (!text) {
toast.info("暂无可复制内容");
toast.info(inviteCopyEmptyLabel);
return;
}
@@ -4389,10 +4680,10 @@ export function AppSidebar({
await navigator.clipboard.writeText(text);
toast.success(successMessage);
} catch {
toast.error("复制失败,请检查剪贴板权限");
toast.error(inviteCopyFailedLabel);
}
},
[],
[inviteCopyEmptyLabel, inviteCopyFailedLabel],
);
const handleLanguageChange = useCallback(
@@ -4480,14 +4771,14 @@ export function AppSidebar({
setInviteDashboard(cachedInviteDashboard);
setInviteDialogOpen(true);
}}
title="邀请好友"
aria-label="邀请好友"
title={inviteEntryLabel}
aria-label={inviteEntryLabel}
data-testid="app-sidebar-invite-button"
>
<Gift />
<span>邀请好友</span>
<span>{inviteEntryLabel}</span>
</HeaderInviteButton>,
"邀请好友",
inviteEntryLabel,
)
: null}
@@ -4590,8 +4881,8 @@ export function AppSidebar({
setLanguageMenuOpen(false);
setAppearancePopoverOpen((current) => !current);
}}
title="快速切换外观"
aria-label="快速切换外观"
title={appearanceEntryLabel}
aria-label={appearanceEntryLabel}
aria-expanded={appearancePopoverOpen}
aria-haspopup="dialog"
>
@@ -4602,7 +4893,9 @@ export function AppSidebar({
)}
</IconActionButton>
</TooltipTrigger>
<TooltipContent side="right">快速切换外观</TooltipContent>
<TooltipContent side="right">
{appearanceEntryLabel}
</TooltipContent>
</Tooltip>
) : (
<IconActionButton
@@ -4612,8 +4905,8 @@ export function AppSidebar({
setLanguageMenuOpen(false);
setAppearancePopoverOpen((current) => !current);
}}
title="快速切换外观"
aria-label="快速切换外观"
title={appearanceEntryLabel}
aria-label={appearanceEntryLabel}
aria-expanded={appearancePopoverOpen}
aria-haspopup="dialog"
>
@@ -4629,22 +4922,24 @@ export function AppSidebar({
<AppearancePopover
data-testid="app-sidebar-appearance-popover"
role="dialog"
aria-label="快速切换外观"
aria-label={appearanceEntryLabel}
>
<AppearancePopoverHeader>
<AppearancePopoverTitle>
<Palette />
外观
{appearanceTitleLabel}
</AppearancePopoverTitle>
<AppearancePopoverSummary>
{currentThemeLabel} · {currentColorScheme.label}
{appearanceSummaryLabel}
</AppearancePopoverSummary>
</AppearancePopoverHeader>
<AppearanceGroup>
<AppearanceGroupLabel>主题</AppearanceGroupLabel>
<AppearanceGroupLabel>
{appearanceThemeGroupLabel}
</AppearanceGroupLabel>
<ThemeModeGrid>
{LIME_THEME_MODE_OPTIONS.map((option) => {
{appearanceThemeOptions.map((option) => {
const active = option.id === themeState.themeMode;
return (
<ThemeModeButton
@@ -4652,7 +4947,13 @@ export function AppSidebar({
$active={active}
type="button"
aria-pressed={active}
aria-label={`切换主题为${option.label}`}
aria-label={t(
"navigation.sidebar.appearance.theme.switchAria",
{
theme: option.label,
defaultValue: "切换主题为{{theme}}",
},
)}
title={option.description}
onClick={() => handleThemeModeChange(option.id)}
>
@@ -4665,18 +4966,20 @@ export function AppSidebar({
</AppearanceGroup>
<AppearanceGroup>
<AppearanceGroupLabel>配色</AppearanceGroupLabel>
<AppearanceGroupLabel>
{appearanceColorSchemeGroupLabel}
</AppearanceGroupLabel>
<ColorSchemeList>
<RandomColorSchemeButton
type="button"
aria-label="随机切换配色"
title="随机切换一个颜色主题"
aria-label={appearanceRandomColorSchemeAriaLabel}
title={appearanceRandomColorSchemeTitle}
onClick={handleRandomColorScheme}
>
<Shuffle />
<span>随机</span>
<span>{appearanceRandomColorSchemeLabel}</span>
</RandomColorSchemeButton>
{LIME_COLOR_SCHEMES.map((scheme) => {
{appearanceColorSchemes.map((scheme) => {
const active = scheme.id === colorSchemeId;
return (
<ColorSchemeButton
@@ -4684,7 +4987,13 @@ export function AppSidebar({
$active={active}
type="button"
aria-pressed={active}
aria-label={`切换配色为${scheme.label}`}
aria-label={t(
"navigation.sidebar.appearance.colorScheme.switchAria",
{
colorScheme: scheme.label,
defaultValue: "切换配色为{{colorScheme}}",
},
)}
title={scheme.description}
onClick={() => handleColorSchemeChange(scheme.id)}
>
@@ -5168,26 +5477,23 @@ export function AppSidebar({
<InviteDialogSurface data-testid="app-sidebar-invite-dialog">
<InviteDialogCloseButton
type="button"
aria-label="关闭邀请弹窗"
aria-label={inviteCloseDialogLabel}
onClick={() => setInviteDialogOpen(false)}
>
<X />
</InviteDialogCloseButton>
<InviteDialogHeader>
<InviteDialogEyebrow>
{inviteShare?.brandName ?? accountTenantLabel ?? "Lime"} 邀请
</InviteDialogEyebrow>
<InviteDialogTitle>邀请好友</InviteDialogTitle>
<InviteDialogEyebrow>{inviteEyebrowLabel}</InviteDialogEyebrow>
<InviteDialogTitle>{inviteDialogTitleLabel}</InviteDialogTitle>
<InviteDialogDescription>
{inviteHeadline}
{!inviteLoading && !inviteError ? `。${inviteRules}` : ""}
{inviteDescriptionLabel}
</InviteDialogDescription>
</InviteDialogHeader>
<InviteDialogBody>
{!hasCloudAccount ? (
<InviteStatusCard>
{`连接 ${cloudBrandLabel} 后会生成专属邀请码,并自动读取当前品牌云端的域名和奖励策略。`}
{inviteDisconnectedLabel}
<InviteActionBar style={{ marginTop: 10 }}>
<InviteDialogActionButton
type="button"
@@ -5198,14 +5504,14 @@ export function AppSidebar({
}}
>
<Cloud />
连接云端账号
{inviteConnectAccountLabel}
</InviteDialogActionButton>
</InviteActionBar>
</InviteStatusCard>
) : null}
{hasCloudAccount && inviteLoading ? (
<InviteStatusCard>正在从云端同步邀请信息...</InviteStatusCard>
<InviteStatusCard>{inviteLoadingLabel}</InviteStatusCard>
) : null}
{hasCloudAccount && inviteError ? (
@@ -5217,7 +5523,7 @@ export function AppSidebar({
onClick={() => setInviteReloadKey((value) => value + 1)}
>
<RefreshCw />
重试
{inviteRetryLabel}
</InviteDialogActionButton>
</InviteActionBar>
</InviteStatusCard>
@@ -5230,7 +5536,7 @@ export function AppSidebar({
<InviteShareCard>
<InviteCodeBlock>
<InviteCodeMeta>
<InviteCodeLabel>邀请码</InviteCodeLabel>
<InviteCodeLabel>{inviteCodeLabel}</InviteCodeLabel>
<InviteCodeValue>{inviteShare?.code}</InviteCodeValue>
</InviteCodeMeta>
<InviteDialogActionButton
@@ -5238,36 +5544,36 @@ export function AppSidebar({
onClick={() =>
void handleCopyInviteText(
inviteShare?.code,
"已复制邀请码",
inviteCopyCodeSuccessLabel,
)
}
>
<Copy />
复制
{inviteCopyLabel}
</InviteDialogActionButton>
</InviteCodeBlock>
<InviteMetaGrid>
<InviteMetaItem>
<span>下载地址</span>
<span>{inviteDownloadUrlLabel}</span>
<strong>{inviteShare?.downloadUrl}</strong>
</InviteMetaItem>
<InviteMetaItem>
<span>邀请链接</span>
<span>{inviteLandingUrlLabel}</span>
<strong>{inviteShare?.landingUrl}</strong>
</InviteMetaItem>
<InviteMetaItem>
<span>邀请人奖励</span>
<span>{inviteReferrerRewardLabel}</span>
<strong>
{formatReferralCredits(
{formatInviteReferralCredits(
invitePolicy?.referrerRewardCredits,
)}
</strong>
</InviteMetaItem>
<InviteMetaItem>
<span>被邀请人奖励</span>
<span>{inviteInviteeRewardLabel}</span>
<strong>
{formatReferralCredits(
{formatInviteReferralCredits(
invitePolicy?.inviteeRewardCredits,
)}
</strong>
@@ -5281,24 +5587,24 @@ export function AppSidebar({
onClick={() =>
void handleCopyInviteText(
inviteShare?.shareText,
"已复制邀请文案",
inviteCopyShareTextSuccessLabel,
)
}
>
<Copy />
复制邀请文案
{inviteCopyShareTextLabel}
</InviteDialogActionButton>
<InviteDialogActionButton
type="button"
onClick={() =>
void handleCopyInviteText(
inviteShare?.landingUrl,
"已复制邀请链接",
inviteCopyLandingUrlSuccessLabel,
)
}
>
<ExternalLink />
复制邀请链接
{inviteCopyLandingUrlLabel}
</InviteDialogActionButton>
</InviteActionBar>
</InviteShareCard>
+78
View File
@@ -0,0 +1,78 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ConfirmDialog } from "./ConfirmDialog";
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string, options?: { defaultValue?: string }) =>
(
{
"common.cancel": "Cancel",
"common.confirm": "Confirm",
"common.confirmDialog.title": "Confirm action",
} as Record<string, string>
)[key] ??
options?.defaultValue ??
key,
}),
}));
interface MountedDialog {
container: HTMLDivElement;
root: Root;
}
const mountedDialogs: MountedDialog[] = [];
function renderConfirmDialog() {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(
<ConfirmDialog
isOpen={true}
message="This cannot be undone."
onCancel={() => {}}
onConfirm={() => {}}
/>,
);
});
mountedDialogs.push({ container, root });
}
beforeEach(() => {
(
globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}
).IS_REACT_ACT_ENVIRONMENT = true;
});
afterEach(() => {
while (mountedDialogs.length > 0) {
const mounted = mountedDialogs.pop();
if (!mounted) {
break;
}
act(() => {
mounted.root.unmount();
});
mounted.container.remove();
}
document.body.replaceChildren();
});
describe("ConfirmDialog", () => {
it("未传入显式文案时使用 common namespace 默认 chrome", () => {
renderConfirmDialog();
expect(document.body.textContent).toContain("Confirm action");
expect(document.body.textContent).toContain("Cancel");
expect(document.body.textContent).toContain("Confirm");
expect(document.body.textContent).toContain("This cannot be undone.");
});
});
+23 -6
View File
@@ -1,4 +1,5 @@
import { AlertTriangle } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Modal } from "./Modal";
interface ConfirmDialogProps {
@@ -14,14 +15,15 @@ interface ConfirmDialogProps {
export function ConfirmDialog({
isOpen,
title = "确认操作",
title,
message,
confirmText = "确定",
cancelText = "取消",
confirmText,
cancelText,
variant = "danger",
onConfirm,
onCancel,
}: ConfirmDialogProps) {
const { t } = useTranslation("common");
const variantStyles = {
danger: {
icon: "text-red-500",
@@ -38,6 +40,21 @@ export function ConfirmDialog({
};
const styles = variantStyles[variant];
const resolvedTitle =
title ??
t("common.confirmDialog.title", {
defaultValue: "确认操作",
});
const resolvedConfirmText =
confirmText ??
t("common.confirm", {
defaultValue: "确定",
});
const resolvedCancelText =
cancelText ??
t("common.cancel", {
defaultValue: "取消",
});
return (
<Modal
@@ -52,7 +69,7 @@ export function ConfirmDialog({
<AlertTriangle className="h-6 w-6" />
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold">{title}</h3>
<h3 className="text-lg font-semibold">{resolvedTitle}</h3>
<p className="mt-2 text-sm text-muted-foreground">{message}</p>
</div>
</div>
@@ -61,13 +78,13 @@ export function ConfirmDialog({
onClick={onCancel}
className="rounded-lg border px-4 py-2 text-sm hover:bg-muted"
>
{cancelText}
{resolvedCancelText}
</button>
<button
onClick={onConfirm}
className={`rounded-lg px-4 py-2 text-sm ${styles.button}`}
>
{confirmText}
{resolvedConfirmText}
</button>
</div>
</div>
@@ -411,6 +411,60 @@ describe("AgentThreadReliabilityPanel", () => {
);
});
it("后端 pending 为 0 且存在 runtime_error 时应显示故障而不是本地旧待补", () => {
const container = renderPanel({
threadRead: {
thread_id: "thread-1",
status: "running",
active_turn_id: "turn-2",
pending_requests: [],
incidents: [
{
id: "incident-runtime-error",
thread_id: "thread-1",
turn_id: "turn-1",
incident_type: "runtime_error",
severity: "high",
status: "active",
title: "时间线记录到异常项",
details:
"Agent provider execution failed: Request failed with status 402 Payment Required",
},
],
},
turns: [
{
id: "turn-2",
thread_id: "thread-1",
prompt_text: "继续",
status: "running",
started_at: "2026-05-11T00:26:18Z",
created_at: "2026-05-11T00:26:18Z",
updated_at: "2026-05-11T00:26:24Z",
},
],
currentTurnId: "turn-2",
pendingActions: [
{
requestId: "ask-turn-1",
actionType: "ask_user",
prompt: "请提供继续执行所需信息",
status: "pending",
scope: {
sessionId: "session-1",
threadId: "thread-1",
turnId: "turn-1",
},
},
],
});
expect(container.textContent).toContain("时间线记录到异常项");
expect(container.textContent).toContain("402 Payment Required");
expect(container.textContent).not.toContain("当前线程正在等待人工处理");
expect(container.textContent).not.toContain("优先响应当前待处理请求");
});
it("缺少 thread_read 时,应从当前 turn 与 pendingActions 推导并支持中断", async () => {
const onInterruptCurrentTurn = vi.fn().mockResolvedValue(undefined);
const container = renderPanel({
@@ -1149,6 +1149,34 @@ describe("AgentThreadTimeline", () => {
);
});
it("运行中的单条 reasoning 应直接显示为一个思考块,避免摘要壳嵌套详情壳", () => {
const reasoningText = "正在梳理 PPT 大纲所需的关键输入。";
const items: AgentThreadItem[] = [
{
...createBaseItem("reasoning-active-1", 1),
type: "reasoning",
status: "in_progress",
completed_at: undefined,
text: reasoningText,
},
];
const container = renderTimeline(items, {
isCurrentTurn: true,
turn: {
status: "running",
},
});
expect(
container.querySelector(
'[data-testid="agent-thread-block:1:process:details"]',
),
).toBeNull();
expect((container.textContent?.split("思考中").length ?? 1) - 1).toBe(1);
expect(container.textContent).toContain(reasoningText);
});
it("已完成的单条思考应默认只保留摘要,展开后再显示完整正文", () => {
const reasoningText =
"先核对执行链路,再立即恢复当前运行。\n随后补齐自动续提。";
@@ -1552,10 +1552,13 @@ function TimelineBlockCard({
block.items.length === 1 && isThinkingTimelineItem(block.items[0]!)
? block.items[0]!
: null;
const shouldRenderActiveSingleThinkingInline =
Boolean(singleThinkingItem) && block.status === "in_progress";
const shouldSummarizeSingleThinkingInline =
Boolean(singleThinkingItem) &&
(singleThinkingItem?.type === "reasoning" ||
singleThinkingItem?.type === "turn_summary") &&
!shouldRenderActiveSingleThinkingInline &&
!hasStructuredThinkingInlinePreview(singleThinkingItem);
const shouldRenderSingleItemInline =
block.items.length === 1 &&
@@ -233,8 +233,10 @@ describe("AgentThreadTimelineArtifactCard", () => {
expect(badge).not.toBeNull();
expect(badge?.textContent).toContain("AgentUI Artifact 预览");
expect(badge?.getAttribute("title")).toContain(
"conversationProjectionStore.agentUi",
"Agent UI 标准投影",
);
expect(badge?.getAttribute("title")).toContain("产物快照");
expect(badge?.getAttribute("title")).not.toContain("artifact_snapshot");
});
it("Markdown 文件产物应明确标识为可沉淀的 Document 产物", () => {
@@ -20,6 +20,7 @@ import { useLatestAgentUiProjectionEventForArtifact } from "../projection/useCon
import {
formatAgentUiProjectionEventType,
formatAgentUiProjectionPhase,
formatAgentUiProjectionSourceType,
} from "../projection/agentUiProjectionSummary";
interface AgentThreadTimelineArtifactCardProps {
@@ -451,8 +452,10 @@ export function AgentThreadTimelineArtifactCard({
className="border-sky-200 bg-white text-sky-700"
data-testid="timeline-file-artifact-agentui"
title={[
"conversationProjectionStore.agentUi",
latestArtifactProjection.sourceType,
"Agent UI 标准投影",
formatAgentUiProjectionSourceType(
latestArtifactProjection.sourceType,
),
formatAgentUiProjectionPhase(
latestArtifactProjection.phase,
),
@@ -121,7 +121,7 @@ describe("AgentUiTeamWorkbenchSurfaceView", () => {
const container = renderSurfaceView({ events: buildEvents() });
expect(container.textContent).toContain("工作台操作视图");
expect(container.textContent).toContain("3 items");
expect(container.textContent).toContain("3 项");
expect(container.textContent).toContain("注意 2");
expect(container.textContent).toContain("Board");
expect(container.textContent).toContain("Review");
@@ -73,13 +73,12 @@ export function AgentUiTeamWorkbenchSurfaceView({
工作台操作视图
</div>
<div className="mt-1 text-[10px] leading-4 text-slate-500">
按 Agent UI v0.6 surface
展示可操作目标,避免在组件内重新推断队友状态。
按 Agent UI v0.6 工作区展示可操作目标,避免在组件内重新推断队友状态。
</div>
</div>
<div className="flex flex-wrap gap-1.5">
<span className="rounded-full border border-slate-200 bg-white px-2 py-0.5 text-[10px] text-slate-500">
{model.total} items
{model.total} 项
</span>
{model.attentionCount > 0 ? (
<span className="rounded-full border border-amber-200 bg-amber-50 px-2 py-0.5 text-[10px] font-medium text-amber-700">
@@ -15,6 +15,7 @@ import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
summarizeCuratedTaskFollowUpActions,
buildCuratedTaskTemplateCopy,
summarizeCuratedTaskOutputContract,
summarizeCuratedTaskRequiredInputs,
findCuratedTaskTemplateById,
@@ -88,6 +89,13 @@ export function CuratedTaskLauncherDialog({
}: CuratedTaskLauncherDialogProps) {
const { i18n, t } = useTranslation("agent");
const locale = i18n.language;
const curatedTaskTemplateCopy = useMemo(
() =>
buildCuratedTaskTemplateCopy((key, defaultValue, values) =>
t(key, { defaultValue, ...values }),
),
[t],
);
const [inputValues, setInputValues] = useState<CuratedTaskInputValues>({});
const [referenceEntries, setReferenceEntries] = useState<
CuratedTaskReferenceEntry[]
@@ -375,8 +383,8 @@ export function CuratedTaskLauncherDialog({
return null;
}
return findCuratedTaskTemplateById(suggestedTaskId);
}, [reviewFeedbackProjection]);
return findCuratedTaskTemplateById(suggestedTaskId, curatedTaskTemplateCopy);
}, [curatedTaskTemplateCopy, reviewFeedbackProjection]);
const activeReviewBaselineSnapshot = useMemo(() => {
if (!task) {
@@ -1221,7 +1221,6 @@ describe("EmptyState", () => {
const onOpenMemoryWorkbench = vi.fn();
const onOpenChannels = vi.fn();
const onOpenChromeRelay = vi.fn();
const onOpenOpenClaw = vi.fn();
const container = renderEmptyState({
activeTheme: "general",
runtimeToolAvailability: {
@@ -1256,7 +1255,6 @@ describe("EmptyState", () => {
onOpenMemoryWorkbench,
onOpenChannels,
onOpenChromeRelay,
onOpenOpenClaw,
});
await act(async () => {
@@ -1276,12 +1274,10 @@ describe("EmptyState", () => {
);
expect(container.textContent).not.toContain("频道入口");
expect(container.textContent).not.toContain("浏览器连接器");
expect(container.textContent).not.toContain("OpenClaw 兼容入口");
expect(container.textContent).not.toContain("打开记忆工作台");
expect(onOpenChannels).not.toHaveBeenCalled();
expect(onOpenChromeRelay).not.toHaveBeenCalled();
expect(onOpenOpenClaw).not.toHaveBeenCalled();
expect(onOpenMemoryWorkbench).not.toHaveBeenCalled();
});
@@ -6,6 +6,7 @@ import React, {
useState,
} from "react";
import styled, { keyframes } from "styled-components";
import { useTranslation } from "react-i18next";
import { getConfig } from "@/lib/api/appConfig";
import {
getSkillCatalog,
@@ -24,6 +25,7 @@ import {
} from "../utils/contextualRecommendations";
import {
buildCuratedTaskLaunchPrompt,
buildCuratedTaskTemplateCopy,
findCuratedTaskTemplateById,
listCuratedTaskTemplates,
recordCuratedTaskTemplateUsage,
@@ -394,8 +396,6 @@ interface EmptyStateProps extends SkillSelectionSourceProps {
onOpenChannels?: () => void;
/** 打开浏览器连接器 */
onOpenChromeRelay?: () => void;
/** 打开 OpenClaw 兼容入口 */
onOpenOpenClaw?: () => void;
/** 当前带入的 creation replay 前台投影 */
creationReplaySurface?: CreationReplaySurfaceModel | null;
/** 当前结果模板默认带入的 memory 引用 id */
@@ -529,6 +529,14 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
fileManagerOpen = false,
onToggleFileManager,
}) => {
const { t } = useTranslation("agent");
const curatedTaskTemplateCopy = useMemo(
() =>
buildCuratedTaskTemplateCopy((key, defaultValue, values) =>
t(key, { defaultValue, ...values }),
),
[t],
);
const pageContainerRef = useRef<HTMLDivElement | null>(null);
const [activeCapability, setActiveCapability] =
useState<InputCapabilitySelection | null>(null);
@@ -759,8 +767,12 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
const curatedTaskTemplates = useMemo(() => {
void curatedTaskTemplatesVersion;
void curatedTaskRecommendationSignalsVersion;
return listCuratedTaskTemplates();
}, [curatedTaskRecommendationSignalsVersion, curatedTaskTemplatesVersion]);
return listCuratedTaskTemplates(curatedTaskTemplateCopy);
}, [
curatedTaskRecommendationSignalsVersion,
curatedTaskTemplateCopy,
curatedTaskTemplatesVersion,
]);
const selectedTextPreview = useMemo(() => {
const normalized = (recommendationSelectedText || "")
@@ -1073,7 +1085,8 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
}
const resolvedTemplate =
findCuratedTaskTemplateById(template.id) ?? template;
findCuratedTaskTemplateById(template.id, curatedTaskTemplateCopy) ??
template;
const launchPrompt = buildCuratedTaskLaunchPrompt({
task: resolvedTemplate,
inputValues,
@@ -1107,6 +1120,7 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
[
activeCuratedTask,
appendSelectedTextToRecommendation,
curatedTaskTemplateCopy,
handleThemeChange,
input,
onLaunchBrowserAssist,
@@ -1294,7 +1308,10 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
const handleSelectHomeSkillItem = useCallback(
(item: HomeSkillSurfaceItem) => {
if (item.launchKind === "curated_task_launcher") {
const template = findCuratedTaskTemplateById(item.id);
const template = findCuratedTaskTemplateById(
item.id,
curatedTaskTemplateCopy,
);
if (!template) {
return;
}
@@ -1358,6 +1375,7 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
}
},
[
curatedTaskTemplateCopy,
effectiveDefaultCuratedTaskReferenceEntries,
effectiveDefaultCuratedTaskReferenceMemoryIds,
handleCuratedTaskLauncherRequest,
@@ -1399,7 +1417,10 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
: null;
if (targetItem) {
if (targetItem.launchKind === "curated_task_launcher") {
const template = findCuratedTaskTemplateById(targetItem.id);
const template = findCuratedTaskTemplateById(
targetItem.id,
curatedTaskTemplateCopy,
);
if (!template) {
return;
}
@@ -1426,6 +1447,7 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
}
},
[
curatedTaskTemplateCopy,
effectiveDefaultCuratedTaskReferenceEntries,
effectiveDefaultCuratedTaskReferenceMemoryIds,
handleSelectHomeSkillItem,
@@ -44,6 +44,31 @@ vi.mock("sonner", () => ({
},
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({
i18n: { language: "zh-CN" },
t: (
key: string,
defaultValueOrOptions?:
| string
| ({ defaultValue?: string } & Record<string, unknown>),
) => {
if (typeof defaultValueOrOptions === "string") {
return defaultValueOrOptions;
}
if (
defaultValueOrOptions &&
typeof defaultValueOrOptions.defaultValue === "string"
) {
return defaultValueOrOptions.defaultValue;
}
return key;
},
}),
}));
beforeEach(() => {
(
globalThis as typeof globalThis & {
@@ -1,4 +1,5 @@
import React, { memo, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
AlertCircle,
ChevronDown,
@@ -38,7 +39,9 @@ import {
} from "../utils/curatedTaskRecommendationSignals";
import { buildCuratedTaskLaunchInputPrefillFromReferenceEntries } from "../utils/curatedTaskReferenceSelection";
import {
buildReviewFeedbackProjectionCopy,
buildReviewFeedbackProjection,
formatReviewFeedbackTemplate,
type ReviewFeedbackProjection,
} from "../utils/reviewFeedbackProjection";
import {
@@ -591,6 +594,7 @@ function ReviewFeedbackProjectionCard({
className?: string;
onApplyAction?: () => void;
}) {
const { t } = useTranslation("agent");
const primarySuggestedTask = projection.suggestedTasks[0] ?? null;
return (
@@ -603,7 +607,7 @@ function ReviewFeedbackProjectionCard({
>
<div className="flex flex-wrap items-center gap-2">
<span className="inline-flex items-center rounded-full border border-emerald-200 bg-white px-2 py-0.5 text-[10px] font-medium text-emerald-700">
围绕最近判断
{t("reviewFeedback.badge", "围绕最近判断")}
</span>
{!projection.matchedCurrentTask &&
projection.suggestedTaskTitles.length > 0 ? (
@@ -613,7 +617,13 @@ function ReviewFeedbackProjectionCard({
) : null}
</div>
<div className="mt-1 text-[11px] font-medium leading-5 text-slate-900">
最近判断已更新:{projection.signal.title}
{formatReviewFeedbackTemplate(
t("reviewFeedback.title", {
defaultValue: "最近判断已更新:{{title}}",
title: projection.signal.title,
}),
{ title: projection.signal.title },
)}
</div>
<div className="mt-1 text-[11px] leading-5 text-slate-500">
{projection.signal.summary}
@@ -629,10 +639,19 @@ function ReviewFeedbackProjectionCard({
data-testid={dataTestId ? `${dataTestId}-action` : undefined}
onClick={onApplyAction}
>
继续去「{primarySuggestedTask.title}」
{formatReviewFeedbackTemplate(
t("reviewFeedback.action", {
defaultValue: "继续去「{{title}}」",
title: primarySuggestedTask.title,
}),
{ title: primarySuggestedTask.title },
)}
</button>
<span className="text-[10px] leading-5 text-slate-500">
会继续带着这轮结果与参考对象,不用重新整理一遍。
{t(
"reviewFeedback.helper.generalWorkbench",
"会继续带着这轮结果与参考对象,不用重新整理一遍。",
)}
</span>
</div>
) : null}
@@ -1283,6 +1302,7 @@ function GeneralWorkbenchWorkflowPanelComponent({
onRevealArtifactInFinder,
onOpenArtifactWithDefaultApp,
}: GeneralWorkbenchWorkflowPanelProps) {
const { t } = useTranslation("agent");
const [recommendationSignalsVersion, setRecommendationSignalsVersion] =
useState(0);
@@ -1377,11 +1397,12 @@ function GeneralWorkbenchWorkflowPanelComponent({
const reviewFeedbackProjection = useMemo(
() =>
buildReviewFeedbackProjection({
copy: buildReviewFeedbackProjectionCopy(t),
signal: latestReviewSignal,
currentTaskId: runMetadataSummary.curatedTask?.taskId,
currentTaskTitle: runMetadataSummary.curatedTask?.taskTitle,
}),
[latestReviewSignal, runMetadataSummary.curatedTask],
[latestReviewSignal, runMetadataSummary.curatedTask, t],
);
const reviewFeedbackFollowUpActionPayload = useMemo(() => {
if (!reviewFeedbackProjection) {
@@ -847,8 +847,14 @@ describe("HarnessStatusPanel", () => {
expect(document.body.textContent).toContain("Artifact");
expect(document.body.textContent).toContain("Evidence");
expect(document.body.textContent).toContain("Diagnostics");
expect(document.body.textContent).toContain("队列更新");
expect(document.body.textContent).toContain("产物快照");
expect(document.body.textContent).toContain("运行状态");
expect(document.body.textContent).toContain("steer_intent");
expect(document.body.textContent).toContain("gaps_present");
expect(document.body.textContent).not.toContain("queue_added");
expect(document.body.textContent).not.toContain("artifact_snapshot");
expect(document.body.textContent).not.toContain("runtime_status");
expect(document.body.textContent).not.toContain("other session marker");
});
@@ -92,6 +92,7 @@ import {
formatAgentUiProjectionEventDetail,
formatAgentUiProjectionEventType,
formatAgentUiProjectionPhase,
formatAgentUiProjectionSourceType,
summarizeAgentUiProjectionEvents,
} from "../projection/agentUiProjectionSummary";
import {
@@ -5855,8 +5856,10 @@ export function HarnessStatusPanel({
) : null}
</div>
<div className="mt-2 text-xs text-muted-foreground">
<span className="font-mono text-foreground">
{event.sourceType}
<span className="text-foreground">
{formatAgentUiProjectionSourceType(
event.sourceType,
)}
</span>
<span className="mx-1">·</span>
{formatAgentUiProjectionEventDetail(event)}
@@ -151,6 +151,7 @@ const mockAgentThreadTimeline = vi.fn(
actionRequests,
onOpenSavedSiteContent,
placement,
turn,
}: {
actionRequests?: Array<Record<string, unknown>>;
onOpenSavedSiteContent?: (target: {
@@ -160,10 +161,12 @@ const mockAgentThreadTimeline = vi.fn(
}) => void;
deferCompletedSingleDetails?: boolean;
placement?: "leading" | "trailing" | "default";
turn?: { id?: string } | null;
}) => (
<div
data-testid={`agent-thread-timeline:${placement || "default"}`}
data-has-open-saved-site-content={onOpenSavedSiteContent ? "yes" : "no"}
data-turn-id={turn?.id || ""}
>
执行轨迹{actionRequests?.length ? `:${actionRequests.length}` : ""}
</div>
@@ -3731,6 +3734,66 @@ describe("MessageList", () => {
);
});
it("流式正文已出现但过程由 timeline 承载时,应把思考区放在正文气泡外", () => {
const now = new Date("2026-05-11T09:40:00.000Z");
const messages: Message[] = [
{
id: "msg-user-streaming-timeline-process",
role: "user",
content: "帮我做 PPT 大纲,先确认关键信息",
timestamp: now,
},
{
id: "msg-assistant-streaming-timeline-process",
role: "assistant",
content: "好的,要帮您做 PPT 大纲,我先确认几个关键点。",
timestamp: now,
isThinking: true,
},
];
const container = render(messages, {
currentTurnId: "turn-streaming-timeline-process",
turns: [
{
id: "turn-streaming-timeline-process",
thread_id: "thread-streaming-timeline-process",
prompt_text: "帮我做 PPT 大纲,先确认关键信息",
status: "running",
started_at: "2026-05-11T09:40:00.000Z",
created_at: "2026-05-11T09:40:00.000Z",
updated_at: "2026-05-11T09:40:02.000Z",
},
],
threadItems: [
{
id: "reasoning-streaming-timeline-process",
thread_id: "thread-streaming-timeline-process",
turn_id: "turn-streaming-timeline-process",
sequence: 1,
status: "in_progress",
started_at: "2026-05-11T09:40:01.000Z",
updated_at: "2026-05-11T09:40:02.000Z",
type: "reasoning",
text: "正在判断需要补充哪些 PPT 输入。",
},
],
});
expect(
container.querySelector('[data-testid="assistant-primary-timeline-shell"]'),
).not.toBeNull();
expect(
container.querySelector('[data-testid="agent-thread-timeline:leading"]'),
).not.toBeNull();
expect(mockStreamingRenderer).toHaveBeenCalledWith(
expect.objectContaining({
content: "好的,要帮您做 PPT 大纲,我先确认几个关键点。",
thinkingContent: undefined,
}),
);
});
it("已完成旧消息残留 runtimeStatus 时不应把思考过程重复塞回正文", () => {
const now = new Date();
const messages: Message[] = [
@@ -5549,4 +5612,110 @@ describe("MessageList", () => {
).toBeNull();
expect(timelineNodes).toHaveLength(1);
});
it("继续回合的执行过程应挂在第二次对话组而不是第一次失败组", () => {
const messages: Message[] = [
{
id: "msg-user-first",
role: "user",
content: "帮我做一份 PPT 大纲",
timestamp: new Date("2026-05-11T00:20:46Z"),
},
{
id: "msg-assistant-first",
role: "assistant",
content: "执行失败:402 Payment Required",
timestamp: new Date("2026-05-11T00:20:47Z"),
runtimeTurnId: "turn-first",
},
{
id: "msg-user-continue",
role: "user",
content: "继续",
timestamp: new Date("2026-05-11T00:26:18Z"),
},
{
id: "msg-assistant-continue",
role: "assistant",
content: "好的",
timestamp: new Date("2026-05-11T00:26:19Z"),
runtimeTurnId: "turn-continue",
},
];
const container = render(messages, {
currentTurnId: "turn-continue",
turns: [
{
id: "turn-first",
thread_id: "thread-1",
prompt_text: "帮我做一份 PPT 大纲",
status: "failed",
started_at: "2026-05-11T00:20:46Z",
completed_at: "2026-05-11T00:20:47Z",
created_at: "2026-05-11T00:20:46Z",
updated_at: "2026-05-11T00:20:47Z",
},
{
id: "turn-continue",
thread_id: "thread-1",
prompt_text: "继续",
status: "completed",
started_at: "2026-05-11T00:26:18Z",
completed_at: "2026-05-11T00:26:24Z",
created_at: "2026-05-11T00:26:18Z",
updated_at: "2026-05-11T00:26:24Z",
},
],
threadItems: [
{
id: "error-first",
thread_id: "thread-1",
turn_id: "turn-first",
sequence: 1,
status: "failed",
started_at: "2026-05-11T00:20:47Z",
updated_at: "2026-05-11T00:20:47Z",
type: "error",
message: "Agent provider execution failed: 402 Payment Required",
},
{
id: "process-continue",
thread_id: "thread-1",
turn_id: "turn-continue",
sequence: 1,
status: "completed",
started_at: "2026-05-11T00:26:19Z",
completed_at: "2026-05-11T00:26:24Z",
updated_at: "2026-05-11T00:26:24Z",
type: "plan",
text: "等待用户补充 PPT 信息",
},
],
});
const firstAssistant = Array.from(
container.querySelectorAll('[data-testid="streaming-renderer"]'),
).find((node) => node.textContent?.includes("402 Payment Required"));
const continueAssistant = Array.from(
container.querySelectorAll('[data-testid="streaming-renderer"]'),
).find((node) => node.textContent?.includes("好的"));
const continueTimeline = container.querySelector(
'[data-testid^="agent-thread-timeline:"][data-turn-id="turn-continue"]',
);
expect(firstAssistant).toBeTruthy();
expect(continueAssistant).toBeTruthy();
expect(continueTimeline).toBeTruthy();
expect(
(continueTimeline as Node).compareDocumentPosition(
continueAssistant as Node,
) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
expect(
(continueTimeline as Node).compareDocumentPosition(
firstAssistant as Node,
) & Node.DOCUMENT_POSITION_PRECEDING,
).toBeTruthy();
});
});
@@ -1429,11 +1429,13 @@ const MessageListInner: React.FC<MessageListProps> = ({
lastAssistantMessageId,
timelineByMessageId,
renderedThreadItems,
renderedMessages,
});
}, [
activeCurrentTurn,
activeCurrentTurnId,
lastAssistantMessageId,
renderedMessages,
renderedThreadItems,
timelineByMessageId,
]);
@@ -2404,8 +2406,7 @@ const MessageListInner: React.FC<MessageListProps> = ({
const shouldRenderPrimaryTimelineOutsideBubble =
msg.role === "assistant" &&
Boolean(primaryTimelineNode) &&
hasVisibleAssistantText &&
!msg.isThinking;
hasVisibleAssistantText;
return (
<MessageWrapper
@@ -402,30 +402,30 @@ describe("TeamWorkbenchSummaryPanel", () => {
});
expect(container.textContent).toContain("Agent UI v0.6");
expect(container.textContent).toContain("10 events");
expect(container.textContent).toContain("10 条事件");
expect(container.textContent).toContain("Team 拓扑");
expect(container.textContent).toContain("Worker 流");
expect(container.textContent).toContain("Review / Handoff");
expect(container.textContent).toContain("工作台操作视图");
expect(container.textContent).toContain("10 items");
expect(container.textContent).toContain("10 项");
expect(container.textContent).toContain("注意 1");
expect(container.textContent).toContain("Surface 专门视图");
expect(container.textContent).toContain("工作区专门视图");
expect(container.textContent).toContain("Roster");
expect(container.textContent).toContain("成员、角色、来源与当前状态");
expect(container.textContent).toContain("delegation_graph");
expect(container.textContent).toContain("work_board");
expect(container.textContent).toContain("worker_notifications");
expect(container.textContent).toContain("teammate_transcript");
expect(container.textContent).toContain("background_teammate");
expect(container.textContent).toContain("remote_teammate");
expect(container.textContent).toContain("handoff_lane");
expect(container.textContent).toContain("review_lane");
expect(container.textContent).toContain("分派关系");
expect(container.textContent).toContain("任务板");
expect(container.textContent).toContain("执行通知");
expect(container.textContent).toContain("队友记录");
expect(container.textContent).toContain("后台队友");
expect(container.textContent).toContain("远程队友");
expect(container.textContent).toContain("交接");
expect(container.textContent).toContain("评审");
expect(container.textContent).toContain("决策:pending_review");
expect(container.textContent).toContain("风险:high");
expect(container.textContent).toContain("清单 2");
expect(container.textContent).toContain("修复:复核权限确认");
expect(container.textContent).toContain("回归项:npm run test:contracts");
expect(container.textContent).toContain("team_policy");
expect(container.textContent).toContain("团队策略");
expect(container.textContent).toContain("Review 请求");
expect(container.textContent).toContain("Agent 交接");
@@ -446,10 +446,10 @@ describe("TeamWorkbenchSummaryPanel", () => {
});
expect(container.textContent).toContain("已定位工作台目标");
expect(container.textContent).toContain("请求审核 · review-1");
expect(container.textContent).toContain("review_lane");
expect(container.textContent).toContain("评审");
expect(container.textContent).toContain("Review:review-1");
expect(container.textContent).toContain(
"不从文本推断状态,也不伪造 remote / review / handoff 运行时调用",
"不从文本推断状态,也不伪造远端、评审或交接运行时调用",
);
const transcriptAction = container.querySelector<HTMLButtonElement>(
@@ -475,9 +475,9 @@ describe("TeamWorkbenchSummaryPanel", () => {
},
});
expect(container.textContent).toContain("打开详情 · child-1:turn-child-1");
expect(container.textContent).toContain("Transcript Zoom");
expect(container.textContent).toContain("队友记录详情");
expect(container.textContent).toContain(
"Transcript Ref:child-1:turn-child-1",
"记录引用:child-1:turn-child-1",
);
expect(container.textContent).toContain("子会话:child-1");
expect(container.textContent).toContain("最新回合:turn-child-1");
@@ -491,15 +491,15 @@ describe("TeamWorkbenchSummaryPanel", () => {
expect(container.textContent).toContain("1 条");
expect(container.textContent).toContain("结构化 Drilldown");
expect(container.textContent).toContain("工具活动 1 条");
expect(container.textContent).toContain("tool_call");
expect(container.textContent).toContain("工具调用");
expect(container.textContent).toContain("读取文件");
expect(container.textContent).toContain("已读取 Agent UI 标准文档");
expect(container.textContent).toContain("相关队友链路");
expect(container.textContent).toContain("不合成新运行时事实");
expect(container.textContent).toContain("worker_notifications");
expect(container.textContent).toContain("执行通知");
expect(container.textContent).toContain("child-1:completed");
expect(container.textContent).toContain("不把队友输出混进主");
expect(container.textContent).toContain("final answer");
expect(container.textContent).toContain("主回复");
});
it("应把工作台 action route 结果显示为可见状态", () => {
@@ -543,7 +543,7 @@ describe("TeamWorkbenchSummaryPanel", () => {
expect(container.textContent).toContain("请求审核 · review-route-1");
expect(container.textContent).toContain("审核未接入");
expect(container.textContent).toContain(
"review lane 已定位;真实审核 callback / reassignment 写回仍需后续接入。",
"评审记录已定位;真实审核回调与重指派写回仍需后续接入。",
);
});
@@ -595,7 +595,7 @@ describe("TeamWorkbenchSummaryPanel", () => {
expect(onWorkbenchAction).toHaveBeenCalledTimes(1);
expect(container.textContent).toContain("远端任务已定位");
expect(container.textContent).toContain(
"已定位结构化 remote_task source;当前只展示来源、状态与结果引用,不伪造远端运行时控制。",
"已定位结构化远端任务记录;当前只展示来源、状态与结果引用,不伪造远端运行时控制。",
);
expect(container.textContent).not.toContain("远端未接入");
});
@@ -649,7 +649,7 @@ describe("TeamWorkbenchSummaryPanel", () => {
expect(onWorkbenchAction).toHaveBeenCalledTimes(1);
expect(container.textContent).toContain("交接已定位");
expect(container.textContent).toContain(
"已定位结构化 handoff_lane source;当前只展示 handoff 生命周期,不从文本伪造跨 Agent 控制。",
"已定位结构化交接记录;当前只展示交接生命周期,不从文本伪造跨 Agent 控制。",
);
expect(container.textContent).not.toContain("交接未接入");
});
@@ -887,23 +887,23 @@ describe("TeamWorkbenchSummaryPanel", () => {
expect(container.textContent).toContain("工具活动 1 条");
expect(container.textContent).toContain("近期消息 1 条");
expect(container.textContent).toContain(
"这里只消费子会话详情里的 queued_turns 与 thread item 类型",
"这里只消费子会话详情里的输入队列与历史正文类型",
);
expect(container.textContent).toContain(
"继续处理 Agent UI transcript drilldown",
);
expect(container.textContent).toContain("图片 1");
expect(container.textContent).toContain("请选择修复策略");
expect(container.textContent).toContain("request_user_input");
expect(container.textContent).toContain("等待补充");
expect(container.textContent).toContain("工具活动");
expect(container.textContent).toContain("tool_call");
expect(container.textContent).toContain("工具调用");
expect(container.textContent).toContain("子会话进展");
expect(container.textContent).toContain("历史正文 3 条");
expect(container.textContent).toContain("回复");
expect(container.textContent).toContain(
"历史正文:已完成差异梳理,并整理出两个未对齐项。",
);
expect(container.textContent).toContain("不把队友输出混进主 final answer");
expect(container.textContent).toContain("不把队友输出混进主回复");
});
it("应在 Team Workbench 中展示 reassignment live board update", () => {
@@ -975,10 +975,10 @@ describe("TeamWorkbenchSummaryPanel", () => {
expect(container.textContent).toContain("已定位工作台目标");
expect(container.textContent).toContain("工作项已定位");
expect(container.textContent).toContain(
"已定位结构化 work_board source;可通过负责人选择器回填 TaskUpdate 指令,等待 owner_change source 确认。",
"已定位结构化任务板记录;可通过负责人选择器回填负责人更新指令,等待运行时确认负责人变化。",
);
expect(container.textContent).toContain("重新指派 · work-item-2");
expect(container.textContent).toContain("work_board");
expect(container.textContent).toContain("任务板");
});
it("应为 work_board source fact 提供重指派 selector 并回填 TaskUpdate 指令", async () => {
@@ -1055,9 +1055,9 @@ describe("TeamWorkbenchSummaryPanel", () => {
);
expect(selector).not.toBeNull();
expect(container.textContent).toContain("负责人重指派");
expect(container.textContent).toContain("TaskUpdate owner_change");
expect(container.textContent).toContain("负责人更新");
expect(container.textContent).toContain(
"仍以 TaskUpdate 返回的 owner_change / ownerChange metadata 作为唯一负责人变化 source",
"仍以运行时返回的负责人变化作为唯一事实",
);
await act(async () => {
@@ -1089,7 +1089,7 @@ describe("TeamWorkbenchSummaryPanel", () => {
expect(onWorkbenchReassign.mock.calls[0]?.[1]).toBe("复核员");
expect(container.textContent).toContain("重指派已回填");
expect(container.textContent).toContain(
"TaskUpdate owner 更新指令已回填;发送后由真实 owner_change source 确认负责人变化。",
"负责人更新指令已回填;发送并执行后,以运行时返回的负责人变化为准。",
);
});
@@ -1171,13 +1171,13 @@ describe("TeamWorkbenchSummaryPanel", () => {
expect(container.textContent).toContain("已定位工作台目标");
expect(container.textContent).toContain("工作项未接入");
expect(container.textContent).toContain(
"真实 board/team API 写回或执行发起接入前不伪造工作项状态。",
"真实任务板或团队 API 写回接入前不伪造工作项状态。",
);
expect(container.textContent).toContain(`结果引用:${resultRef}`);
expect(container.textContent).toContain(`Artifact paths:${artifactPath}`);
expect(container.textContent).toContain("相关队友链路");
expect(container.textContent).toContain("artifact ref");
expect(container.textContent).toContain("artifact_workspace");
expect(container.textContent).toContain("产物引用");
expect(container.textContent).toContain("产物工作区");
expect(container.textContent).toContain("runtime-json");
});
});
@@ -147,7 +147,7 @@ function buildWorkbenchActionRouteStatus(
return {
label: "只定位",
detail:
"该目标已在 Agent UI 标准 surface 中定位,当前没有可执行的本地 child session handler。",
"该目标已在 Agent UI 标准工作区中定位,当前没有可执行的本地子任务处理器。",
className: "border-sky-200 bg-sky-50 text-sky-700",
};
case "seeded_work_item":
@@ -168,62 +168,62 @@ function buildWorkbenchActionRouteStatus(
return {
label: "重指派已回填",
detail:
"TaskUpdate owner 更新指令已回填;发送后由真实 owner_change source 确认负责人变化。",
"负责人更新指令已回填;发送并执行后,以运行时返回的负责人变化为准。",
className: "border-emerald-200 bg-emerald-50 text-emerald-700",
};
case "work_item_source_located":
return {
label: "工作项已定位",
detail:
"已定位结构化 work_board source;可通过负责人选择器回填 TaskUpdate 指令,等待 owner_change source 确认。",
"已定位结构化任务板记录;可通过负责人选择器回填负责人更新指令,等待运行时确认负责人变化。",
className: "border-sky-200 bg-sky-50 text-sky-700",
};
case "remote_task_source_located":
return {
label: "远端任务已定位",
detail:
"已定位结构化 remote_task source;当前只展示来源、状态与结果引用,不伪造远端运行时控制。",
"已定位结构化远端任务记录;当前只展示来源、状态与结果引用,不伪造远端运行时控制。",
className: "border-sky-200 bg-sky-50 text-sky-700",
};
case "handoff_source_located":
return {
label: "交接已定位",
detail:
"已定位结构化 handoff_lane source;当前只展示 handoff 生命周期,不从文本伪造跨 Agent 控制。",
"已定位结构化交接记录;当前只展示交接生命周期,不从文本伪造跨 Agent 控制。",
className: "border-sky-200 bg-sky-50 text-sky-700",
};
case "unsupported_remote":
return {
label: "远端未接入",
detail:
"remote teammate 需要真实 remote ingress / A2A task source 后才能运行时控制。",
"远端队友需要接入真实远端入口或 A2A 任务记录后才能运行时控制。",
className: "border-amber-200 bg-amber-50 text-amber-700",
};
case "unsupported_review":
return {
label: "审核未接入",
detail:
"review lane 已定位;真实审核 callback / reassignment 写回仍需后续接入。",
"评审记录已定位;真实审核回调与重指派写回仍需后续接入。",
className: "border-amber-200 bg-amber-50 text-amber-700",
};
case "unsupported_handoff":
return {
label: "交接未接入",
detail:
"handoff lane 已定位;accepted / returned / resumed 真实 source 接入前不伪造状态。",
"交接记录已定位;真实接收、退回、恢复记录接入前不伪造状态。",
className: "border-amber-200 bg-amber-50 text-amber-700",
};
case "unsupported_work_item":
return {
label: "工作项未接入",
detail:
"work_board 目标已定位;真实 board/team API 写回或执行发起接入前不伪造工作项状态。",
"任务板目标已定位;真实任务板或团队 API 写回接入前不伪造工作项状态。",
className: "border-amber-200 bg-amber-50 text-amber-700",
};
case "unsupported":
return {
label: "暂不支持",
detail: "该 Agent UI target 暂无可执行的本地运行时 handler。",
detail: "该 Agent UI 目标暂无可执行的本地运行时处理器。",
className: "border-slate-200 bg-slate-50 text-slate-600",
};
default:
@@ -702,6 +702,59 @@ const RECENT_MESSAGE_SOURCE_TYPES = new Set([
"user_message",
]);
const TRANSCRIPT_ENTRY_SOURCE_LABELS: Record<string, string> = {
agent_message: "Agent 回复",
approval_request: "等待确认",
command_execution: "命令执行",
message_fallback: "消息",
plan: "计划",
reasoning: "推理",
request_user_input: "等待补充",
tool_call: "工具调用",
turn_summary: "回合摘要",
user_message: "用户消息",
web_search: "联网搜索",
};
const AGENT_UI_SURFACE_LABELS: Record<string, string> = {
artifact_workspace: "产物工作区",
background_teammate: "后台队友",
conversation: "对话",
delegation_graph: "分派关系",
diagnostics: "诊断",
handoff_lane: "交接",
hitl: "人工确认",
inline_process: "过程",
remote_teammate: "远程队友",
review_lane: "评审",
runtime_status: "运行状态",
session_tabs: "会话",
task_capsule: "任务",
team_policy: "团队策略",
team_roster: "成员",
teammate_transcript: "队友记录",
timeline_evidence: "证据",
tool_ui: "工具界面",
work_board: "任务板",
worker_notifications: "执行通知",
};
function formatTranscriptEntrySourceLabel(sourceType?: string | null): string {
const normalized = sourceType?.trim();
if (!normalized) {
return "活动";
}
return TRANSCRIPT_ENTRY_SOURCE_LABELS[normalized] ?? "活动";
}
function formatAgentUiSurfaceLabel(surface?: string | null): string {
const normalized = surface?.trim();
if (!normalized) {
return "工作区";
}
return AGENT_UI_SURFACE_LABELS[normalized] ?? "工作区";
}
function matchesTranscriptEntrySource(
entry: TeamWorkspaceActivityEntry,
sourceTypes: Set<string>,
@@ -716,7 +769,7 @@ function buildTranscriptEntryGroups(
{
kind: "pending_input",
label: "待处理输入",
description: "来自 request_user_input / approval_request 结构化 item。",
description: "等待你确认或补充的结构化请求。",
entries: entries.filter((entry) =>
matchesTranscriptEntrySource(entry, PENDING_INPUT_SOURCE_TYPES),
),
@@ -724,7 +777,7 @@ function buildTranscriptEntryGroups(
{
kind: "tool_activity",
label: "工具活动",
description: "来自 tool_call / command_execution / web_search。",
description: "工具、命令和联网搜索的最新动作。",
entries: entries.filter((entry) =>
matchesTranscriptEntrySource(entry, TOOL_ACTIVITY_SOURCE_TYPES),
),
@@ -732,7 +785,7 @@ function buildTranscriptEntryGroups(
{
kind: "recent_messages",
label: "近期消息",
description: "来自 user / agent / reasoning / plan 的有界预览。",
description: "用户消息、Agent 回复、推理和计划的有界预览。",
entries: entries.filter((entry) =>
matchesTranscriptEntrySource(entry, RECENT_MESSAGE_SOURCE_TYPES),
),
@@ -1273,7 +1326,7 @@ export function TeamWorkbenchSummaryPanel({
<Workflow className="h-3.5 w-3.5" />
<span>Agent UI v0.6</span>
<span className="rounded-full border border-emerald-200 bg-emerald-50 px-2 py-0.5 text-[10px] font-medium tracking-normal text-emerald-700 normal-case">
{teamWorkbenchProjectionSummary.total} events
{teamWorkbenchProjectionSummary.total} 条事件
</span>
</div>
<div className="mt-3 grid gap-2 sm:grid-cols-3">
@@ -1288,7 +1341,9 @@ export function TeamWorkbenchSummaryPanel({
<div className="mt-1 text-lg font-semibold text-slate-900">
{card.value}
</div>
<div className="text-[10px] text-slate-500">{card.hint}</div>
<div className="text-[10px] text-slate-500">
{formatAgentUiSurfaceLabel(card.hint)}
</div>
</div>
))}
</div>
@@ -1322,7 +1377,11 @@ export function TeamWorkbenchSummaryPanel({
<span className="font-semibold text-slate-700">
{formatAgentUiProjectionEventType(event.type)}
</span>
{event.surface ? <span>{event.surface}</span> : null}
{event.surface ? (
<span>
{formatAgentUiSurfaceLabel(event.surface)}
</span>
) : null}
</div>
<div className="mt-0.5 truncate text-[10px] text-slate-500">
{formatAgentUiProjectionEventDetail(event)}
@@ -1356,14 +1415,14 @@ export function TeamWorkbenchSummaryPanel({
</span>
{selectedWorkbenchItem.event.surface ? (
<span className="rounded-full border border-slate-200 bg-white px-2 py-0.5 text-[10px] text-slate-500">
{selectedWorkbenchItem.event.surface}
{formatAgentUiSurfaceLabel(
selectedWorkbenchItem.event.surface,
)}
</span>
) : null}
</div>
<p className="mt-1 text-[10px] leading-4 text-slate-600">
这里只定位 Agent UI 标准 surface
中的目标,不从文本推断状态,也不伪造 remote / review / handoff
运行时调用。
这里只定位 Agent UI 标准工作区中的目标,不从文本推断状态,也不伪造远端、评审或交接运行时调用。
</p>
{selectedWorkbenchActionRouteStatus ? (
<div
@@ -1409,13 +1468,12 @@ export function TeamWorkbenchSummaryPanel({
负责人重指派
</span>
<span className="rounded-full border border-emerald-200 bg-emerald-50 px-2 py-0.5 text-[10px] text-emerald-700">
TaskUpdate owner_change
负责人更新
</span>
</div>
<p className="mt-1 text-[10px] leading-4 text-slate-600">
选择目标负责人后只回填 TaskUpdate 指令;发送并执行后,Agent
UI 仍以 TaskUpdate 返回的 owner_change / ownerChange
metadata 作为唯一负责人变化 source。
选择目标负责人后只回填负责人更新指令;发送并执行后,Agent
UI 仍以运行时返回的负责人变化作为唯一事实。
</p>
<div className="mt-2 flex flex-wrap items-center gap-2">
<select
@@ -1465,13 +1523,11 @@ export function TeamWorkbenchSummaryPanel({
相关队友链路
</span>
<span className="rounded-full border border-slate-200 bg-slate-50 px-2 py-0.5 text-[10px] text-slate-500">
{selectedWorkbenchRelatedEvents.length} events
{selectedWorkbenchRelatedEvents.length} 条事件
</span>
</div>
<p className="mt-1 text-[10px] leading-4 text-slate-600">
按 agent / task / work item / transcript / artifact ref
精确匹配同一目标的 projection
事件;这里只串联队友状态,不合成新运行时事实。
按成员、任务、工作项、队友记录与产物引用精确匹配同一目标的事件;这里只串联队友状态,不合成新运行时事实。
</p>
<div className="mt-2 space-y-1.5">
{selectedWorkbenchRelatedEvents.map((event) => {
@@ -1490,7 +1546,9 @@ export function TeamWorkbenchSummaryPanel({
{formatAgentUiProjectionPhase(event.phase)}
</span>
{event.surface ? (
<span>{event.surface}</span>
<span>
{formatAgentUiSurfaceLabel(event.surface)}
</span>
) : null}
</div>
<div className="mt-0.5 truncate text-[10px] text-slate-500">
@@ -1511,20 +1569,18 @@ export function TeamWorkbenchSummaryPanel({
<div className="mt-3 rounded-2xl border border-slate-200 bg-white px-3 py-3">
<div className="flex flex-wrap items-center gap-2 text-xs">
<span className="font-semibold text-slate-900">
Transcript Zoom
队友记录详情
</span>
<span className="rounded-full border border-slate-200 bg-slate-50 px-2 py-0.5 text-[10px] text-slate-500">
teammate_transcript
队友记录
</span>
</div>
<p className="mt-1 text-[10px] leading-4 text-slate-600">
正文由子会话 / Team board 选中成员 activity preview
读取;这里仅展示标准 ref 与焦点,不把队友输出混进主 final
answer。
正文由子会话 / 团队任务板选中成员活动预览读取;这里仅展示标准引用与焦点,不把队友输出混进主回复。
</p>
<div className="mt-2 flex flex-wrap gap-1.5">
<span className="rounded-full border border-slate-200 bg-slate-50 px-2 py-0.5 text-[10px] text-slate-600">
Transcript Ref:
记录引用:
{selectedWorkbenchTranscriptZoom.transcriptRef}
</span>
{selectedWorkbenchTranscriptZoom.parentSessionId ? (
@@ -1598,9 +1654,7 @@ export function TeamWorkbenchSummaryPanel({
))}
</div>
<p className="mt-1 text-[10px] leading-4 text-slate-600">
这里只消费子会话详情里的 queued_turns 与 thread item
类型;没有结构化 source 时不合成 pending / tool /
message 状态。
这里只消费子会话详情里的输入队列与历史正文类型;没有结构化记录时不合成等待、工具或消息状态。
</p>
{selectedTranscriptQueuedTurns.length > 0 ? (
<div className="mt-2 rounded-xl border border-amber-200 bg-amber-50 px-2 py-2">
@@ -1654,7 +1708,9 @@ export function TeamWorkbenchSummaryPanel({
</span>
{entry.sourceType ? (
<span className="rounded-full border border-slate-200 bg-white px-1.5 py-0.5 text-slate-500">
{entry.sourceType}
{formatTranscriptEntrySourceLabel(
entry.sourceType,
)}
</span>
) : null}
</div>
@@ -1726,7 +1782,7 @@ export function TeamWorkbenchSummaryPanel({
) : selectedTranscriptHistoryStatus === "loading" ? (
<p className="mt-3 rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-[10px] leading-4 text-amber-700">
正在从子会话读取历史正文;读取结果仍只作为
teammate_transcript preview,不进入主 final answer。
队友记录预览,不进入主回复。
</p>
) : selectedTranscriptHistoryStatus === "error" ? (
<p className="mt-3 rounded-xl border border-rose-200 bg-rose-50 px-3 py-2 text-[10px] leading-4 text-rose-700">
@@ -1748,7 +1804,7 @@ export function TeamWorkbenchSummaryPanel({
{agentUiSurfaceSummaries.length > 0 ? (
<div className="mt-3 space-y-2">
<div className="text-[11px] font-semibold uppercase tracking-[0.12em] text-slate-500">
Surface 专门视图
工作区专门视图
</div>
{agentUiSurfaceSummaries.map((surface, index) => (
<details
@@ -1762,7 +1818,7 @@ export function TeamWorkbenchSummaryPanel({
{surface.label}
</span>
<span className="ml-2 text-[10px] text-slate-500">
{surface.surface}
{formatAgentUiSurfaceLabel(surface.surface)}
</span>
</span>
<span className="rounded-full border border-slate-200 bg-slate-50 px-2 py-0.5 text-[10px] text-slate-500">
@@ -1821,7 +1877,7 @@ export function TeamWorkbenchSummaryPanel({
</span>
{event.surface ? (
<span className="rounded-full border border-slate-200 bg-slate-50 px-2 py-0.5 text-[10px] text-slate-500">
{event.surface}
{formatAgentUiSurfaceLabel(event.surface)}
</span>
) : null}
</div>
@@ -20,7 +20,14 @@ export const upsertAssistantActionRequest = ({
setPendingActions,
setMessages,
}: UpsertAssistantActionRequestOptions) => {
const promptKey = replaceByPrompt ? resolveActionPromptKey(actionData) : null;
const scopedActionData: ActionRequired = {
...actionData,
sourceMessageId: actionData.sourceMessageId || assistantMsgId,
status: actionData.status || "pending",
};
const promptKey = replaceByPrompt
? resolveActionPromptKey(scopedActionData)
: null;
setPendingActions((prev) => {
let next = [...prev];
@@ -29,15 +36,15 @@ export const upsertAssistantActionRequest = ({
next = next.filter((item) => {
const itemKey = resolveActionPromptKey(item);
return !(
item.requestId !== actionData.requestId &&
item.requestId !== scopedActionData.requestId &&
itemKey &&
itemKey === promptKey
);
});
}
next = next.filter((item) => item.requestId !== actionData.requestId);
next.push(actionData);
next = next.filter((item) => item.requestId !== scopedActionData.requestId);
next.push(scopedActionData);
return next;
});
@@ -52,7 +59,7 @@ export const upsertAssistantActionRequest = ({
nextRequests = nextRequests.filter((item) => {
const itemKey = resolveActionPromptKey(item);
return !(
item.requestId !== actionData.requestId &&
item.requestId !== scopedActionData.requestId &&
itemKey &&
itemKey === promptKey
);
@@ -61,24 +68,24 @@ export const upsertAssistantActionRequest = ({
(part) =>
!(
part.type === "action_required" &&
part.actionRequired.requestId !== actionData.requestId &&
part.actionRequired.requestId !== scopedActionData.requestId &&
resolveActionPromptKey(part.actionRequired) === promptKey
),
);
}
nextRequests = nextRequests.filter(
(item) => item.requestId !== actionData.requestId,
(item) => item.requestId !== scopedActionData.requestId,
);
nextParts = nextParts.filter(
(part) =>
!(
part.type === "action_required" &&
part.actionRequired.requestId === actionData.requestId
part.actionRequired.requestId === scopedActionData.requestId
),
);
nextRequests.push(actionData);
nextParts = appendActionRequiredToParts(nextParts, actionData);
nextRequests.push(scopedActionData);
nextParts = appendActionRequiredToParts(nextParts, scopedActionData);
return {
...msg,
@@ -962,7 +962,7 @@ describe("agentStreamRuntimeHandler", () => {
expect(messages[0]?.thinkingContent).toBe("先想第一段。再想第二段。");
});
it("高频 reasoning item_updated 事件不应持续刷新时间线状态", () => {
it("reasoning item_updated 应持续刷新时间线思考内容", () => {
const setThreadItems = vi.fn();
const activateStream = vi.fn();
@@ -1026,7 +1026,20 @@ describe("agentStreamRuntimeHandler", () => {
});
expect(activateStream).toHaveBeenCalledTimes(1);
expect(setThreadItems).not.toHaveBeenCalled();
expect(setThreadItems).toHaveBeenCalledTimes(1);
const updater = setThreadItems.mock.calls[0]?.[0];
expect(typeof updater).toBe("function");
const nextItems =
typeof updater === "function" ? updater([]) : updater;
expect(nextItems).toEqual([
expect.objectContaining({
id: "reasoning-1",
type: "reasoning",
text: "正在持续追加推理文本",
status: "in_progress",
}),
]);
});
it("收到 final_done 时应剥离 assistant 正文中的工具协议残留", () => {
@@ -260,6 +260,29 @@ interface HandleTurnStreamEventOptions {
setIsSending: Dispatch<SetStateAction<boolean>>;
}
function bindAssistantMessageToRuntimeTurn(
setMessages: Dispatch<SetStateAction<Message[]>>,
assistantMsgId: string,
turnId?: string | null,
) {
const normalizedTurnId = turnId?.trim();
if (!normalizedTurnId) {
return;
}
setMessages((prev) =>
prev.map((message) =>
message.id === assistantMsgId &&
message.runtimeTurnId !== normalizedTurnId
? {
...message,
runtimeTurnId: normalizedTurnId,
}
: message,
),
);
}
function finishRequestLog(
requestState: StreamRequestState,
payload: AgentStreamRequestLogFinishPayload,
@@ -721,6 +744,11 @@ export function handleTurnStreamEvent({
case "turn_started":
clearQueuedDraftCleanupTimer();
activateStream();
bindAssistantMessageToRuntimeTurn(
setMessages,
assistantMsgId,
data.turn.id,
);
setCurrentTurnId(data.turn.id);
setThreadTurns((prev) =>
upsertThreadTurnState(
@@ -750,6 +778,11 @@ export function handleTurnStreamEvent({
case "item_started":
case "item_completed":
activateStream();
bindAssistantMessageToRuntimeTurn(
setMessages,
assistantMsgId,
data.item.turn_id,
);
setThreadItems((prev) =>
upsertThreadItemState(
removeThreadItemState(prev, pendingItemKey),
@@ -760,6 +793,11 @@ export function handleTurnStreamEvent({
case "item_updated":
activateStream();
bindAssistantMessageToRuntimeTurn(
setMessages,
assistantMsgId,
data.item.turn_id,
);
if (shouldDeferAgentStreamThreadItemUpdate(data.item)) {
break;
}
@@ -775,6 +813,11 @@ export function handleTurnStreamEvent({
case "turn_failed":
clearQueuedDraftCleanupTimer();
activateStream();
bindAssistantMessageToRuntimeTurn(
setMessages,
assistantMsgId,
data.turn.id,
);
clearOptimisticItem();
setThreadTurns((prev) =>
upsertThreadTurnState(
@@ -1132,6 +1175,11 @@ export function handleTurnStreamEvent({
clearOptimisticItem();
}
}
bindAssistantMessageToRuntimeTurn(
setMessages,
assistantMsgId,
data.scope?.turn_id,
);
handleActionRequiredEvent({
data,
eventName,
@@ -257,6 +257,7 @@ export function createAgentStreamSubmissionLifecycle(
? {
...msg,
runtimeStatus: effectiveWaitingRuntimeStatus,
runtimeTurnId: msg.runtimeTurnId || pendingTurnKey,
}
: msg,
),
@@ -299,6 +300,16 @@ export function createAgentStreamSubmissionLifecycle(
};
if (!expectingQueue) {
setMessages((prev) =>
prev.map((msg) =>
msg.id === assistantMsgId
? {
...msg,
runtimeTurnId: msg.runtimeTurnId || pendingTurnKey,
}
: msg,
),
);
setThreadTurns((prev) =>
upsertThreadTurnState(prev, {
id: pendingTurnKey,
@@ -31,8 +31,11 @@ const turn: AgentThreadTurn = {
};
describe("agentStreamThreadItemController", () => {
it("应延后 in-progress reasoning 与 agent_message 高频更新", () => {
expect(shouldDeferAgentStreamThreadItemUpdate(threadItem())).toBe(true);
it("应保留运行中 reasoning 更新,避免思考区只停在首个片段", () => {
expect(shouldDeferAgentStreamThreadItemUpdate(threadItem())).toBe(false);
});
it("仍应延后 in-progress agent_message 高频正文快照", () => {
expect(
shouldDeferAgentStreamThreadItemUpdate(
threadItem({ type: "agent_message", text: "hello" }),
@@ -3,10 +3,7 @@ import type { AgentThreadItem, AgentThreadTurn } from "@/lib/api/agentProtocol";
export function shouldDeferAgentStreamThreadItemUpdate(
item: AgentThreadItem,
): boolean {
return (
item.status === "in_progress" &&
(item.type === "reasoning" || item.type === "agent_message")
);
return item.status === "in_progress" && item.type === "agent_message";
}
export function buildAgentStreamTurnStartedPendingItemUpdate(params: {
@@ -1,6 +1,7 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type Dispatch,
@@ -24,6 +25,10 @@ import { markThreadActionItemSubmitted } from "./agentThreadState";
import { buildActionRequestSubmissionContext } from "../utils/actionRequestA2UI";
import { buildActionResumeRuntimeStatus } from "../utils/agentRuntimeStatus";
import { governActionRequest } from "../utils/actionRequestGovernance";
import {
filterActionsForCurrentAssistantTail,
findActionRequestSourceMessageId,
} from "../utils/currentTurnActionRequests";
interface UseAgentToolsOptions {
runtime: AgentRuntimeAdapter;
@@ -47,6 +52,45 @@ function upsertSubmittedAction(
return next;
}
function findActionRequestInMessages(
messages: Message[],
requestId: string,
): ActionRequired | undefined {
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index];
if (message.role !== "assistant" || !message.actionRequests?.length) {
continue;
}
const action = message.actionRequests.find(
(item) => item.requestId === requestId,
);
if (action) {
return {
...action,
sourceMessageId: action.sourceMessageId || message.id,
};
}
}
return undefined;
}
function attachActionSourceFromMessages(
action: ActionRequired,
messages: Message[],
): ActionRequired {
if (action.sourceMessageId) {
return action;
}
const sourceMessageId = findActionRequestSourceMessageId(
messages,
action.requestId,
);
return sourceMessageId ? { ...action, sourceMessageId } : action;
}
export function useAgentTools(options: UseAgentToolsOptions) {
const {
runtime,
@@ -69,6 +113,7 @@ export function useAgentTools(options: UseAgentToolsOptions) {
string,
Omit<ConfirmResponse, "requestId"> & {
requestId: string;
sourceMessageId?: string;
}
>
>(new Map());
@@ -80,11 +125,9 @@ export function useAgentTools(options: UseAgentToolsOptions) {
const pendingAction = pendingActions.find(
(item) => item.requestId === response.requestId,
);
const persistedActionRaw =
pendingAction ||
messages
.flatMap((message) => message.actionRequests || [])
.find((item) => item.requestId === response.requestId);
const persistedActionRaw = pendingAction
? attachActionSourceFromMessages(pendingAction, messages)
: findActionRequestInMessages(messages, response.requestId);
const persistedAction = persistedActionRaw
? governActionRequest(persistedActionRaw)
: undefined;
@@ -133,11 +176,18 @@ export function useAgentTools(options: UseAgentToolsOptions) {
if (persistedAction?.isFallback) {
const fallbackPromptKey = resolveActionPromptKey(persistedAction);
if (fallbackPromptKey) {
const fallbackSourceMessageId = persistedAction.sourceMessageId;
const resolvedAction = pendingActions.find((item) => {
if (item.requestId === persistedAction.requestId) return false;
if (item.isFallback) return false;
if (item.actionType !== persistedAction.actionType)
return false;
if (
fallbackSourceMessageId &&
item.sourceMessageId !== fallbackSourceMessageId
) {
return false;
}
return resolveActionPromptKey(item) === fallbackPromptKey;
});
@@ -147,6 +197,7 @@ export function useAgentTools(options: UseAgentToolsOptions) {
actionType,
requestId: persistedAction.requestId,
userData,
sourceMessageId: fallbackSourceMessageId,
});
setPendingActions((prev) =>
prev.map((item) =>
@@ -353,8 +404,20 @@ export function useAgentTools(options: UseAgentToolsOptions) {
],
);
const visiblePendingActions = useMemo(
() => filterActionsForCurrentAssistantTail(pendingActions, messages),
[messages, pendingActions],
);
const visibleSubmittedActionsInFlight = useMemo(
() =>
filterActionsForCurrentAssistantTail(submittedActionsInFlight, messages, {
keepUnscoped: true,
}),
[messages, submittedActionsInFlight],
);
useEffect(() => {
for (const pendingAction of pendingActions) {
for (const pendingAction of visiblePendingActions) {
if (
pendingAction.isFallback ||
pendingAction.status === "submitted" ||
@@ -373,6 +436,12 @@ export function useAgentTools(options: UseAgentToolsOptions) {
if (!queuedResponse) {
continue;
}
if (
queuedResponse.sourceMessageId &&
pendingAction.sourceMessageId !== queuedResponse.sourceMessageId
) {
continue;
}
queuedFallbackResponsesRef.current.delete(promptKey);
void confirmAction({
@@ -382,7 +451,7 @@ export function useAgentTools(options: UseAgentToolsOptions) {
});
break;
}
}, [confirmAction, pendingActions]);
}, [confirmAction, visiblePendingActions]);
const handlePermissionResponse = useCallback(
async (response: ConfirmResponse) => {
@@ -470,8 +539,8 @@ export function useAgentTools(options: UseAgentToolsOptions) {
);
return {
pendingActions,
submittedActionsInFlight,
pendingActions: visiblePendingActions,
submittedActionsInFlight: visibleSubmittedActionsInFlight,
setPendingActions,
warnedKeysRef,
confirmAction,

Some files were not shown because too many files have changed in this diff Show More