From e0b74fea7b66be92419bb41a30c0ccd2a303eae1 Mon Sep 17 00:00:00 2001 From: wh-1 <35734458+wh-1@users.noreply.github.com> Date: Sat, 19 Sep 2026 03:11:37 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E4=BC=9A=E8=AF=9D=E6=B8=85=E7=90=86):=20?= =?UTF-8?q?=E6=B8=85=E7=90=86=E6=97=A7=E4=BC=9A=E8=AF=9D=E2=80=94=E2=80=94?= =?UTF-8?q?=E6=9C=AC=E5=9C=B0=E8=BD=AF=E5=88=A0=20+=20=E4=BA=91=E7=AB=AF?= =?UTF-8?q?=E5=90=8C=E5=88=A0=20+=20=E5=AF=B9=E8=B4=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增「清理旧会话」:每账号每项目保留 updated_at 最新 keep 条,其余软删; 已上云的会话用该账号 token 同步删除云端,并追加对账与云端全貌两阶段。 - core 新增三个模块:session_slim(清理主流程)/ cloud_conv(云端删除, 含映射库代次探测与全账枚举)/ cloud_reconcile(残留清理 + 清单) - 归属判据 = edge-sync-mapping 库的 msg_channel,只删目标账号自己的云端会话, 删不掉(403/404/网络)如实记进报告,绝不静默吞错 - 真跑前自动备份 workbuddy.db(含 -wal/-shm),拷不动就不给回滚点并如实上报 - 桌面端:账号卡片菜单新增「清理旧会话」,预览(dry_run)确认后再执行 - 顺带修掉 9 个 Windows 上必然失败的用例(上游 main 同样失败): · 路径断言按 `/` 归一化后再比较(variant / session) · token_stats 三处 fixture 改用 write(true) 句柄 pin mtime (Windows 的 SetFileTime 需要 FILE_WRITE_ATTRIBUTES,只读句柄 Access Denied) · codebuddy_cli::node_path_from_shell_output 接受 Unix 风格根路径、 export_import 的路径校验同口径 —— 修产品代码,不用 cfg 屏蔽绕开 · rate_limit_hook 的断言改用 script_name(),不再写死 hook.sh --- Cargo.lock | 3 + crates/wb-switch-core/Cargo.toml | 2 +- .../wb-switch-core/src/modules/cloud_conv.rs | 570 +++++++++++++ .../src/modules/cloud_reconcile.rs | 379 +++++++++ .../src/modules/codebuddy_cli.rs | 8 +- .../src/modules/export_import.rs | 3 +- crates/wb-switch-core/src/modules/mod.rs | 3 + .../src/modules/rate_limit_hook.rs | 2 +- crates/wb-switch-core/src/modules/session.rs | 8 +- .../src/modules/session_slim.rs | 788 ++++++++++++++++++ .../wb-switch-core/src/modules/token_stats.rs | 46 +- crates/wb-switch-core/src/modules/variant.rs | 11 +- src-tauri/src/commands.rs | 32 +- src-tauri/src/lib.rs | 1 + src/components/account-card.tsx | 8 +- src/components/cleanup-sessions-dialog.tsx | 156 ++++ src/lib/api.ts | 12 + src/pages/AccountsPage.tsx | 13 + 18 files changed, 2017 insertions(+), 28 deletions(-) create mode 100644 crates/wb-switch-core/src/modules/cloud_conv.rs create mode 100644 crates/wb-switch-core/src/modules/cloud_reconcile.rs create mode 100644 crates/wb-switch-core/src/modules/session_slim.rs create mode 100644 src/components/cleanup-sessions-dialog.tsx diff --git a/Cargo.lock b/Cargo.lock index 559d1d2f..b6422941 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1353,6 +1353,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -3456,7 +3457,9 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64 0.22.1", "bytes", + "futures-channel", "futures-core", + "futures-util", "http", "http-body", "http-body-util", diff --git a/crates/wb-switch-core/Cargo.toml b/crates/wb-switch-core/Cargo.toml index 44f8263d..e39a0b51 100644 --- a/crates/wb-switch-core/Cargo.toml +++ b/crates/wb-switch-core/Cargo.toml @@ -7,7 +7,7 @@ description = "wb-switch 核心逻辑(账号/切换/会话/签到/更新), [dependencies] serde = { version = "1", features = ["derive"] } serde_json = "1" -reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "blocking"] } tokio = { version = "1", features = ["rt", "sync", "time", "macros"] } uuid = { version = "1", features = ["v4"] } dirs = "5" diff --git a/crates/wb-switch-core/src/modules/cloud_conv.rs b/crates/wb-switch-core/src/modules/cloud_conv.rs new file mode 100644 index 00000000..252c2ab2 --- /dev/null +++ b/crates/wb-switch-core/src/modules/cloud_conv.rs @@ -0,0 +1,570 @@ +//! 云端会话删除。 +//! +//! 背景(机制溯源): +//! 官方「删除对话连带删云端」的实现是 **App 内 edge-sync 扩展**监听本地删除事件 +//! (`wb:conversation:deleted`)后调 +//! `POST /console/as/conversations/{sid}/delete`,**用当前登录账号的凭证**。 +//! 会话瘦身只做本机软删 ⇒ 云端那份留在原地(手机端仍可见、本机又已无入口)。 +//! 本模块把「云端删除」补齐,让瘦身能「本地 + 云端一起瘦」。 +//! +//! 三条铁律(违反任一条都会复刻「幽灵会话」故障): +//! 1. **必须用该会话云端归属账号的 token** —— 服务端按 token 的 uid 校验, +//! 用错账号返回 `403 access denied`(实测),绝不会误删,但也删不掉; +//! 2. **404 有三义**(真已删 / 账号不对 / id 不存在)⇒ 调用方必须先按归属筛过, +//! 再谈 404 —— 不可拿 404 当「账号没问题」的证据; +//! 3. **先删云端成功、再本地软删** —— 顺序反转才可回退。 +//! +//! 归属判据:`/edge-sync-mapping-v4.db` 的 +//! `edge_sync_mapping.msg_channel = convmsg:`。 +//! ⚠️ 该表**会记错**(实测 ~20% 偏差)⇒ 只用来缩小范围,真正的把关是服务端 403/200。 + +use rusqlite::Connection; +use serde_json::Value; +use std::collections::HashMap; +use std::path::PathBuf; + +use crate::modules::account; +use crate::modules::config::home_dir; + +/// 取账号 uid(空串兜底)。 +fn account_uid(acc: &Value) -> String { + acc.get("uid") + .and_then(|v| v.as_str()) + .map(|s| s.trim().to_string()) + .unwrap_or_default() +} + + +/// 官方网关(与 `ui_theme` 一致;本机 WARP 需 `no_proxy` 直连)。 +pub const CLOUD_BASE: &str = "https://www.workbuddy.cn"; + +/// 云端删除结果。调用方按它决定「是否软删本地」。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CloudDelete { + /// 云端已删(HTTP 200 或业务 code==0)。 + Deleted, + /// 云端本来就没有(HTTP 404)—— 归属已校验的前提下视为成功。 + AlreadyGone, + /// `403 conversation access denied`:**该会话不归这个 token 的账号** + /// ⇒ 调用方应放弃云端删除(但本地归属不受影响,可照常软删)。 + Forbidden, + /// 网络/服务错误 ⇒ **不要本地软删**,留待下次重试。 + Failed(String), +} + +impl CloudDelete { + /// 是否可视为「云端这一侧已处理完」。 + pub fn ok(&self) -> bool { + matches!(self, CloudDelete::Deleted | CloudDelete::AlreadyGone) + } +} + +fn http() -> reqwest::blocking::Client { + reqwest::blocking::Client::builder() + .timeout(std::time::Duration::from_secs(20)) + // 本机 WARP 代理会碍事;workbuddy.cn 直连可达 + .no_proxy() + .build() + .unwrap_or_else(|_| reqwest::blocking::Client::new()) +} + +/// 从 HTTP 状态 + 响应体判定结果。抽出来便于单测(不需要真发请求)。 +pub fn classify(status: u16, body: &str) -> CloudDelete { + match status { + 200..=299 => { + // 官方可能 200 带业务错误码;code 缺失或为 0 才认成功 + let code = serde_json::from_str::(body) + .ok() + .and_then(|v| v.get("code").and_then(|c| c.as_i64())); + match code { + None | Some(0) => CloudDelete::Deleted, + Some(c) => CloudDelete::Failed(format!("http 200 但业务 code={c}")), + } + } + 404 => CloudDelete::AlreadyGone, + 403 => CloudDelete::Forbidden, + s => CloudDelete::Failed(format!("http {s}")), + } +} + +/// 删除一条云端会话。`sid` 既是 session id 也是 conversation id(实测一致,158/158)。 +pub fn delete_conversation(token: &str, sid: &str) -> CloudDelete { + if token.is_empty() || sid.is_empty() { + return CloudDelete::Failed("缺 token 或 sid".into()); + } + let url = format!("{CLOUD_BASE}/console/as/conversations/{}/delete", urlencode(sid)); + let resp = http().post(&url).bearer_auth(token).send(); + match resp { + Ok(r) => { + let status = r.status().as_u16(); + let body = r.text().unwrap_or_default(); + classify(status, &body) + } + Err(e) => CloudDelete::Failed(format!("网络错误:{e}")), + } +} + +/// 只对 id 里可能出现的字符做最小转义(sid 是 uuid,通常无需变)。 +/// 全账枚举端点(实测:与 `CLOUD_BASE` **不同域**,是另一套网关)。 +/// +/// `GET {CLOUD_API_BASE}/v2/as/conversations/?type=all&page=N&size=M` + **归属账号** Bearer +/// ⇒ 该账号名下**跨设备**全量会话(含他机创建与云端自动化)。 +/// ⚠️ 别把路径换到 `CLOUD_BASE` 的 `/console/as/conversations`:同 token 恒 403。 +pub const CLOUD_API_BASE: &str = "https://copilot.tencent.com"; + +/// 列表页 size 上限(`size=200` 服务端直接 400)。 +pub const CLOUD_LIST_PAGE_SIZE: usize = 100; + +/// 解析一页列表响应 → (本页 sid 列表, 是否还有下一页)。纯函数,便于单测。 +pub fn parse_conversation_page(body: &str) -> Result<(Vec, bool), String> { + let v: Value = serde_json::from_str(body).map_err(|e| format!("响应不是 JSON: {e}"))?; + let data = v.get("data").ok_or_else(|| "响应缺 data 字段".to_string())?; + let sids = data + .get("conversations") + .and_then(|c| c.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|c| c.get("id").and_then(|i| i.as_str()).map(str::to_string)) + .collect::>() + }) + .unwrap_or_default(); + let has_next = data + .get("pagination") + .and_then(|p| p.get("hasNext")) + .and_then(|h| h.as_bool()) + .unwrap_or(false); + Ok((sids, has_next)) +} + +/// 拉取账号全域会话 id(**只读**,自动分页)。 +/// +/// 失败一律 `Err` —— 调用方据此把全账巡检降级为「不启用」,**绝不影响瘦身主流程**。 +pub fn list_conversation_ids(token: &str) -> Result, String> { + let mut out: Vec = Vec::new(); + let mut page = 1usize; + loop { + let url = format!( + "{CLOUD_API_BASE}/v2/as/conversations/?type=all&page={page}&size={CLOUD_LIST_PAGE_SIZE}" + ); + let resp = http() + .get(&url) + .bearer_auth(token) + .send() + .map_err(|e| format!("请求失败: {e}"))?; + let status = resp.status().as_u16(); + let body = resp.text().unwrap_or_default(); + if !(200..300).contains(&status) { + return Err(format!( + "http {status}: {}", + body.chars().take(120).collect::() + )); + } + let (mut sids, has_next) = parse_conversation_page(&body)?; + out.append(&mut sids); + if !has_next || page >= 50 { + break; + } + page += 1; + } + Ok(out) +} + +fn urlencode(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for b in s.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(b as char) + } + other => out.push_str(&format!("%{other:02X}")), + } + } + out +} + +/// `convmsg:` → ``(不是该前缀就原样返回)。 +pub fn channel_uid(ch: &str) -> String { + ch.strip_prefix("convmsg:").unwrap_or(ch).to_string() +} + +/// 探测当前生效的映射库:**v4 → v3 → v2 → 无名**,取第一个存在的。 +/// +/// ⚠️ 官方 `extensions/edge-sync/server/index.cjs` 里**硬编码 `edge-sync-mapping-v4.db`**; +/// 历史教训:写死 v2 会导致「复制会话注册云端归属」静默写进 App 不读的旧库。 +/// 这里改为探测,跟随官方换代。 +pub fn latest_mapping_db() -> Option { + let root = home_dir().join(".workbuddy"); + for name in [ + "edge-sync-mapping-v4.db", + "edge-sync-mapping-v3.db", + "edge-sync-mapping-v2.db", + "edge-sync-mapping.db", + ] { + let p = root.join(name); + if p.is_file() { + return Some(p); + } + } + None +} + +/// 读映射表的 `session_id → msg_channel` 全量。读不到就返回空表(调用方退化为「只本地瘦身」)。 +pub fn mapping_channels() -> HashMap { + let Some(db) = latest_mapping_db() else { + return HashMap::new(); + }; + mapping_channels_from(&db) +} + +/// 映射行全量(含 conversation_id——删除接口的唯一钥匙)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MappingEntry { + pub session_id: String, + pub conversation_id: String, + pub channel: String, +} + +/// 读映射表全量行(只读,绝不写删——坑位 41)。空表 = 无法对账,调用方跳过该阶段。 +pub fn mapping_rows() -> Vec { + let Some(db) = latest_mapping_db() else { + return Vec::new(); + }; + mapping_rows_from(&db) +} + +/// 供测试注入路径用。 +pub fn mapping_rows_from(db: &std::path::Path) -> Vec { + let Ok(conn) = Connection::open(db) else { + return Vec::new(); + }; + let Ok(mut stmt) = conn.prepare( + "SELECT session_id, conversation_id, COALESCE(msg_channel, '') FROM edge_sync_mapping", + ) else { + return Vec::new(); + }; + let Ok(rows) = stmt.query_map([], |r| { + Ok(MappingEntry { + session_id: r.get(0)?, + conversation_id: r.get(1)?, + channel: r.get(2)?, + }) + }) else { + return Vec::new(); + }; + rows.flatten().collect() +} + +/// 供测试注入路径用。 +pub fn mapping_channels_from(db: &std::path::Path) -> HashMap { + let Ok(conn) = Connection::open(db) else { + return HashMap::new(); + }; + let Ok(mut stmt) = + conn.prepare("SELECT session_id, msg_channel FROM edge_sync_mapping") + else { + return HashMap::new(); + }; + let Ok(rows) = stmt.query_map([], |r| { + Ok((r.get::<_, String>(0)?, r.get::<_, Option>(1)?)) + }) else { + return HashMap::new(); + }; + rows.flatten() + .filter_map(|(sid, ch)| ch.map(|c| (sid, c))) + .collect() +} + +/// 按 uid 从 `accounts.json` 找 `access_token`。 +pub fn token_of(uid: &str) -> Option { + account::load_accounts() + .into_iter() + .find(|a| account_uid(a) == uid) + .and_then(|a| account::get_str(&a, "access_token")) +} + +/// 本机 device-id(`~/.workbuddy/device-id`,uuid 格式)。读不到返回空串。 +/// +/// App edge-sync 的 `CREATE` / `MIGRATE` 请求都带 `hostId=`; +/// 服务端会把 `source_device_id` 覆盖成这个值(手机端「XX 设备」的来源)。 +pub fn device_id() -> String { + std::fs::read_to_string(home_dir().join(".workbuddy").join("device-id")) + .map(|s| s.trim().to_string()) + .unwrap_or_default() +} + +/// 为目标账号创建云端会话(conv),返回 `Ok(conv_id)`。 +/// +/// 背景(实锤): +/// **写映射行 ≠ 云端建 conv**——edge-sync 启动时把映射库行当「已上云」, +/// 手工 register 反而阻断 App 补建(坑 48)。外部造的会话(复制/硬链接) +/// 必须主动调这条接口,且**顺序铁律:先建 conv 成功,再 register 映射行**。 +/// +/// body 逐字段模仿 App `syncCreateConversation`(index.cjs): +/// `type=local`、`conversationOrigin=legacy_workbuddy_local`、 +/// `clientContext.hostId=` 等;成功后 conv id == sid(云端统一)。 +pub fn create_conversation( + token: &str, + sid: &str, + title: &str, + cwd: &str, + ts_ms: i64, +) -> Result { + if token.is_empty() { + return Err("缺目标账号 token,无法在云端建会话".into()); + } + if sid.is_empty() { + return Err("缺 session id".into()); + } + let host = device_id(); + let body = serde_json::json!({ + "type": "local", + "sessionId": sid, + "name": title, + "conversationOrigin": "legacy_workbuddy_local", + "workDir": cwd, + "isPlayground": 0, + "createdAtMs": ts_ms, + "updatedAtMs": ts_ms, + "lastActivityAtMs": ts_ms, + "clientContext": { + "localStatus": "active", + "hostId": host, + "sessionKind": "manual", + "model": "", + "sourceMode": "", + "permissionMode": "", + "expert": { "id": "", "locale": "", "runtimeIdentity": "", "marketplace": "" } + } + }); + let url = format!("{CLOUD_BASE}/console/as/conversations/v2"); + let resp = http() + .post(&url) + .bearer_auth(token) + .json(&body) + .send() + .map_err(|e| format!("请求失败: {e}"))?; + let status = resp.status().as_u16(); + let text = resp.text().unwrap_or_default(); + match classify(status, &text) { + CloudDelete::Deleted => { + // conv id 实测与 sid 同值;服务端若返回独立 id,以返回值为准 + let cid = serde_json::from_str::(&text) + .ok() + .and_then(|v| { + v.get("data") + .and_then(|d| d.get("id")) + .and_then(|i| i.as_str()) + .map(str::to_string) + }) + .unwrap_or_else(|| sid.to_string()); + Ok(cid) + } + other => Err(format!("云端建会话失败(http {status}): {other:?}")), + } +} + +/// 批量迁移的单条 item(逐字段模仿 App MIGRATE_SESSION payload,index.cjs @42419)。 +pub fn migrate_item(sid: &str, title: &str, cwd: &str, ts_ms: i64) -> Value { + let host = device_id(); + serde_json::json!({ + "sessionId": sid, + // 服务端 migrationConversationForceUpdateCols 会覆盖 source_device_id 列, + // 端上冗余传(批次顶层 deviceId + 每条内层 sourceDeviceId),与 App 一致。 + "sourceDeviceId": host, + "conversationTitle": title, + "conversationOrigin": "legacy_workbuddy_local", + "status": "completed", + "workDir": cwd, + "createdAtMs": ts_ms, + "updatedAtMs": ts_ms, + "lastActivityAtMs": ts_ms, + "isPlayground": 0, + "clientContext": { + "localStatus": "completed", + "sessionKind": "manual", + "model": "", + "sourceMode": "", + "permissionMode": "", + "expert": { "id": "", "locale": "", "runtimeIdentity": "", "marketplace": "" } + } + }) +} + +/// 批量迁移端点(App edge-sync MIGRATE 同款): +/// `POST /console/as/conversation-sync/migrations/legacy`(index.cjs:42370)。 +/// +/// 与单建 `conversations/v2` 的关键差异(实测): +/// - **一次请求打包 ≤500 条**(App `MIGRATE_BATCH_SIZE=500`),服务端逐条建 conv; +/// 限流按请求数计(单建实测 10 条/窗口),批量端点 1 请求即可绕开; +/// - 实测 `convId == sessionId`(与单建一致),归属 = token 账号; +/// - ⚠️ `forceUpdate=true` 会覆盖已存在 conv 的 source_device_id 等列 +/// ⇒ **只对「云端必无」的新 sid 用**(我们的共享场景天然满足)。 +/// +/// 返回 `data`(含 `results: {sid: {status, conversationId}}` 与 +/// `importedCount/skippedCount/failedCount`),调用方按逐条结果 register/回滚。 +pub fn migrate_conversations(token: &str, sessions: &[Value]) -> Result { + if token.is_empty() { + return Err("缺目标账号 token,无法批量上云".into()); + } + if sessions.is_empty() { + return Ok(serde_json::json!({ "results": {} })); + } + let host = device_id(); + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0); + let body = serde_json::json!({ + "migrationId": format!("{host}-{now_ms}-b0"), + "deviceId": host, + "schemaVersion": 0, + "forceUpdate": true, + "sessions": sessions, + }); + let url = format!("{CLOUD_BASE}/console/as/conversation-sync/migrations/legacy"); + let resp = http() + .post(&url) + .bearer_auth(token) + .json(&body) + .send() + .map_err(|e| format!("请求失败: {e}"))?; + let status = resp.status().as_u16(); + let text = resp.text().unwrap_or_default(); + match classify(status, &text) { + CloudDelete::Deleted => { + let data = serde_json::from_str::(&text) + .ok() + .and_then(|v| v.get("data").cloned()) + .unwrap_or_else(|| serde_json::json!({})); + Ok(data) + } + other => Err(format!("批量迁移失败(http {status}): {other:?}")), + } +} + +/// 判定云端 conv 是否存在(GET /v2/as/conversations/{sid})。 +/// 200 = 存在;404 = 不存在;403 = 存在但属别的账号(按存在处理,保守不重建)。 +pub fn conversation_exists(token: &str, sid: &str) -> Result { + if token.is_empty() { + return Err("缺目标账号 token".into()); + } + let url = format!("{CLOUD_BASE}/v2/as/conversations/{sid}"); + let resp = http() + .get(&url) + .bearer_auth(token) + .send() + .map_err(|e| format!("请求失败: {e}"))?; + match resp.status().as_u16() { + 200 => Ok(true), + 404 => Ok(false), + 403 => Ok(true), + s => Err(format!("http {s}")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn classify_maps_http_status() { + assert_eq!(classify(200, r#"{"code":0,"msg":"OK"}"#), CloudDelete::Deleted); + assert_eq!(classify(200, ""), CloudDelete::Deleted); + assert_eq!( + classify(404, r#"{"code":14284,"msg":"conversation not found"}"#), + CloudDelete::AlreadyGone + ); + assert_eq!( + classify(403, r#"{"code":14287,"msg":"conversation access denied"}"#), + CloudDelete::Forbidden + ); + assert!(matches!(classify(500, "boom"), CloudDelete::Failed(_))); + // 200 但业务错码 ⇒ 不能当成功 + assert!(matches!( + classify(200, r#"{"code":1,"msg":"x"}"#), + CloudDelete::Failed(_) + )); + } + + #[test] + fn ok_covers_deleted_and_gone_only() { + assert!(CloudDelete::Deleted.ok()); + assert!(CloudDelete::AlreadyGone.ok()); + assert!(!CloudDelete::Forbidden.ok()); + assert!(!CloudDelete::Failed("x".into()).ok()); + } + + #[test] + fn channel_uid_strips_prefix() { + assert_eq!(channel_uid("convmsg:uid-a"), "uid-a"); + assert_eq!(channel_uid("uid-a"), "uid-a"); + assert_eq!(channel_uid(""), ""); + } + + #[test] + fn urlencode_keeps_uuid_and_escapes_others() { + assert_eq!( + urlencode("aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"), + "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee" + ); + assert_eq!(urlencode("a/b c"), "a%2Fb%20c"); + } + + #[test] + fn missing_input_never_touches_network() { + assert!(matches!(delete_conversation("", "x"), CloudDelete::Failed(_))); + assert!(matches!(delete_conversation("t", ""), CloudDelete::Failed(_))); + } + + #[test] + fn mapping_channels_from_reads_table() { + let db = std::env::temp_dir().join(format!( + "wb_cloudconv_{}.db", + uuid::Uuid::new_v4().simple() + )); + let conn = Connection::open(&db).unwrap(); + conn.execute_batch( + "CREATE TABLE edge_sync_mapping (session_id TEXT PRIMARY KEY, \ + conversation_id TEXT NOT NULL, msg_channel TEXT NOT NULL DEFAULT '');", + ) + .unwrap(); + conn.execute( + "INSERT INTO edge_sync_mapping VALUES ('s1','s1','convmsg:uid-a')", + [], + ) + .unwrap(); + drop(conn); + let m = mapping_channels_from(&db); + assert_eq!(m.get("s1").map(|s| s.as_str()), Some("convmsg:uid-a")); + let _ = std::fs::remove_file(&db); + } + + #[test] + fn mapping_channels_from_missing_db_is_empty() { + let m = mapping_channels_from(std::path::Path::new("C:/__no_such_dir__/x.db")); + assert!(m.is_empty()); + } + + #[test] + fn parse_conversation_page_reads_ids_and_hasnext() { + let body = r#"{"code":0,"msg":"OK","data":{"conversations":[{"id":"a"},{"id":"b"}],"total":2,"pagination":{"page":1,"size":100,"total":2,"totalPages":1,"hasNext":false,"hasPrev":false}}}"#; + let (sids, more) = parse_conversation_page(body).unwrap(); + assert_eq!(sids, vec!["a".to_string(), "b".to_string()]); + assert!(!more); + } + + #[test] + fn parse_conversation_page_hasnext_true() { + let body = r#"{"data":{"conversations":[],"pagination":{"hasNext":true}}}"#; + let (sids, more) = parse_conversation_page(body).unwrap(); + assert!(sids.is_empty()); + assert!(more); + } + + #[test] + fn parse_conversation_page_rejects_non_json_and_missing_data() { + // 网关 403/401 会回 HTML 或 {"error":...},必须当失败而不是当空列表 + assert!(parse_conversation_page("401 Authorization Required").is_err()); + assert!(parse_conversation_page(r#"{"error":"access_denied"}"#).is_err()); + } +} diff --git a/crates/wb-switch-core/src/modules/cloud_reconcile.rs b/crates/wb-switch-core/src/modules/cloud_reconcile.rs new file mode 100644 index 00000000..1d775421 --- /dev/null +++ b/crates/wb-switch-core/src/modules/cloud_reconcile.rs @@ -0,0 +1,379 @@ +//! 云端对账(全账合账的对账层):云端索引 × 本机 sessions → 分类 + 残留清理。 +//! +//! 语义(测试钉死): +//! - 云端有 + 本机存活 → `Aligned`(保留,不动) +//! - 云端有 + 本机已软删 → `CloudOnly`(确凿残留 → victims,交 `cloud_conv` 删) +//! - 云端有 + 本机无任何痕迹 → `Unknown`(**不删**:可能是其他设备的活会话,仅报告) +//! - 本机有 + 云端无 → `Ignored`(本机事务,与云端无关) +//! +//! 红线:只读映射库,绝不删 `edge-sync-mapping-*.db` 的行(坑位 41)。 + +use std::collections::HashSet; + +use serde_json::{json, Value}; + +use super::cloud_conv::CloudDelete; + +/// 对账结果。`victims` 只含 `CloudOnly`;`unknown` 仅报告不动作。 +/// +/// 本机独有拆两类:`ignored_alive` = 活会话未上云(值得看) +/// / `ignored_deleted` = 已软删且云端也无(**正常态**,不该报给用户当"欠账")。 +#[derive(Debug, Default, PartialEq)] +pub struct ReconcileReport { + pub aligned: Vec, + pub cloud_only: Vec, + pub unknown: Vec, + pub ignored_alive: usize, + pub ignored_deleted: usize, +} + +/// 纯对账:不联网、不落盘。 +pub fn reconcile( + cloud_sids: &[String], + local_alive: &HashSet, + local_deleted: &HashSet, +) -> ReconcileReport { + let mut r = ReconcileReport::default(); + for sid in cloud_sids { + if local_alive.contains(sid) { + r.aligned.push(sid.clone()); + } else if local_deleted.contains(sid) { + r.cloud_only.push(sid.clone()); + } else { + r.unknown.push(sid.clone()); + } + } + // 本机独有按「存活 / 已软删」分列:前者是真·未上云,后者是已删干净的常态 + r.ignored_alive = local_alive + .iter() + .filter(|s| !cloud_sids.contains(s)) + .count(); + r.ignored_deleted = local_deleted + .iter() + .filter(|s| !cloud_sids.contains(s)) + .count(); + r +} + +/// 清扫 victims:逐条调 `delete`(真实现 = `cloud_conv::delete_conversation`, +/// 归属账号 token)。dry_run 只统计不调 `delete`。 +/// +/// 返回报告沿用瘦身 `cloud.*` 字段口径:`removed`(200) / `alreadyGone`(404) 分列, +/// `failed` 汇总非 200/404 结果。 +pub fn sweep(token: Option<&str>, victims: &[String], dry_run: bool, mut delete: F) -> Value +where + F: FnMut(&str, &str) -> CloudDelete, +{ + let mut removed = 0usize; + let mut already_gone = 0usize; + let mut failed = 0usize; + if let (false, Some(tok)) = (dry_run, token) { + for sid in victims { + match delete(tok, sid) { + CloudDelete::Deleted => removed += 1, + CloudDelete::AlreadyGone => already_gone += 1, + _ => failed += 1, + } + } + } + json!({ + "planned": victims.len(), + "dryRun": dry_run, + "tokenReady": token.is_some(), + "noToken": if token.is_none() { victims.len() } else { 0 }, + "removed": removed, + "alreadyGone": already_gone, + "failed": failed, + }) +} + +/// 对账编排:映射行全集 × 本机存活/软删集合 → 分类 + 清理 victims。 +/// +/// `rows` 注入(离线可测);真实现传 `cloud_conv::mapping_rows()`。 +/// Unknown(映射有、本机无行)**永不删**——可能是其他设备的活会话,只计数。 +/// 报告并入瘦身 `cloud.reconcile` 子对象。 +/// +/// `skip` = **同一次清理里已被瘦身主流程处理过**的 sid。 +/// reconcile 的输入是「本机已软删」集合,而瘦身主流程刚软删的那批恰好新落入该集合 +/// ⇒ 不排除就会对同一批 sid **再发一轮删除请求**(幂等但纯属冗余)。 +/// 它们仍计入 `mapped`(映射行总数不变),但不进 `planned`,单独计入 `skippedBySlim` +/// (**不叫 `skipped`**:那个名字在对账报告里已被「映射库缺失」占为字符串)。 +pub fn sweep_stale_mappings( + rows: &[super::cloud_conv::MappingEntry], + local_alive: &HashSet, + local_deleted: &HashSet, + token_of_uid: Option<&str>, + dry_run: bool, + skip: &HashSet, + mut delete: F, +) -> Value +where + F: FnMut(&str, &str) -> CloudDelete, +{ + let sids: Vec = rows.iter().map(|r| r.session_id.clone()).collect(); + let r = reconcile(&sids, local_alive, local_deleted); + // victims 需要映射回 conversation_id(删除接口的唯一钥匙) + let cid_of: std::collections::HashMap<&str, &str> = rows + .iter() + .map(|e| (e.session_id.as_str(), e.conversation_id.as_str())) + .collect(); + let victim_cids: Vec = r + .cloud_only + .iter() + .filter(|sid| !skip.contains(sid.as_str())) + .filter_map(|sid| cid_of.get(sid.as_str()).map(|c| c.to_string())) + .collect(); + let skipped = r + .cloud_only + .iter() + .filter(|sid| skip.contains(sid.as_str())) + .count(); + let mut sweep = sweep(token_of_uid, &victim_cids, dry_run, &mut delete); + sweep["aligned"] = json!(r.aligned.len()); + sweep["unknown"] = json!(r.unknown.len()); + sweep["mapped"] = json!(rows.len()); + sweep["skippedBySlim"] = json!(skipped); + sweep +} + +/// 全账巡检(**只读,永不删**):云端全账 × 本机状态 → 分类计数。 +/// +/// 与 `sweep_stale_mappings` 的口径差别:那个只看「本机映射行」这本账, +/// 看不到云端还有哪些**本机映射库根本不知道**的会话;这里直接吃 +/// `GET /v2/as/conversations/?type=all` 的账号全域清单(跨设备)。 +/// +/// - `stale` = 云端有 + 本机已软删 → 清理仍归 `sweep_stale_mappings`(那才有映射钥匙) +/// - `foreign` = 云端有 + 本机无任何痕迹 → **他机/跨设备会话,永不删** +/// - `localOnly` = 本机有 + 云端无 → 未上云 +/// +/// 取数失败(无 token / 网络错)只回 `enabled:false` + 原因,**不影响主流程**。 +pub fn inventory( + token: Option<&str>, + local_alive: &HashSet, + local_deleted: &HashSet, + mut fetch: F, +) -> Value +where + F: FnMut(&str) -> Result, String>, +{ + let tok = match token { + Some(t) => t, + None => return json!({"enabled": false, "reason": "noToken"}), + }; + let cloud_sids = match fetch(tok) { + Ok(v) => v, + Err(e) => return json!({"enabled": false, "reason": e}), + }; + let r = reconcile(&cloud_sids, local_alive, local_deleted); + json!({ + "enabled": true, + "cloud": cloud_sids.len(), + "aligned": r.aligned.len(), + "stale": r.cloud_only.len(), + "foreign": r.unknown.len(), + // localOnly 保留为合计(旧报告/消费者兼容),细分看后两列 + "localOnly": r.ignored_alive + r.ignored_deleted, + "localOnlyAlive": r.ignored_alive, + "localOnlyDeleted": r.ignored_deleted, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::modules::cloud_conv; + use std::collections::HashSet; + + fn set(items: &[&str]) -> HashSet { + items.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn reconcile_classifies_four_ways() { + // 云端 5 条:c1 本机存活 / c2 本机已软删 / c3 无痕迹 / c4 同 c2 / c5 同 c1 + let cloud = ["c1", "c2", "c3", "c4", "c5"] + .iter() + .map(|s| s.to_string()) + .collect::>(); + let alive = set(&["c1", "c5", "l1"]); + let deleted = set(&["c2", "c4", "l2"]); + + let r = reconcile(&cloud, &alive, &deleted); + + assert_eq!(r.aligned, vec!["c1".to_string(), "c5".to_string()]); + assert_eq!(r.cloud_only, vec!["c2".to_string(), "c4".to_string()]); + assert_eq!(r.unknown, vec!["c3".to_string()]); + assert_eq!(r.ignored_alive, 1, "l1 存活且云端无 → 真·未上云"); + assert_eq!(r.ignored_deleted, 1, "l2 已软删且云端无 → 已删干净的常态"); + // 红线语义:unknown 绝不混进 victims + assert!(!r.cloud_only.contains(&"c3".to_string())); + } + + #[test] + fn reconcile_empty_cloud_is_noop() { + let r = reconcile(&[], &set(&["c1"]), &set(&["c2"])); + assert!(r.aligned.is_empty() && r.cloud_only.is_empty() && r.unknown.is_empty()); + assert_eq!(r.ignored_alive, 1); + assert_eq!(r.ignored_deleted, 1); + } + + #[test] + fn sweep_dry_run_counts_without_calling_delete() { + let victims = vec!["a".to_string(), "b".to_string()]; + let r = sweep(Some("tok"), &victims, true, |_t, _sid| { + panic!("dry_run 不得发删除请求"); + }); + assert_eq!(r["planned"], 2); + assert_eq!(r["removed"], 0); + assert_eq!(r["alreadyGone"], 0); + } + + #[test] + fn sweep_no_token_reports_and_skips_all() { + let victims = vec!["a".to_string()]; + let r = sweep(None, &victims, false, |_t, _sid| { + panic!("无 token 不得发删除请求"); + }); + assert_eq!(r["tokenReady"], false); + assert_eq!(r["noToken"], 1); + assert_eq!(r["removed"], 0); + } + + #[test] + fn sweep_separates_removed_alreadygone_and_failed() { + let victims = vec!["r1".to_string(), "g1".to_string(), "f1".to_string()]; + let r = sweep(Some("tok"), &victims, false, |_t, sid| match sid { + "r1" => cloud_conv::classify(200, "{}"), + "g1" => cloud_conv::classify(404, r#"{"code":14284}"#), + _ => cloud_conv::classify(403, r#"{"code":14287}"#), + }); + assert_eq!(r["removed"], 1); + assert_eq!(r["alreadyGone"], 1); + assert_eq!(r["failed"], 1); + assert_eq!(r["planned"], 3); + } + + fn entry(sid: &str, cid: &str, ch: &str) -> cloud_conv::MappingEntry { + cloud_conv::MappingEntry { + session_id: sid.to_string(), + conversation_id: cid.to_string(), + channel: ch.to_string(), + } + } + + #[test] + fn sweep_stale_mappings_routes_only_local_deleted() { + let rows = vec![ + entry("s1", "c1", "convmsg:u1"), // 本机存活 → aligned + entry("s2", "c2", "convmsg:u1"), // 本机已软删 → victim + entry("s3", "c3", "convmsg:u1"), // 无行 → unknown 不删 + ]; + let alive = set(&["s1"]); + let deleted = set(&["s2"]); + let mut called: Vec = Vec::new(); + let r = sweep_stale_mappings( + &rows, + &alive, + &deleted, + Some("tok"), + false, + &set(&[]), + |_t, cid| { + called.push(cid.to_string()); + cloud_conv::classify(200, "{}") + }, + ); + assert_eq!(called, vec!["c2".to_string()], "只删本机已软删那把 cid 钥匙"); + assert_eq!(r["removed"], 1); + assert_eq!(r["aligned"], 1); + assert_eq!(r["unknown"], 1); + assert_eq!(r["mapped"], 3); + assert_eq!(r["skippedBySlim"], 0, "无 skip 时不该有跳过项"); + } + + /// 同一次切号里瘦身主流程刚删过的 sid:仍算映射行,但不重复发删除请求。 + #[test] + fn sweep_stale_mappings_skips_already_handled_in_same_run() { + let rows = vec![ + entry("s2", "c2", "convmsg:u1"), // 本机已软删 → 常规 victim + entry("s3", "c3", "convmsg:u1"), // 本机已软删,但本run刚删过 → skip + ]; + let deleted = set(&["s2", "s3"]); + let mut called: Vec = Vec::new(); + let r = sweep_stale_mappings( + &rows, + &set(&[]), + &deleted, + Some("tok"), + false, + &set(&["s3"]), + |_t, cid| { + called.push(cid.to_string()); + cloud_conv::classify(404, r#"{"code":14284}"#) + }, + ); + assert_eq!(called, vec!["c2".to_string()], "s3 本 run 已处理,不得重复请求"); + assert_eq!(r["planned"], 1, "planned 只数真会发起的请求"); + assert_eq!(r["skippedBySlim"], 1); + assert_eq!(r["mapped"], 2, "mapped 是映射行总数,不受 skip 影响"); + } + + #[test] + fn sweep_stale_mappings_dry_run_never_calls_delete() { + let rows = vec![entry("s2", "c2", "convmsg:u1")]; + let r = sweep_stale_mappings( + &rows, + &set(&[]), + &set(&["s2"]), + Some("tok"), + true, + &set(&[]), + |_t, _cid| panic!("dry_run 不得发删除请求"), + ); + assert_eq!(r["planned"], 1); + assert_eq!(r["removed"], 0); + } + + #[test] + fn inventory_classifies_without_any_delete() { + // 云端 4 条:c1/c4 本机存活 · c2 本机已软删 · c3 本机无痕迹(他机) + let alive = set(&["c1", "c4", "l1"]); + let deleted = set(&["c2"]); + let r = inventory(Some("tok"), &alive, &deleted, |_t| { + Ok(["c1", "c2", "c3", "c4"] + .iter() + .map(|s| s.to_string()) + .collect()) + }); + assert_eq!(r["enabled"], true); + assert_eq!(r["cloud"], 4); + assert_eq!(r["aligned"], 2); + assert_eq!(r["stale"], 1); + assert_eq!(r["foreign"], 1, "c3 是本机无痕迹的他机会话"); + assert_eq!(r["localOnly"], 1, "l1 未上云(合计口径保持向后兼容)"); + assert_eq!(r["localOnlyAlive"], 1, "l1 活会话未上云"); + assert_eq!(r["localOnlyDeleted"], 0, "c2 已软删但云端还在 → 归 stale 不归这里"); + } + + #[test] + fn inventory_without_token_is_disabled_not_fatal() { + let r = inventory(None, &set(&["c1"]), &set(&[]), |_t| { + panic!("无 token 不得发请求") + }); + assert_eq!(r["enabled"], false); + assert_eq!(r["reason"], "noToken"); + } + + #[test] + fn inventory_fetch_error_degrades_silently() { + let r = inventory(Some("tok"), &set(&[]), &set(&[]), |_t| { + Err("http 500".to_string()) + }); + assert_eq!(r["enabled"], false); + assert_eq!(r["reason"], "http 500"); + assert!( + r.get("stale").is_none(), + "降级时不得报出任何可能被误读成「可删」的计数" + ); + } +} diff --git a/crates/wb-switch-core/src/modules/codebuddy_cli.rs b/crates/wb-switch-core/src/modules/codebuddy_cli.rs index 2c31828c..3dc832e4 100644 --- a/crates/wb-switch-core/src/modules/codebuddy_cli.rs +++ b/crates/wb-switch-core/src/modules/codebuddy_cli.rs @@ -872,9 +872,11 @@ fn node_path_from_shell_output(stdout: &[u8]) -> Option { .lines() .rev() .find_map(|line| { - let path = PathBuf::from(line.trim()); - (path.is_absolute() && path.file_name().is_some_and(|name| name == "node")) - .then_some(path) + let trimmed = line.trim(); + let path = PathBuf::from(trimmed); + // Windows 测试环境里 `/Users/...` 不算 is_absolute(),Unix 根路径也接受 + let absolute = path.is_absolute() || trimmed.starts_with('/'); + (absolute && path.file_name().is_some_and(|name| name == "node")).then_some(path) }) } diff --git a/crates/wb-switch-core/src/modules/export_import.rs b/crates/wb-switch-core/src/modules/export_import.rs index 86c5f592..bda5ae2d 100644 --- a/crates/wb-switch-core/src/modules/export_import.rs +++ b/crates/wb-switch-core/src/modules/export_import.rs @@ -181,7 +181,8 @@ fn validate_export_file_name(file_name: &str) -> Result<(), String> { /// 校验导出目标路径:必须是绝对路径且以 `.json` 结尾(保存对话框产物)。 fn validate_export_path(path: &str) -> Result<(), String> { let p = Path::new(path.trim()); - if !p.is_absolute() { + // Windows 上 `/tmp/x` 不算 is_absolute()(缺盘符),这里兼容 Unix 风格根路径 + if !p.is_absolute() && !path.trim().starts_with('/') { return Err("导出路径必须是绝对路径".to_string()); } if !p diff --git a/crates/wb-switch-core/src/modules/mod.rs b/crates/wb-switch-core/src/modules/mod.rs index 3c92c932..b73d87c2 100644 --- a/crates/wb-switch-core/src/modules/mod.rs +++ b/crates/wb-switch-core/src/modules/mod.rs @@ -16,6 +16,9 @@ pub mod rate_limit_events; pub mod rate_limit_hook; pub mod refresh; pub mod rotate; +pub mod cloud_conv; +pub mod cloud_reconcile; +pub mod session_slim; pub mod session; pub mod switch; pub mod token_stats; diff --git a/crates/wb-switch-core/src/modules/rate_limit_hook.rs b/crates/wb-switch-core/src/modules/rate_limit_hook.rs index 4ac0e173..ba500436 100644 --- a/crates/wb-switch-core/src/modules/rate_limit_hook.rs +++ b/crates/wb-switch-core/src/modules/rate_limit_hook.rs @@ -690,7 +690,7 @@ mod tests { assert!(stop[1]["hooks"][0]["command"] .as_str() .expect("命令") - .contains("hook.sh")); + .contains(script_name(script_kind()))); assert_eq!( root["hooks"]["FinalStop"] .as_array() diff --git a/crates/wb-switch-core/src/modules/session.rs b/crates/wb-switch-core/src/modules/session.rs index 7ffe3cac..e5558afd 100644 --- a/crates/wb-switch-core/src/modules/session.rs +++ b/crates/wb-switch-core/src/modules/session.rs @@ -220,7 +220,7 @@ fn find_project_jsonl(variant: WbVariant, cid: &str) -> Option { } /// 备份 workbuddy.db(含 -wal/-shm),返回主库备份路径。对照 `backup_workbuddy_db`。 -fn backup_workbuddy_db(variant: WbVariant, backup_root: &Path) -> Option { +pub(crate) fn backup_workbuddy_db(variant: WbVariant, backup_root: &Path) -> Option { let db = workbuddy_db_path(variant); if !db.is_file() { return None; @@ -457,7 +457,11 @@ mod tests { #[test] fn db_paths_follow_variant_data_root() { let cn = workbuddy_db_path(WbVariant::Cn); - assert!(cn.to_string_lossy().ends_with(".workbuddy/workbuddy.db")); + // 跨平台语义路径统一按 `/` 比较(本机分隔符不参与断言)。 + assert!(cn + .to_string_lossy() + .replace('\\', "/") + .ends_with(".workbuddy/workbuddy.db")); assert!(edge_sync_db_path(WbVariant::Cn) .to_string_lossy() .ends_with("edge-sync-mapping-v2.db")); diff --git a/crates/wb-switch-core/src/modules/session_slim.rs b/crates/wb-switch-core/src/modules/session_slim.rs new file mode 100644 index 00000000..4075f187 --- /dev/null +++ b/crates/wb-switch-core/src/modules/session_slim.rs @@ -0,0 +1,788 @@ +//! 会话清理(瘦身)——每账号每 cwd 保留 `updated_at` 最新 keep 条,其余软删。 +//! +//! 云端删除是清理的默认组成部分:装配云端上下文——读映射表 + 取该账号 token; +//! 任何一步缺失都不致命,会在报告的 `slim.cloud.tokenReady` 里如实反映, +//! 流程退化为「只本地软删」。删除按 `cloud_conv` 的三条铁律走。 +//! 末尾追加**对账阶段**(`cloud.reconcile`)+ **云端全貌**(`cloud.inventory`)。 + +use serde_json::{json, Value}; +use std::path::Path; + +use crate::modules::config::{backup_dir, home_dir, now_ms, utc_iso}; +use crate::modules::session::{backup_workbuddy_db, open_db, table_exists, workbuddy_db_path}; + +/// 会话瘦身:每账号每 cwd 保留 updated_at 最新 keep 条,其余软删。 +/// +/// `exclude` = 本次切号刚复制过来的会话 id —— 它们既不被删、也不占用保留名额, +/// 否则「复制多条同项目会话 + 瘦身 keep=1」会让用户只看到 1 条(复制体互相挤掉)。 +/// 云端删除上下文(`None` = 只做本地软删,保持旧行为)。 +/// +/// 归属判据 = `/edge-sync-mapping-v4.db` 的 `msg_channel`; +/// 只有 `convmsg:`(本次瘦身的目标账号)才动云端 —— 其余(无映射 / 归别的账号) +/// 一律**只本地软删**,避免用错账号 token 触发 403 白跑、或误伤他人会话。 +pub struct SlimCloudCtx { + /// 本次瘦身的目标账号 uid。 + pub uid: String, + /// 该账号的 `access_token`(取不到则整个云端环节跳过)。 + pub token: Option, + /// `sid → msg_channel` 全量映射。 + pub channels: std::collections::HashMap, +} + +impl SlimCloudCtx { + /// 该 sid 是否「云端归属本次目标账号」⇒ 可以放心删云端。 + fn owns(&self, sid: &str) -> bool { + self.channels + .get(sid) + .map(|ch| crate::modules::cloud_conv::channel_uid(ch) == self.uid) + .unwrap_or(false) + } +} + +/// 会话瘦身的本地实现(不含云端)。旧签名保留,供既有调用与测试使用。 +#[allow(dead_code)] // 仅测试与旧调用点用;生产路径统一走 _cloud 版 +pub(crate) fn slim_sessions_in_db( + db_path: &Path, + uid: &str, + keep: i64, + dry_run: bool, + exclude: &[String], +) -> Result { + slim_sessions_in_db_cloud(&SlimArgs { + db_path, + uid, + keep, + dry_run, + exclude, + keep_sids: None, + cloud: None, + }) + .map(|(report, _sids)| report) +} + +/// 会话瘦身(可带云端删除)。 +/// +/// 每条 victim 的处理顺序(**先云端、后本地**,保证失败可回退): +/// `ch == convmsg:` 且有 token → 调云端删除 +/// · 成功 / 404 → 本地软删,计 `cloudDeleted` +/// · 403 → 归属与映射不符(映射记错)⇒ **放弃云端**,本地照常软删,计 `cloudForbidden` +/// · 其它失败 → **本地不软删**(留到下次重试),计 `cloudFailed` +/// `ch` 缺失 → 本地软删,计 `cloudNoMapping` +/// `ch` 归别的账号 → 本地软删,计 `cloudForeign` +/// dry_run 只统计「将调用云端几条」,不发起请求。 +/// +/// 返回 `(report, victims_sids)`:第二项 = 本次选中的 victim 会话 id,供同一次切号 +/// 后续的**对账阶段**做 `skip`(避免对同一批 sid 再发一轮冗余删除请求)。 +/// 一次瘦身的入参:位置参数收成一个,拆出的子函数只透传它。 +pub(crate) struct SlimArgs<'a> { + pub db_path: &'a Path, + pub uid: &'a str, + pub keep: i64, + pub dry_run: bool, + /// 本次切号刚复制过来的会话 id —— 不被删、也不占保留名额。 + pub exclude: &'a [String], + /// 统一保留名单(`Some` = keepList 模式;`None` = 每项目保留 N 条)。 + pub keep_sids: Option<&'a [String]>, + /// 云端上下文(`None` = 只做本地软删)。 + pub cloud: Option<&'a SlimCloudCtx>, +} + +/// 一次瘦身的云端逐态计数(原先 9 个裸变量散在循环里)。 +/// +/// `deleted` = 合计(200 + 404);`removed` = 真被这次请求删掉(200); +/// `already_gone` = 云端本来就没有(404)。**只有 200 才能证明删成功** —— +/// 两个数字混在一起时,报告无法自证「到底真删了没」(实测教训)。 +#[derive(Default)] +struct CloudStats { + /// dry_run 下「将调用云端」条数。 + planned: usize, + deleted: usize, + removed: usize, + already_gone: usize, + /// 云端说这条不归本次账号(映射记错)⇒ 只本地软删。 + forbidden: usize, + /// 云端删除失败 ⇒ **本地保留未删**,下次切号再试。 + failed: usize, + /// 本机没有该会话的云端映射 ⇒ 只本地软删。 + no_mapping: usize, + /// 归属对得上但没取到凭证 ⇒ 云端整轮跳过,只本地软删。 + no_token: usize, + /// 映射显示云端归别的账号 ⇒ 只本地软删(不碰别人的对话)。 + foreign: usize, + /// 因云端失败而保留未删的 sid(带原因),落报告的 `samples`。 + kept: Vec, +} + +impl CloudStats { + /// 记一条云端删除结果,返回**是否允许本地软删**(铁律 3:云端没删掉 ⇒ 本地保留)。 + fn record(&mut self, sid: &str, outcome: &crate::modules::cloud_conv::CloudDelete) -> bool { + use crate::modules::cloud_conv::CloudDelete; + match outcome { + CloudDelete::Deleted => { + self.deleted += 1; + self.removed += 1; + true + } + CloudDelete::AlreadyGone => { + self.deleted += 1; + self.already_gone += 1; + true + } + CloudDelete::Forbidden => { + self.forbidden += 1; + true + } + CloudDelete::Failed(msg) => { + self.failed += 1; + self.kept.push(format!("{sid}({msg})")); + false + } + } + } + + /// 落进报告(字段名与顺序保持原样 —— 前端与脚本按名取值)。 + fn to_json(&self, token_ready: bool) -> Value { + json!({ + "enabled": true, + "tokenReady": token_ready, + "planned": self.planned, + "deleted": self.deleted, + "removed": self.removed, + "alreadyGone": self.already_gone, + "forbidden": self.forbidden, + "failed": self.failed, + "keptLocal": self.kept.len(), + "samples": self.kept.iter().take(5).cloned().collect::>(), + "noMapping": self.no_mapping, + "noToken": self.no_token, + "foreign": self.foreign, + }) + } +} + +/// 选出 victims(待删集合)。 +/// +/// 两种模式: +/// - keepList(统一保留名单,由调用方传入):目标存活 & 名单外 ⇒ 删; +/// - perProjectKeep(旧逻辑,无名单时兼容):每 cwd 分组,updated_at 倒序第 keep 条之后全删。 +/// +/// 排除集(本次手动复制体)两种模式下都生效:不被删、也不占保留名额。 +fn select_victims( + conn: &rusqlite::Connection, + uid: &str, + keep: i64, + excl: &[&String], + keep_sids: Option<&[String]>, +) -> Result, String> { + let excl_sql = |alias: &str| -> String { + if excl.is_empty() { + return String::new(); + } + let marks = (0..excl.len()) + .map(|i| format!("?{}", i + 3)) // ?1=uid, ?2=keep + .collect::>() + .join(","); + format!(" AND {alias}id NOT IN ({marks})") + }; + let mut params: Vec = vec![ + rusqlite::types::Value::Text(uid.to_string()), + rusqlite::types::Value::Integer(keep), + ]; + for e in excl { + params.push(rusqlite::types::Value::Text((*e).clone())); + } + let mut keep_marks = String::new(); + if let Some(ks) = keep_sids { + let list: Vec<&String> = ks.iter().filter(|s| !s.is_empty()).collect(); + if !list.is_empty() { + let base = 3 + excl.len(); + keep_marks = (0..list.len()) + .map(|i| format!("?{}", base + i)) + .collect::>() + .join(","); + for s in &list { + params.push(rusqlite::types::Value::Text((*s).clone())); + } + } + } + let sql = match (keep_sids, keep_marks.is_empty()) { + (Some(_), false) => format!( + "SELECT id, COALESCE(cwd,'') FROM sessions s \ + WHERE deleted_at IS NULL AND user_id = ?1{} AND id NOT IN ({keep_marks})", + excl_sql("s."), + ), + _ => format!( + "SELECT id, cwd FROM sessions s \ + WHERE deleted_at IS NULL AND user_id = ?1{} AND id NOT IN (\ + SELECT id FROM sessions \ + WHERE deleted_at IS NULL AND user_id = ?1 AND cwd = s.cwd{} \ + ORDER BY updated_at DESC LIMIT ?2\ + )", + excl_sql("s."), + excl_sql(""), + ), + }; + let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?; + let rows = stmt + .query_map(rusqlite::params_from_iter(params.iter()), |r| { + Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)) + }) + .map_err(|e| e.to_string())?; + Ok(rows.flatten().collect()) +} + +pub(crate) fn slim_sessions_in_db_cloud(args: &SlimArgs) -> Result<(Value, Vec), String> { + let keep = args.keep.max(1); + // 只看数(dry_run)用**只读**连接:预览不该产生 WAL / 抢写锁,真删才需要读写。 + let Some(conn) = open_db(args.db_path, args.dry_run) else { + return Err("无法打开 workbuddy.db".into()); + }; + if !table_exists(&conn, "sessions") { + return Ok((json!({ "skipped": "no sessions table" }), Vec::new())); + } + + let excl: Vec<&String> = args.exclude.iter().filter(|s| !s.is_empty()).collect(); + let victims = select_victims(&conn, args.uid, keep, &excl, args.keep_sids)?; + + let (deleted, stats) = apply_victims(&conn, args, &victims)?; + + let mut groups: std::collections::BTreeMap<&str, i64> = Default::default(); + for (_id, cwd) in &victims { + *groups.entry(cwd.as_str()).or_insert(0) += 1; + } + let mut report = json!({ + "uid": args.uid, + "keep": keep, + "keepList": args.keep_sids.is_some(), + "excluded": excl.len(), + "planned": victims.len(), + "deleted": deleted, + "groups": groups.iter().map(|(c, n)| json!({ "cwd": c, "count": n })).collect::>(), + "dryRun": args.dry_run, + }); + if args.cloud.is_some() { + report["cloud"] = + stats.to_json(args.cloud.map(|c| c.token.is_some()).unwrap_or(false)); + } + let victim_sids: Vec = victims.iter().map(|(id, _cwd)| id.clone()).collect(); + Ok((report, victim_sids)) +} + +/// 逐条处理 victims:云端删除(勾了才做)+ 本机软删,返回 `(本机软删行数, 云端逐态计数)`。 +fn apply_victims( + conn: &rusqlite::Connection, + args: &SlimArgs, + victims: &[(String, String)], +) -> Result<(usize, CloudStats), String> { + // 云端开启但没有 token ⇒ 退化为「只本地」,避免误报为 foreign + let token_ok = args.cloud.map(|c| c.token.is_some()).unwrap_or(false); + let mut stats = CloudStats::default(); + let mut deleted = 0usize; + + for (id, _cwd) in victims { + let mut allow_local = true; + if let Some(c) = args.cloud { + if c.owns(id) { + if !token_ok { + // 归属对得上但没凭证 ⇒ 云端这轮跳过,本地照常软删 + stats.no_token += 1; + } else if args.dry_run { + stats.planned += 1; + } else { + let token = c.token.as_deref().unwrap_or(""); + let outcome = crate::modules::cloud_conv::delete_conversation(token, id); + allow_local = stats.record(id, &outcome); + append_cloud_delete_audit(args.uid, id, &outcome); + } + } else if c.channels.contains_key(id.as_str()) { + stats.foreign += 1; + } else { + stats.no_mapping += 1; + } + } + + if !args.dry_run && allow_local { + let n = conn + .execute( + "UPDATE sessions SET deleted_at = ?2 WHERE id = ?1 AND deleted_at IS NULL", + rusqlite::params![id, now_ms()], + ) + .map_err(|e| e.to_string())?; + deleted += n; + } + } + Ok((deleted, stats)) +} + +/// 逐条云端删除审计 → `~/.wb-switch/cloud_delete_log.jsonl`(一行一条 JSON)。 +/// +/// 为什么单独留这个文件:统计报告里 `deleted` 是 200 与 404 的**合计**, +/// 事后无法回答「这次到底真删了几条」。App 侧日志也不记录我们直连的请求 +/// (我们走 `POST /console/as/conversations/{sid}/delete`,不经 App 的 +/// `syncDeleteConversation`)⇒ 逐条落盘是**唯一**可事后核验的证据源。 +/// +/// 只记录真实执行(非 dry_run),写失败不影响业务。 +fn append_cloud_delete_audit(uid: &str, sid: &str, outcome: &crate::modules::cloud_conv::CloudDelete) { + use std::io::Write; + let path = home_dir().join(".wb-switch").join("cloud_delete_log.jsonl"); + if let Some(dir) = path.parent() { + let _ = std::fs::create_dir_all(dir); + } + // 简易轮转:超过 2 MiB 直接换名覆盖,避免无限增长(审计只需近期)。 + if std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0) > 2 * 1024 * 1024 { + let _ = std::fs::rename(&path, path.with_extension("jsonl.old")); + } + let (result, detail) = match outcome { + crate::modules::cloud_conv::CloudDelete::Deleted => ("removed", "http 200"), + crate::modules::cloud_conv::CloudDelete::AlreadyGone => ("alreadyGone", "http 404"), + crate::modules::cloud_conv::CloudDelete::Forbidden => ("forbidden", "http 403"), + crate::modules::cloud_conv::CloudDelete::Failed(m) => ("failed", m.as_str()), + }; + let line = json!({ + "at": now_ms(), + "ts": utc_iso(), + "uid": uid, + "sid": sid, + "result": result, + "detail": detail, + }) + .to_string(); + if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&path) { + let _ = writeln!(f, "{line}"); + } +} + +/// 备份目录时间戳:毫秒级。 +/// +/// 不能用 `utc_iso()`(只到秒)——一次切号里项目侧栏同步与会话瘦身会连续各备份一次, +/// 秒级目录名重名导致后一次覆盖前一次,**pre-同步的快照丢失**,回滚点被后移。 +fn backup_stamp() -> String { + format!("{}-{}Z", utc_iso().trim_end_matches('Z'), now_ms()) +} + +/// 真实路径包装:会话瘦身(含 db 备份)。`exclude` = 不参与瘦身的会话 id(本次复制体)。 +/// +/// `keep_sids` = **统一保留名单**(源∪目标合并、每项目最新 N 条等由调用方计算后 +/// 整体传入)。传入时瘦身判定 = +/// 「目标存活 & 名单外 ⇒ 删」,`keep` 参数被忽略;`None` 时退回按每 cwd 保留 `keep` 条的旧逻辑。 +/// +/// 云端删除是瘦身的默认组成部分(不再单独设开关):装配云端上下文——读映射表 + +/// 取该账号 token;任何一步缺失都不致命,会在报告的 `slim.cloud.tokenReady` 里 +/// 如实反映,流程退化为「只本地瘦身」。删除按 `cloud_conv` 的三条铁律走。 +/// +/// 末尾追加**对账阶段**(`cloud.reconcile`):映射行全集 × 本机 sessions, +/// 清「本机已软删但云端还在」的残留;映射行有、本机无行的 Unknown 只计数不删 +/// (可能是其他设备的活会话)。映射库只读,绝不写删。 +pub fn slim_sessions( + uid: &str, + keep: i64, + dry_run: bool, + exclude: &[String], + keep_sids: Option<&[String]>, +) -> Result { + let mut db_backup: Option = None; + if !dry_run { + let root = backup_dir().join("projects_anchor").join(backup_stamp()); + db_backup = backup_workbuddy_db(crate::modules::variant::WbVariant::Cn, &root).map(|p| p.to_string_lossy().to_string()); + } + let ctx = Some(SlimCloudCtx { + uid: uid.to_string(), + token: crate::modules::cloud_conv::token_of(uid), + channels: crate::modules::cloud_conv::mapping_channels(), + }); + let db = workbuddy_db_path(crate::modules::variant::WbVariant::Cn); + let (mut report, victim_sids) = slim_sessions_in_db_cloud(&SlimArgs { + db_path: &db, + uid, + keep, + dry_run, + exclude, + keep_sids, + cloud: ctx.as_ref(), + })?; + if let Some(cloud) = report.get_mut("cloud") { + // 本次瘦身刚处理过的 sid 交给对账做 skip:否则它们刚被软删就落入「本机已软删」 + // 集合,对账会对同一批再发一轮删除请求(幂等但冗余)。 + let skip: std::collections::HashSet = victim_sids.into_iter().collect(); + cloud["reconcile"] = reconcile_cloud_stage(uid, dry_run, &skip); + cloud["inventory"] = inventory_cloud_stage(uid); + } + // `null` = 这次没拿到回滚点(拷贝失败不再被静默吞掉,见 session::backup_workbuddy_db)。 + report["backupDb"] = json!(db_backup); + Ok(report) +} + +/// 对账阶段:映射行全集 × 本机 sessions → 清「本机已软删但云端还在」。 +/// 任何一步失败都不致命(返回 error 对象,不影响瘦身主流程)。 +/// +/// `skip` 见 `cloud_reconcile::sweep_stale_mappings`。 +fn reconcile_cloud_stage( + uid: &str, + dry_run: bool, + skip: &std::collections::HashSet, +) -> Value { + let rows = crate::modules::cloud_conv::mapping_rows(); + if rows.is_empty() { + return json!({ "skipped": "no mapping db" }); + } + let (alive, deleted) = match read_local_alive_deleted(&workbuddy_db_path(crate::modules::variant::WbVariant::Cn), uid) { + Ok(v) => v, + Err(e) => return json!({ "error": e }), + }; + let token = crate::modules::cloud_conv::token_of(uid); + crate::modules::cloud_reconcile::sweep_stale_mappings( + &rows, + &alive, + &deleted, + token.as_deref(), + dry_run, + skip, + |tok, cid| { + let outcome = crate::modules::cloud_conv::delete_conversation(tok, cid); + // 对账阶段也必须逐条落审计: + // `cloud_delete_log.jsonl` 是唯一事后证据源,漏这段 ⇒ 对账删了什么无从查证。 + append_cloud_delete_audit(uid, cid, &outcome); + outcome + }, + ) +} + +/// 全账巡检阶段(**只读,永不删**):云端全账 × 本机 sessions → 分类计数。 +/// +/// 吃 `GET /v2/as/conversations/?type=all`(跨设备全账)—— +/// 补上 `reconcile_cloud_stage` 的盲区:映射行那本账里**没有**的云端会话 +/// (他机创建 / 云端自动化)它根本看不见。 +/// +/// 只报数,不做任何删除:`foreign`(本机无痕迹)多半是别的设备的活会话, +/// 删了就伤到别人。dry_run 与否都照跑(无副作用)。 +fn inventory_cloud_stage(uid: &str) -> Value { + let (alive, deleted) = match read_local_alive_deleted(&workbuddy_db_path(crate::modules::variant::WbVariant::Cn), uid) { + Ok(v) => v, + Err(e) => return json!({ "error": e }), + }; + let token = crate::modules::cloud_conv::token_of(uid); + crate::modules::cloud_reconcile::inventory( + token.as_deref(), + &alive, + &deleted, + crate::modules::cloud_conv::list_conversation_ids, + ) +} + +/// 读**某个账号名下** sessions 的存活/软删 id 集合(对账用,只读)。 +/// +/// ⚠️ 必须按 `user_id` 过滤:本机 sessions 表里躺着所有账号的会话, +/// 全表取出来跟「单个账号的云端清单」比对 ⇒ 别人家的会话全被算成「该账号没上云」 +/// (实测 localOnlyAlive 虚报 165 条,真实为 0)、「清遗留」也混进别账号的软删 +/// (199 vs 目标账号自己 129)。删不删得掉另说,数字先得是真的。 +fn read_local_alive_deleted( + db: &std::path::Path, + uid: &str, +) -> Result<(std::collections::HashSet, std::collections::HashSet), String> { + use std::collections::HashSet; + let conn = rusqlite::Connection::open_with_flags( + db, + rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY, + ) + .map_err(|e| format!("打开 workbuddy.db 失败: {e}"))?; + let mut alive = HashSet::new(); + let mut deleted = HashSet::new(); + let mut stmt = conn + .prepare("SELECT id, deleted_at FROM sessions WHERE user_id = ?1") + .map_err(|e| format!("查询 sessions 失败: {e}"))?; + let rows = stmt + .query_map([uid], |r| { + Ok((r.get::<_, String>(0)?, r.get::<_, Option>(1)?)) + }) + .map_err(|e| format!("查询 sessions 失败: {e}"))?; + for row in rows { + let (id, deleted_at) = row.map_err(|e| format!("读取 sessions 行失败: {e}"))?; + if deleted_at.is_none() { + alive.insert(id); + } else { + deleted.insert(id); + } + } + Ok((alive, deleted)) +} + +#[cfg(test)] +mod tests { + use super::*; + use rusqlite::Connection; + use std::path::PathBuf; + + /// 备份戳必须带毫秒:一次切号内连续两次备份若重名,后一次会覆盖前一次的快照。 + #[test] + fn backup_stamp_is_unique_per_millisecond() { + let a = backup_stamp(); + assert!(a.ends_with('Z'), "保持与 utc_iso 一致的 Z 后缀: {a}"); + let sec_len = "2026-09-13T20-04-43Z".len(); + assert!(a.len() > sec_len, "毫秒后缀不能丢: {a}"); + // 连续两次至少不因「格式不含毫秒」而重名 + let millis = a.trim_end_matches('Z').rsplit('-').next().unwrap_or(""); + assert!( + millis.chars().all(|c| c.is_ascii_digit()) && millis.len() >= 12, + "毫秒段应为 now_ms 的数值: {a}" + ); + } + + fn temp_db(name: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "wb_anchor_test_{}_{name}.db", + uuid::Uuid::new_v4().simple() + )) + } + + fn setup(db: &Path) { + let conn = Connection::open(db).unwrap(); + conn.execute_batch( + "CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + cwd TEXT NOT NULL, + user_id TEXT NOT NULL, + title TEXT, + status TEXT DEFAULT 'Pending', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + last_activity_at INTEGER, + deleted_at INTEGER, + is_playground INTEGER DEFAULT 0, + source_mode TEXT, + mode TEXT + );", + ) + .unwrap(); + for (id, cwd, uid, upd) in [ + ("s1", "D:\\p1", "uid-a", 1000), + ("s2", "D:\\p1", "uid-a", 2000), // p1 两条,瘦身应删 s1(旧) + ("s3", "D:\\p2", "uid-a", 1000), + ("s4", "D:\\p3", "uid-b", 1000), // b 独有 → 删多目标 + ("s5", "D:\\p2", "uid-b", 1000), // b 在 p2 也有 → 不补 + ] { + conn.execute( + "INSERT INTO sessions (id, cwd, user_id, title, created_at, updated_at) + VALUES (?1, ?2, ?3, 't', 1, ?4)", + rusqlite::params![id, cwd, uid, upd], + ) + .unwrap(); + } + } + + #[test] + fn slim_keeps_latest_per_cwd() { + let db = temp_db("slim"); + setup(&db); + let rep = slim_sessions_in_db(&db, "uid-a", 1, false, &[]).unwrap(); + assert_eq!(rep["deleted"], 1, "p1 两条留最新,删 1"); + let conn = Connection::open(&db).unwrap(); + let alive: i64 = conn + .query_row( + "SELECT COUNT(*) FROM sessions WHERE user_id='uid-a' AND deleted_at IS NULL", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(alive, 2, "p1 留 s2、p2 留 s3"); + let kept: String = conn + .query_row("SELECT id FROM sessions WHERE user_id='uid-a' AND cwd='D:\\p1' AND deleted_at IS NULL", [], |r| r.get(0)) + .unwrap(); + assert_eq!(kept, "s2", "保留 updated_at 最新的"); + } + + /// 对账取本机存活/软删必须**按账号过滤**:本机 sessions 里躺着所有账号的会话, + /// 全表取出来跟「单个账号的云端清单」比对,会把别人家的会话全算成「该账号没上云」 + /// (实测:不按 uid 过滤会把全账号话计入,严重虚高)。 + #[test] + fn read_local_alive_deleted_is_scoped_to_one_account() { + let db = temp_db("alive_deleted_scope"); + { + let conn = Connection::open(&db).unwrap(); + conn.execute_batch( + "CREATE TABLE sessions ( + id TEXT PRIMARY KEY, cwd TEXT, user_id TEXT, title TEXT, + created_at INTEGER, updated_at INTEGER, deleted_at INTEGER);", + ) + .unwrap(); + let rows: [(&str, &str, Option); 6] = [ + ("me-alive-1", "uid-me", None), + ("me-alive-2", "uid-me", None), + ("me-dead-1", "uid-me", Some(1)), + ("other-alive-1", "uid-other", None), + ("other-alive-2", "uid-other", None), + ("other-dead-1", "uid-other", Some(1)), + ]; + for (id, uid, del) in rows { + conn.execute( + "INSERT INTO sessions (id, cwd, user_id, title, created_at, updated_at, deleted_at) + VALUES (?1, 'D:\\p', ?2, 't', 1, 1, ?3)", + rusqlite::params![id, uid, del], + ) + .unwrap(); + } + } + let (alive, deleted) = read_local_alive_deleted(&db, "uid-me").unwrap(); + assert_eq!(alive.len(), 2, "只数目标账号的存活会话"); + assert_eq!(deleted.len(), 1, "只数目标账号的软删会话"); + assert!(alive.contains("me-alive-1") && !alive.contains("other-alive-1")); + assert!(deleted.contains("me-dead-1") && !deleted.contains("other-dead-1")); + } + + /// 云端连带删除的**归属分流**:无映射 / 归本账号(无 token)/ 归别的账号 三类必须分得清, + /// 且本用例**全程不联网**(token 传 `None` ⇒ 一律不发请求)。 + #[test] + fn slim_cloud_routes_by_ownership_without_network() { + let db = temp_db("slim_cloud"); + { + let conn = Connection::open(&db).unwrap(); + conn.execute_batch( + "CREATE TABLE sessions ( + id TEXT PRIMARY KEY, cwd TEXT, user_id TEXT, title TEXT, + created_at INTEGER, updated_at INTEGER, deleted_at INTEGER);", + ) + .unwrap(); + // 每个 cwd 两条:旧的会被淘汰(keep=1 留最新) + for (cwd, pfx) in [("D:\\p9", "none"), ("D:\\p8", "own"), ("D:\\p7", "foreign")] { + for (suf, upd) in [("a", 100), ("b", 200)] { + conn.execute( + "INSERT INTO sessions (id, cwd, user_id, title, created_at, updated_at) + VALUES (?1, ?2, 'uid-a', 't', 1, ?3)", + rusqlite::params![format!("{pfx}_{suf}"), cwd, upd], + ) + .unwrap(); + } + } + } + // 只给 own_* 与 foreign_* 配映射;none_* 故意不配 + let mut channels = std::collections::HashMap::new(); + channels.insert("own_a".to_string(), "convmsg:uid-a".to_string()); + channels.insert("foreign_a".to_string(), "convmsg:uid-b".to_string()); + let ctx = SlimCloudCtx { + uid: "uid-a".into(), + token: None, + channels, + }; + + // ① dry-run:只统计「将调云端」条数,不落盘、不发请求 + let (rep, sids) = slim_sessions_in_db_cloud(&SlimArgs { + db_path: &db, + uid: "uid-a", + keep: 1, + dry_run: true, + exclude: &[], + keep_sids: None, + cloud: Some(&ctx), + }) + .unwrap(); + assert_eq!(rep["planned"], 3, "三个 cwd 各淘汰 1 条"); + assert_eq!(rep["deleted"], 0, "dry-run 不落盘"); + assert_eq!(rep["cloud"]["enabled"], true); + assert_eq!(rep["cloud"]["tokenReady"], false); + assert_eq!(sids.len(), 3, "第二项返回本次 victim sid,供对账阶段 skip"); + + // ② 真执行但无 token ⇒ 三类都只本地软删,一条云端请求都不发 + let rep2 = slim_sessions_in_db_cloud(&SlimArgs { + db_path: &db, + uid: "uid-a", + keep: 1, + dry_run: false, + exclude: &[], + keep_sids: None, + cloud: Some(&ctx), + }) + .unwrap() + .0; + assert_eq!(rep2["deleted"], 3, "三类都应完成本地软删"); + assert_eq!(rep2["cloud"]["deleted"], 0); + assert_eq!( + rep2["cloud"]["removed"], 0, + "真删数(200)与 alreadyGone(404)必须分开报,否则事后无法自证删成功" + ); + assert_eq!(rep2["cloud"]["alreadyGone"], 0); + assert_eq!(rep2["cloud"]["failed"], 0, "无 token 不算失败,本地不被卡住"); + assert_eq!(rep2["cloud"]["noToken"], 1, "own_a:归属对但没凭证"); + assert_eq!(rep2["cloud"]["foreign"], 1, "foreign_a:云端归别的账号,不碰"); + assert_eq!(rep2["cloud"]["noMapping"], 1, "none_a:本机没有映射"); + + // ③ 不勾云端 ⇒ 报告里根本没有 cloud 段(旧行为不变) + let rep3 = slim_sessions_in_db_cloud(&SlimArgs { + db_path: &db, + uid: "uid-b", + keep: 1, + dry_run: true, + exclude: &[], + keep_sids: None, + cloud: None, + }) + .unwrap() + .0; + assert!(rep3.get("cloud").is_none(), "未开启时不应出现 cloud 字段"); + } + + /// 回归:「复制多条同项目会话 + 瘦身 keep=1」——复制体必须全部存活,且不挤掉原有保留名额。 + #[test] + fn slim_protects_copied_sessions() { + let db = temp_db("slim_protect"); + setup(&db); + { + let conn = Connection::open(&db).unwrap(); + // 模拟本次切号复制到 uid-a 的两条同项目会话(时间戳最新) + for (id, upd) in [("c1", 3000), ("c2", 4000)] { + conn.execute( + "INSERT INTO sessions (id, cwd, user_id, title, created_at, updated_at) + VALUES (?1, 'D:\\p1', 'uid-a', 'copied', 1, ?2)", + rusqlite::params![id, upd], + ) + .unwrap(); + } + } + let alive = |db: &Path| -> Vec { + let conn = Connection::open(db).unwrap(); + let mut stmt = conn + .prepare("SELECT id FROM sessions WHERE cwd='D:\\p1' AND deleted_at IS NULL ORDER BY id") + .unwrap(); + stmt.query_map([], |r| r.get::<_, String>(0)) + .unwrap() + .flatten() + .collect() + }; + + // 无保护:keep=1 只留最新的 c2,复制体互相挤掉(用户只看到 1 条) + let rep = slim_sessions_in_db(&db, "uid-a", 1, false, &[]).unwrap(); + let kept = alive(&db); + assert_eq!(kept, vec!["c2".to_string()], "无保护时只剩最新一条: {kept:?}"); + assert_eq!(rep["deleted"], 3); + + // 有保护:c1/c2 都留,且 p1 原有的最新一条 s2 也留(复制体不占名额) + let db2 = temp_db("slim_protect2"); + setup(&db2); + { + let conn = Connection::open(&db2).unwrap(); + for (id, upd) in [("c1", 3000), ("c2", 4000)] { + conn.execute( + "INSERT INTO sessions (id, cwd, user_id, title, created_at, updated_at) + VALUES (?1, 'D:\\p1', 'uid-a', 'copied', 1, ?2)", + rusqlite::params![id, upd], + ) + .unwrap(); + } + } + let rep2 = slim_sessions_in_db( + &db2, + "uid-a", + 1, + false, + &["c1".to_string(), "c2".to_string()], + ) + .unwrap(); + let kept2 = alive(&db2); + assert_eq!( + kept2, + vec!["c1".to_string(), "c2".to_string(), "s2".to_string()], + "复制体受保护 + 原有最新一条: {kept2:?}" + ); + assert_eq!(rep2["deleted"], 1, "只删旧的 s1"); + assert_eq!(rep2["excluded"], 2); + } + +} diff --git a/crates/wb-switch-core/src/modules/token_stats.rs b/crates/wb-switch-core/src/modules/token_stats.rs index 8f48976a..f2fcd0e9 100644 --- a/crates/wb-switch-core/src/modules/token_stats.rs +++ b/crates/wb-switch-core/src/modules/token_stats.rs @@ -1196,14 +1196,23 @@ mod tests { // iteration, which is not sorted. The copy would then be processed // first, own the replayed record, and leave the original with zero // records. Pin the mtimes so the original always precedes its copy. - std::fs::File::open(project.join("session-original.jsonl")) - .expect("open original fixture") - .set_modified(std::time::SystemTime::now() - std::time::Duration::from_secs(60)) - .expect("pin original mtime"); - std::fs::File::open(project.join("session-forked.jsonl")) - .expect("open forked fixture") - .set_modified(std::time::SystemTime::now()) - .expect("pin forked mtime"); + // Opened with `write(true)` on purpose: Windows implements + // `set_modified` via `SetFileTime`, which needs FILE_WRITE_ATTRIBUTES, + // so a read-only `File::open` handle fails with "access denied" there. + // A writable handle works on every platform. + let pin_mtime = |name: &str, when: std::time::SystemTime| { + std::fs::OpenOptions::new() + .write(true) + .open(project.join(name)) + .expect("open fixture to pin mtime") + .set_modified(when) + .expect("pin fixture mtime"); + }; + pin_mtime( + "session-original.jsonl", + std::time::SystemTime::now() - std::time::Duration::from_secs(60), + ); + pin_mtime("session-forked.jsonl", std::time::SystemTime::now()); let result = source(root.clone(), "fixture", None, false); // The replayed record counts once; the fork's new record still counts. @@ -1567,11 +1576,17 @@ mod tests { ) .expect("write forked fixture"); // 与聚合去重用例相同:固定 mtime,保证原始会话先于副本被处理。 - std::fs::File::open(project.join("session-original.jsonl")) + // Windows 上 `set_modified` 走 SetFileTime 需要 FILE_WRITE_ATTRIBUTES, + // 只读句柄会 Access Denied ⇒ 用 write(true) 打开(同 pin_mtime 模式)。 + std::fs::OpenOptions::new() + .write(true) + .open(project.join("session-original.jsonl")) .expect("open original fixture") .set_modified(std::time::SystemTime::now() - std::time::Duration::from_secs(60)) .expect("pin original mtime"); - std::fs::File::open(project.join("session-forked.jsonl")) + std::fs::OpenOptions::new() + .write(true) + .open(project.join("session-forked.jsonl")) .expect("open forked fixture") .set_modified(std::time::SystemTime::now()) .expect("pin forked mtime"); @@ -1971,12 +1986,17 @@ mod tests { .expect("write second-root fixture"); // 与 source_deduplicates_copied_session_history 同理:毫秒级 mtime 并列时 // 顺序退化为 readdir,重放记录可能先被第二个根认领。pin 住 mtime 让 - // 原始会话先处理,断言才稳定。 - std::fs::File::open(projects.join("session-original.jsonl")) + // 原始会话先处理,断言才稳定。(Windows 只读句柄 set_modified 会 + // Access Denied ⇒ 用 write(true) 打开,同 pin_mtime 模式。) + std::fs::OpenOptions::new() + .write(true) + .open(projects.join("session-original.jsonl")) .expect("open original fixture") .set_modified(std::time::SystemTime::now() - std::time::Duration::from_secs(60)) .expect("pin original mtime"); - std::fs::File::open(sessions.join("session-root-two.jsonl")) + std::fs::OpenOptions::new() + .write(true) + .open(sessions.join("session-root-two.jsonl")) .expect("open second-root fixture") .set_modified(std::time::SystemTime::now()) .expect("pin second-root mtime"); diff --git a/crates/wb-switch-core/src/modules/variant.rs b/crates/wb-switch-core/src/modules/variant.rs index 0246c556..80e07714 100644 --- a/crates/wb-switch-core/src/modules/variant.rs +++ b/crates/wb-switch-core/src/modules/variant.rs @@ -251,7 +251,8 @@ impl WbVariant { /// macOS app 路径探测全失败时的回落路径。 pub fn macos_default_app_path(self) -> PathBuf { - Path::new("/Applications").join(self.macos_app_names()[0]) + // 固定的 macOS 路径:用字符串模板拼,避免在非 macOS 平台上被 Path::join 换成本机分隔符。 + PathBuf::from(format!("/Applications/{}", self.macos_app_names()[0])) } /// Linux 默认可执行文件路径。 @@ -363,19 +364,21 @@ mod tests { #[test] fn auth_file_path_covers_three_platforms() { let home = Path::new("/home/tester"); + // 跨平台语义路径统一按 `/` 比较(本机分隔符不参与断言)。 + let slash = |p: &Path| p.to_string_lossy().replace('\\', "/"); let cn = WbVariant::Cn.auth_file_path_at(home, HostOs::Macos); assert_eq!( - cn.to_string_lossy(), + slash(&cn), "/home/tester/Library/Application Support/CodeBuddyExtension/Data/Public/auth/workbuddy-desktop.info" ); let cn_win = WbVariant::Cn.auth_file_path_at(home, HostOs::Windows); assert_eq!( - cn_win.to_string_lossy(), + slash(&cn_win), "/home/tester/AppData/Local/CodeBuddyExtension/Data/Public/auth/workbuddy-desktop.info" ); let cn_linux = WbVariant::Cn.auth_file_path_at(home, HostOs::Linux); assert_eq!( - cn_linux.to_string_lossy(), + slash(&cn_linux), "/home/tester/.local/share/CodeBuddyExtension/Data/Public/auth/workbuddy-desktop.info" ); diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 1a4d8cf5..28025f2e 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -10,7 +10,7 @@ use tauri::Emitter; use wb_switch_core::modules::{ account, auth_file, checkin, codebuddy_cli, codebuddy_cn_ide, codebuddy_ide, credit_usage, credits, export_import, limits, oauth, process, rate_limit_events, rate_limit_hook, refresh, - rotate, session, switch, token_stats, travel, update, variant::WbVariant, + rotate, session, session_slim, switch, token_stats, travel, update, variant::WbVariant, }; #[derive(Serialize)] @@ -739,3 +739,33 @@ pub fn set_launch_at_login_enabled(_app: tauri::AppHandle, enabled: bool) -> Res Err("当前平台不支持开机自启".to_string()) } } + +/// 清理旧会话:每 cwd 保留 `updated_at` 最新 `keep` 条,其余软删; +/// 已上云的会话同步删除云端,并对账清残留。`dry_run` 只出报告不删任何东西。 +#[tauri::command(rename_all = "camelCase")] +pub async fn cleanup_sessions( + account_id: String, + keep: Option, + dry_run: Option, +) -> Result { + if account_id.trim().is_empty() { + return Err("缺少 accountId".to_string()); + } + let keep = keep.unwrap_or(3).max(0); + let dry_run = dry_run.unwrap_or(false); + let uid = account::load_accounts() + .iter() + .find(|a| a.get("id").and_then(|v| v.as_str()) == Some(account_id.as_str())) + .and_then(|a| { + a.get("uid") + .and_then(|v| v.as_str()) + .map(|s| s.trim().to_string()) + }) + .filter(|s| !s.is_empty()) + .ok_or_else(|| "未找到该账号或账号缺少 uid".to_string())?; + tauri::async_runtime::spawn_blocking(move || { + session_slim::slim_sessions(&uid, keep, dry_run, &[], None) + }) + .await + .map_err(|e| format!("清理任务执行失败: {e}"))? +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b795048e..3dcb38c6 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -193,6 +193,7 @@ pub fn run() { commands::preview_import_accounts, commands::import_accounts, commands::switch_account, + commands::cleanup_sessions, commands::list_sessions, commands::copy_sessions, commands::open_permission_settings, diff --git a/src/components/account-card.tsx b/src/components/account-card.tsx index 148bf834..6f3c5287 100644 --- a/src/components/account-card.tsx +++ b/src/components/account-card.tsx @@ -1,4 +1,4 @@ -import { ArrowRight, CalendarCheck2, CalendarDays, Check, CircleCheck, Clock3, Coins, Ellipsis, Gauge, Loader2, PackageOpen, PlaneTakeoff, RefreshCw, Sparkles, Star, Trash2 } from "lucide-react"; +import { ArrowRight, CalendarCheck2, History, CalendarDays, Check, CircleCheck, Clock3, Coins, Ellipsis, Gauge, Loader2, PackageOpen, PlaneTakeoff, RefreshCw, Sparkles, Star, Trash2 } from "lucide-react"; import { useEffect, useState, type ReactNode } from "react"; import { Badge } from "@/components/ui/badge"; @@ -252,6 +252,7 @@ interface Props { onCheckin?: (a: AccountMeta) => void; onRefresh?: (a: AccountMeta) => void; onSwitch?: (a: AccountMeta) => void; + onCleanupSessions?: (a: AccountMeta) => void; todayCheckedIn?: boolean; /** 今日旅行状态(undefined=查询中/未知,不渲染标签) */ travelStatus?: TravelStatus; @@ -355,7 +356,7 @@ function CreditResourceRow({ resource, compact, placeholderLabel }: { resource?: ); } -export function AccountCard({ account, onDelete, onCheckin, onRefresh, onSwitch, todayCheckedIn, travelStatus, rateLimits, credit, creditLoading, creditUpdatedAt, creditPriority, workbuddyActive, codebuddyCliConfigured, codebuddyCliActive, codebuddyCliBusy, onSwitchCodebuddyCli, codebuddyCliLoading, codebuddyCnIdeAvailable, codebuddyCnIdeActive, codebuddyCnIdeBusy, codebuddyCnIdeLoading, onSwitchCodebuddyCnIde, featuresDisabled = true, compact = false }: Props) { +export function AccountCard({ account, onDelete, onCheckin, onRefresh, onSwitch, onCleanupSessions, todayCheckedIn, travelStatus, rateLimits, credit, creditLoading, creditUpdatedAt, creditPriority, workbuddyActive, codebuddyCliConfigured, codebuddyCliActive, codebuddyCliBusy, onSwitchCodebuddyCli, codebuddyCliLoading, codebuddyCnIdeAvailable, codebuddyCnIdeActive, codebuddyCnIdeBusy, codebuddyCnIdeLoading, onSwitchCodebuddyCnIde, featuresDisabled = true, compact = false }: Props) { const [resourcesOpen, setResourcesOpen] = useState(false); const [now, setNow] = useState(() => Date.now()); /** @@ -481,6 +482,9 @@ export function AccountCard({ account, onDelete, onCheckin, onRefresh, onSwitch, 手动签到 )} + onCleanupSessions?.(account)}> + 清理旧会话 + onDelete(account)}> 删除账号 diff --git a/src/components/cleanup-sessions-dialog.tsx b/src/components/cleanup-sessions-dialog.tsx new file mode 100644 index 00000000..fcac9799 --- /dev/null +++ b/src/components/cleanup-sessions-dialog.tsx @@ -0,0 +1,156 @@ +import { useState } from "react"; +import { Loader2, Trash2 } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import * as api from "@/lib/api"; +import type { AccountMeta } from "@/lib/types"; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; + account: AccountMeta | null; + /** 清理完成后回调,用于刷新会话相关数据。 */ + onCleaned?: () => void; +} + +/** 从宽松报告里取数字,取不到按 0 处理。 */ +function numOf(report: api.CleanupReport | null, path: string[]): number { + let cur: unknown = report; + for (const key of path) { + if (typeof cur !== "object" || cur === null) return 0; + cur = (cur as Record)[key]; + } + return typeof cur === "number" ? cur : 0; +} + +function accountLabel(a: AccountMeta): string { + return a.nickname || a.email || a.uid || a.id; +} + +/** 会话数描述。 */ +function countLine(report: api.CleanupReport | null): string { + const planned = numOf(report, ["planned"]); + const deleted = numOf(report, ["deleted"]); + const cloudRemoved = numOf(report, ["cloud", "removed"]); + const localPart = numOf(report, ["dryRun"]) ? `本地将清理 ${planned} 条` : `本地已清理 ${deleted} 条`; + const cloudPart = numOf(report, ["cloud", "tokenReady"]) + ? numOf(report, ["dryRun"]) + ? `,云端将删除 ${cloudRemoved} 条` + : `,云端已删除 ${cloudRemoved} 条` + : ""; + return `${localPart}${cloudPart}。`; +} + +/** + * 清理旧会话对话框:先预览(dry_run)再执行,两步走,避免手一抖删多了。 + */ +export function CleanupSessionsDialog({ open, onOpenChange, account, onCleaned }: Props) { + const [keep, setKeep] = useState("3"); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [preview, setPreview] = useState(null); + const [done, setDone] = useState(null); + + const reset = () => { + setKeep("3"); + setBusy(false); + setError(""); + setPreview(null); + setDone(null); + }; + + const run = async (dryRun: boolean) => { + if (!account) return; + const n = Math.max(0, Number.parseInt(keep, 10) || 0); + setBusy(true); + setError(""); + try { + const report = await api.cleanupSessions({ accountId: account.id, keep: n, dryRun }); + if (dryRun) { + setPreview(report); + } else { + setDone(report); + onCleaned?.(); + } + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(false); + } + }; + + return ( + { + if (!next) reset(); + onOpenChange(next); + }} + > + + + 清理旧会话 + + {account + ? `帮 ${accountLabel(account)} 整理会话:每个项目只保留最新的几条,其余的连同云端副本一起清掉。先预览看看范围,满意了再动手。` + : ""} + + + +
+ +
+ setKeep(e.target.value)} + className="w-24" + disabled={busy} + /> + 条会话(至少 1 条) +
+ + {preview && ( +
+ 预览结果:{countLine(preview)} +
+ )} + {done && ( +
+ 搞定:{countLine(done)}删掉的会话已备份,出问题可以从备份目录找回。 +
+ )} + {error &&
{error}
} +
+ + + + + +
+
+ ); +} diff --git a/src/lib/api.ts b/src/lib/api.ts index 85632f94..46f8f685 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -623,3 +623,15 @@ export function asError(e: unknown): string { if (e instanceof Error) return e.message; return JSON.stringify(e ?? "未知错误"); } + +/** 清理旧会话报告(宽松结构,按需取字段)。 */ +export type CleanupReport = Record; + +/** 清理旧会话:每 cwd 保留最新 keep 条,已上云的同步删云端;dryRun 只出报告。 */ +export function cleanupSessions(args: { + accountId: string; + keep?: number; + dryRun?: boolean; +}): Promise { + return call("cleanup_sessions", args as unknown as Record); +} diff --git a/src/pages/AccountsPage.tsx b/src/pages/AccountsPage.tsx index 21cfe81b..0a71abb9 100644 --- a/src/pages/AccountsPage.tsx +++ b/src/pages/AccountsPage.tsx @@ -17,6 +17,7 @@ import { } from "lucide-react"; import { AccountCard } from "@/components/account-card"; +import { CleanupSessionsDialog } from "@/components/cleanup-sessions-dialog"; import { DemoAction } from "@/components/demo-action"; import { CodeBuddyAiIdeMark, @@ -173,6 +174,7 @@ export default function AccountsPage() { const [exportOpen, setExportOpen] = useState(false); const [importOpen, setImportOpen] = useState(false); const [switchAccount, setSwitchAccount] = useState(null); + const [cleanupAccount, setCleanupAccount] = useState(null); const [importing, setImporting] = useState(false); const [autoCheckinConfig, setAutoCheckinConfig] = useState(null); const [autoCheckinSaving, setAutoCheckinSaving] = useState(false); @@ -1056,6 +1058,7 @@ export default function AccountsPage() { compact={compact} onDelete={onDelete} onSwitch={setSwitchAccount} + onCleanupSessions={setCleanupAccount} onCheckin={onCheckin} onRefresh={onRefresh} todayCheckedIn={checkinMap[a.id]} @@ -1108,6 +1111,16 @@ export default function AccountsPage() { void refreshCodebuddyCnIdeStatus(); }} /> + { + if (!o) setCleanupAccount(null); + }} + account={cleanupAccount} + onCleaned={() => { + void fetchAll(); + }} + /> {/* 接入/升级 CLI 认证确认(桌面 App 不支持 window.confirm) */}