# Desktop 后端异步化 + 应用级缓存 设计文档

## 1. 现状分析

### 1.1 当前调用链

```
前端 ipc.xxx()
  → Tauri invoke (IPC bridge, 同步等待)
    → src-tauri/commands.rs #[tauri::command] fn (同步)
      → spool::desktop::tauri::desktop_xxx (同步)
        → DesktopService.xxx (同步)
          → LifecycleService / app::run_with_overrides / memory_gateway (同步 IO)
```

### 1.2 性能瓶颈

| 操作 | 瓶颈来源 | 典型耗时 |
|------|----------|----------|
| `load_workbench` | 读 ledger JSONL → 投影重建（首次）或 fingerprint 校验 + 快照反序列化 | 50-200ms |
| `run_context` | vault 扫描 + 评分 + 渲染 | 100-500ms（取决于 vault 大小） |
| `build_wakeup` | vault 扫描 + lifecycle 读 + packet 组装 | 150-600ms |
| `browse_sessions` | 扫描多个 provider 目录 + 解析 session 索引 | 80-300ms |
| `get_session` | ledger 全量读 + session 文件解析 + 消息加载 | 100-400ms |
| `import_session` | 消息解析 + candidate 提取 + 可选 ledger 写入 | 200-1000ms |

所有操作都在 Tauri 主线程同步执行。当 vault 较大或 ledger 条目较多时，UI 会出现明显卡顿。

### 1.3 重复计算

- 每次 `load_workbench` 都重新构造 `LifecycleStore` + 读投影
- 每次 `browse_sessions` 都重新扫描 provider 目录
- `get_session` 内部调 `latest_state_entries` 全量读一次 ledger
- 多个 command 之间无状态共享，`DesktopService` 是 zero-size struct

### 1.4 已有缓存基础设施

`lifecycle_store/projection.rs` 已实现三级缓存：
- L1: 进程内 `static PROJECTION_CACHE: Lazy<Mutex<HashMap<PathBuf, ProjectionCacheEntry>>>`
- L2: 磁盘快照 `memory-ledger.latest-state.json`
- L3: 全量重建

fingerprint 基于 `(file_len, modified_nanos)`，append 后自动 invalidate。

**问题**：这个缓存在 CLI/MCP 短生命周期进程中有效，但在 Tauri 长驻进程中不够——每次 command 仍需 lock mutex + 比较 fingerprint + clone projection。

---

## 2. 设计目标

1. **非阻塞 UI**：所有 Tauri command 异步执行，不阻塞 webview 主线程
2. **跨 command 状态复用**：Tauri 进程内维护单例 AppState，避免重复构造
3. **智能缓存失效**：文件变更驱动（notify watcher），而非每次请求都校验 fingerprint
4. **渐进式改造**：不改变核心 crate 的同步 API，仅在 Tauri 层包装异步
5. **向后兼容**：CLI / MCP / daemon 不受影响

---

## 3. 架构设计

### 3.1 分层视图

```
┌─────────────────────────────────────────────────────────┐
│  Frontend (React)                                        │
│  ipc.loadWorkbench() → Tauri invoke (async)             │
└──────────────────────────┬──────────────────────────────┘
                           │ IPC (JSON)
┌──────────────────────────▼──────────────────────────────┐
│  src-tauri/commands.rs                                   │
│  #[tauri::command] async fn → State<AppState>           │
└──────────────────────────┬──────────────────────────────┘
                           │
┌──────────────────────────▼──────────────────────────────┐
│  AppState (Tauri managed state)                          │
│  ┌─────────────┐  ┌──────────────┐  ┌───────────────┐  │
│  │ CachedStore │  │ SessionCache │  │ ConfigWatcher │  │
│  │ (RwLock)    │  │ (RwLock)     │  │ (notify)      │  │
│  └─────────────┘  └──────────────┘  └───────────────┘  │
└──────────────────────────┬──────────────────────────────┘
                           │ spawn_blocking
┌──────────────────────────▼──────────────────────────────┐
│  spool core (同步 API 不变)                              │
│  DesktopService / LifecycleService / app::*             │
└─────────────────────────────────────────────────────────┘
```

### 3.2 AppState 结构

```rust
// src-tauri/state.rs

use spool::desktop::DesktopService;
use spool::lifecycle_service::LifecycleWorkbenchSnapshot;
use spool::lifecycle_store::{LedgerEntry, LifecycleStore};
use spool::session_sources::DesktopSessionItem;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Instant;
use tokio::sync::{RwLock, Notify};

/// Tauri 进程级应用状态，通过 app.manage() 注入。
pub struct AppState {
    pub config_path: RwLock<PathBuf>,
    pub lifecycle: LifecycleCache,
    pub sessions: SessionCache,
    pub invalidation: Arc<Notify>,
}

/// Lifecycle 数据缓存。
pub struct LifecycleCache {
    inner: RwLock<LifecycleCacheInner>,
}

struct LifecycleCacheInner {
    /// 最近一次成功加载的 workbench snapshot
    workbench: Option<CacheEntry<LifecycleWorkbenchSnapshot>>,
    /// 按 record_id 缓存的单条记录（LRU 或 bounded HashMap）
    records: lru::LruCache<String, CacheEntry<LedgerEntry>>,
}

struct CacheEntry<T> {
    data: T,
    loaded_at: Instant,
}

/// Session 索引缓存。
pub struct SessionCache {
    inner: RwLock<SessionCacheInner>,
}

struct SessionCacheInner {
    items: Option<CacheEntry<Vec<DesktopSessionItem>>>,
}
```

### 3.3 缓存失效策略

```rust
// src-tauri/watcher.rs

use notify::{RecommendedWatcher, RecursiveMode, Watcher, Event, EventKind};
use std::path::Path;
use std::sync::Arc;
use tokio::sync::Notify;

pub fn spawn_ledger_watcher(
    ledger_path: &Path,
    session_dirs: &[PathBuf],
    invalidation: Arc<Notify>,
) -> anyhow::Result<RecommendedWatcher> {
    let mut watcher = notify::recommended_watcher(move |res: Result<Event, _>| {
        if let Ok(event) = res {
            match event.kind {
                EventKind::Modify(_) | EventKind::Create(_) | EventKind::Remove(_) => {
                    invalidation.notify_waiters();
                }
                _ => {}
            }
        }
    })?;

    // 监听 ledger 文件
    if let Some(parent) = ledger_path.parent() {
        watcher.watch(parent, RecursiveMode::NonRecursive)?;
    }

    // 监听 session 目录（Claude/Codex session 文件变更）
    for dir in session_dirs {
        if dir.exists() {
            watcher.watch(dir, RecursiveMode::Recursive)?;
        }
    }

    Ok(watcher)
}
```

失效逻辑：

1. **写操作后立即失效**：`record_manual` / `propose_memory` / `apply_memory_action` 成功后，直接更新 cache 而非清空（write-through）
2. **外部变更通过 watcher 失效**：CLI/MCP 写入 ledger 时，watcher 检测到文件变更 → `invalidation.notify_waiters()` → 下次读请求重建
3. **TTL 兜底**：即使 watcher 失效，cache entry 超过 30s 也标记为 stale，下次读时后台刷新

### 3.4 异步 Command 改造

```rust
// src-tauri/commands.rs (改造后)

use tauri::State;
use crate::state::AppState;

#[tauri::command]
pub async fn desktop_load_workbench_command(
    command: TauriWorkbenchCommand,
    state: State<'_, AppState>,
) -> TauriCommandResult<DesktopWorkbenchResponse> {
    // 1. 尝试从缓存读取
    if let Some(cached) = state.lifecycle.get_workbench().await {
        return Ok(cached.into_response());
    }

    // 2. 缓存未命中，spawn_blocking 执行同步 IO
    let config_path = command.config_path.clone();
    let daemon = command.daemon.clone();
    let result = tokio::task::spawn_blocking(move || {
        DesktopService::new().load_workbench(command.into_request())
    })
    .await
    .map_err(|e| runtime_error(anyhow::anyhow!("task join error: {e}")))?;

    // 3. 写入缓存
    if let Ok(ref response) = result {
        state.lifecycle.set_workbench(response.snapshot.clone()).await;
    }

    result
}

#[tauri::command]
pub async fn desktop_apply_memory_action_command(
    command: TauriMemoryActionCommand,
    state: State<'_, AppState>,
) -> TauriCommandResult<DesktopWriteResponse> {
    let result = tokio::task::spawn_blocking(move || {
        DesktopService::new().apply_memory_action(command.into_request())
    })
    .await
    .map_err(|e| runtime_error(anyhow::anyhow!("task join error: {e}")))?;

    // 写成功后 write-through 更新缓存
    if let Ok(ref response) = result {
        state.lifecycle.invalidate_workbench().await;
        state.lifecycle.update_record(&response.entry).await;
    }

    result
}

#[tauri::command]
pub async fn desktop_run_context_command(
    command: TauriContextCommand,
    state: State<'_, AppState>,
) -> TauriCommandResult<DesktopContextResponse> {
    // context 生成依赖 vault 扫描，不适合缓存结果（task 每次不同）
    // 但仍需异步化避免阻塞
    tokio::task::spawn_blocking(move || {
        DesktopService::new().run_context(command.into_request())
    })
    .await
    .map_err(|e| runtime_error(anyhow::anyhow!("task join error: {e}")))?
}
```

### 3.5 Tauri 启动初始化

```rust
// src-tauri/main.rs (改造后)

mod commands;
mod state;
mod watcher;

use state::AppState;
use std::sync::Arc;
use tokio::sync::Notify;

fn main() {
    tauri::Builder::default()
        .plugin(tauri_plugin_dialog::init())
        .setup(|app| {
            let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
            let config_path = std::path::PathBuf::from(format!("{home}/.spool/config.toml"));
            let ledger_path = std::path::PathBuf::from(format!("{home}/.spool/memory-ledger.jsonl"));

            let invalidation = Arc::new(Notify::new());

            // 初始化应用状态
            let app_state = AppState::new(config_path.clone(), invalidation.clone());
            app.manage(app_state);

            // 启动文件监听
            let session_dirs = vec![
                std::path::PathBuf::from(format!("{home}/.claude/projects")),
                std::path::PathBuf::from(format!("{home}/.codex/sessions")),
            ];
            let _watcher = watcher::spawn_ledger_watcher(
                &ledger_path,
                &session_dirs,
                invalidation,
            );
            // 保持 watcher 存活
            app.manage(_watcher);

            // tray icon setup...
            Ok(())
        })
        .invoke_handler(tauri::generate_handler![
            commands::desktop_load_workbench_command,
            commands::desktop_run_context_command,
            // ... 其余 commands
        ])
        .run(tauri::generate_context!())
        .expect("error while running spool tauri host");
}
```

---

## 4. LifecycleCache 实现细节

```rust
// src-tauri/state.rs — LifecycleCache impl

impl LifecycleCache {
    pub fn new() -> Self {
        Self {
            inner: RwLock::new(LifecycleCacheInner {
                workbench: None,
                records: lru::LruCache::new(
                    std::num::NonZeroUsize::new(128).unwrap()
                ),
            }),
        }
    }

    /// 读取 workbench 缓存。返回 None 表示缓存未命中或已过期。
    pub async fn get_workbench(&self) -> Option<LifecycleWorkbenchSnapshot> {
        let inner = self.inner.read().await;
        inner.workbench.as_ref().and_then(|entry| {
            if entry.loaded_at.elapsed() < std::time::Duration::from_secs(30) {
                Some(entry.data.clone())
            } else {
                None // stale
            }
        })
    }

    /// 写入 workbench 缓存。
    pub async fn set_workbench(&self, snapshot: LifecycleWorkbenchSnapshot) {
        let mut inner = self.inner.write().await;
        inner.workbench = Some(CacheEntry {
            data: snapshot,
            loaded_at: Instant::now(),
        });
    }

    /// 失效 workbench 缓存（外部写入或 watcher 触发）。
    pub async fn invalidate_workbench(&self) {
        let mut inner = self.inner.write().await;
        inner.workbench = None;
    }

    /// 单条记录 write-through。
    pub async fn update_record(&self, entry: &LedgerEntry) {
        let mut inner = self.inner.write().await;
        inner.records.put(
            entry.record_id.clone(),
            CacheEntry {
                data: entry.clone(),
                loaded_at: Instant::now(),
            },
        );
    }

    /// 读取单条记录缓存。
    pub async fn get_record(&self, record_id: &str) -> Option<LedgerEntry> {
        let mut inner = self.inner.write().await;
        inner.records.get(record_id).and_then(|entry| {
            if entry.loaded_at.elapsed() < std::time::Duration::from_secs(30) {
                Some(entry.data.clone())
            } else {
                None
            }
        })
    }

    /// watcher 触发的全量失效。
    pub async fn invalidate_all(&self) {
        let mut inner = self.inner.write().await;
        inner.workbench = None;
        inner.records.clear();
    }
}
```

---

## 5. 后台刷新（Stale-While-Revalidate）

对于 workbench 这种高频读操作，可以采用 SWR 策略：

```rust
#[tauri::command]
pub async fn desktop_load_workbench_command(
    command: TauriWorkbenchCommand,
    state: State<'_, AppState>,
) -> TauriCommandResult<DesktopWorkbenchResponse> {
    // 先返回可能 stale 的缓存
    let cached = state.lifecycle.get_workbench_even_stale().await;

    if let Some((snapshot, is_fresh)) = cached {
        if !is_fresh {
            // 后台刷新，不阻塞当前响应
            let state_clone = state.inner().clone();
            let config_path = command.config_path.clone();
            tokio::spawn(async move {
                if let Ok(fresh) = tokio::task::spawn_blocking(move || {
                    DesktopService::new().load_workbench_from_config(&config_path)
                }).await.ok().flatten() {
                    state_clone.lifecycle.set_workbench(fresh).await;
                    // 可选：通过 Tauri event 通知前端数据已更新
                }
            });
        }
        return Ok(snapshot.into_response());
    }

    // 完全无缓存，同步等待
    let result = tokio::task::spawn_blocking(move || {
        DesktopService::new().load_workbench(command.into_request())
    }).await.map_err(|e| runtime_error(anyhow::anyhow!("{e}")))?;

    if let Ok(ref response) = result {
        state.lifecycle.set_workbench(response.snapshot.clone()).await;
    }
    result
}
```

---

## 6. Watcher → 前端推送

当 watcher 检测到 ledger 变更时，除了失效缓存，还应主动通知前端刷新：

```rust
// src-tauri/watcher.rs 中

use tauri::{AppHandle, Emitter};

pub fn spawn_invalidation_listener(
    app: AppHandle,
    invalidation: Arc<Notify>,
    state: Arc<AppState>,
) {
    tokio::spawn(async move {
        loop {
            invalidation.notified().await;
            // debounce: 等 100ms 合并连续写入
            tokio::time::sleep(std::time::Duration::from_millis(100)).await;

            state.lifecycle.invalidate_all().await;
            state.sessions.invalidate().await;

            // 通知前端
            let _ = app.emit("spool://cache-invalidated", ());
        }
    });
}
```

前端监听：

```typescript
// frontend/src/lib/api/events.ts
import { listen } from '@tauri-apps/api/event'

export function onCacheInvalidated(callback: () => void) {
  return listen('spool://cache-invalidated', callback)
}
```

在 workbench/sessions 页面中：

```typescript
useEffect(() => {
  const unlisten = onCacheInvalidated(() => {
    void loadQueue() // 自动刷新
  })
  return () => { unlisten.then(fn => fn()) }
}, [loadQueue])
```

---

## 7. 依赖变更

### src-tauri/Cargo.toml 新增

```toml
[dependencies]
# 已有
spool = { path = ".." }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tauri = { version = "^2.2.5", features = ["tray-icon"] }
tauri-plugin-dialog = "2.7.1"

# 新增
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time"] }
notify = "7"
lru = "0.12"
```

### 核心 crate 不变

`spool` crate 的同步 API 保持不变。异步包装仅在 `src-tauri` 层。

---

## 8. 改造步骤（渐进式）

### Phase 1: 异步化骨架（不改缓存逻辑）

1. `src-tauri/Cargo.toml` 加 `tokio` 依赖
2. 所有 `#[tauri::command]` 加 `async`，内部用 `spawn_blocking` 包装
3. 验证：前端行为不变，但 UI 不再卡顿

预计改动：~50 行 commands.rs + Cargo.toml

### Phase 2: AppState + LifecycleCache

1. 新建 `src-tauri/state.rs`
2. `main.rs` setup 中初始化 AppState 并 `app.manage()`
3. `load_workbench` / `get_record` / `get_history` 加缓存读写
4. 写操作后 write-through 更新

预计改动：~200 行新代码

### Phase 3: File Watcher + 前端推送

1. 新建 `src-tauri/watcher.rs`
2. setup 中启动 watcher + invalidation listener
3. 前端加 event listener 自动刷新

预计改动：~80 行 Rust + ~20 行 TypeScript

### Phase 4: SessionCache + SWR

1. session 索引缓存
2. workbench SWR 策略
3. 可选：预热（startup 时后台加载 workbench）

预计改动：~100 行

---

## 9. 测试策略

### 单元测试

- `LifecycleCache` 的 get/set/invalidate/TTL 行为
- watcher debounce 逻辑（用 tempfile 模拟文件变更）

### 集成测试

- Tauri command 异步执行不阻塞（用 `tauri::test` 或手动 spawn）
- 写操作后缓存一致性：write → immediate read 应返回最新数据
- 外部写入后 watcher 触发失效：CLI append → desktop read 应反映变更

### Smoke 测试

- 现有 `tests/cli_smoke.rs` 和 `tests/mcp_smoke.rs` 不受影响
- 新增 `tests/tauri_smoke.rs`：验证 command 注册 + 基本调用

---

## 10. 风险与缓解

| 风险 | 缓解 |
|------|------|
| `spawn_blocking` 线程池耗尽 | Tauri 默认 tokio runtime 线程池足够；vault 扫描最多并发 1-2 次 |
| watcher 在某些 FS 上不可靠（网络盘） | TTL 兜底 + 手动刷新按钮保留 |
| RwLock 竞争 | 读多写少场景下 RwLock 性能优于 Mutex；写操作频率极低 |
| 缓存与 ledger 不一致窗口 | write-through 保证自身写入立即可见；外部写入有 100ms debounce 延迟，可接受 |
| `lru` crate 引入新依赖 | 极轻量（~200 行），无 unsafe，维护活跃 |

---

## 11. 不做什么

- **不改核心 crate 的同步 API**：CLI/MCP/daemon 仍走同步路径
- **不引入 async trait**：`DesktopService` 保持同步，异步仅在 Tauri 层
- **不做 vault 扫描结果缓存**：context/wakeup 的输入每次不同（task/files），缓存命中率极低
- **不做 daemon 自动 spawn**：留给后续 Phase，当前 daemon 仍是可选的
- **不改前端 IPC 接口**：前端已经是 async 调用，后端异步化对前端完全透明
