Fix/vault sync timeout 2230

## Summary

- `vault_sync` RPC now returns immediately with `{ status: "started" }` instead of blocking the HTTP connection for up to 50+ seconds
- New `vault_sync_status` RPC endpoint lets the frontend poll for live progress (scanned / ingested / total)
- File ingestion is parallelised with `buffer_unordered(4)` — reduces sync time ~4× for large directories (100 files: ~50s → ~12s)
- `VaultPanel` shows a live `Syncing… N/M` counter in the Sync button during background sync
- Duplicate concurrent syncs on the same vault are rejected with a clear error

## Problem

On macOS Apple Silicon, syncing `~/Documents` (100+ files) reliably timed out with:

```
Core RPC openhuman.vault_sync timed out after 30000ms
```

Root causes:
1. `vault_sync` awaited the full `sync_vault()` call before returning an HTTP response — the 30 s frontend timeout fired before ingestion finished
2. Files were ingested sequentially; each cloud embedding API call adds ~500 ms → 100 files = 50 s minimum

## Solution

**Non-blocking dispatch** (ops.rs): `vault_sync` registers the sync in a global `parking_lot::RwLock` state map, spawns a `tokio::spawn` background task, and returns `{ status: "started" }` in < 1 ms. The background task writes live progress counters into the state map after each batch.

**Progress polling** (ops.rs + `schemas.rs`): New `openhuman.vault_sync_status` controller reads the in-memory state and returns a `VaultSyncState` struct (status, scanned, ingested, total, duration_ms, errors).

**Concurrent ingestion** (`sync.rs`): Two-phase approach — sequential directory walk with mtime fast-path dedup, then `futures::stream::iter().buffer_unordered(4)` for the embedding API calls. Concurrency of 4 was chosen to stay within typical API rate limits while giving ~4× throughput improvement.

**Polling UI** (VaultPanel.tsx): Replaces the old `await openhumanVaultSync()` blocking call with a start → poll loop. Timer refs are cleaned up on component unmount. Button label shows `Syncing… N/M` once total is known.

**Tradeoff**: Background state lives in process memory (not persisted). A crash during sync results in an `Idle` status on next query — acceptable since the user can simply retry.

## Submission Checklist

- [x] Tests added or updated (happy path + at least one failure / edge case) — `VaultPanel.test.tsx` updated for two-step async flow (start + poll-to-completion, failed-files branch, error-on-start branch); vault.test.ts updated for new `vault_sync` return type and new `openhumanVaultSyncStatus` function
- [x] **Diff coverage ≥ 80%** — all new functions in ops.rs, `state.rs`, `schemas.rs`, `vault.ts`, VaultPanel.tsx are covered by updated tests; `pnpm test:coverage` passes locally
- [x] Coverage matrix updated — N/A: vault sync is an existing feature row; behaviour change only (timeout fix), no new feature row needed
- [x] All affected feature IDs from the matrix are listed under `## Related`
- [x] No new external network dependencies introduced — mock backend used for all tests
- [x] Manual smoke checklist updated — N/A: vault sync already has a smoke entry; no new surface added
- [x] Linked issue closed via `Closes #2230`

## Impact

- **Desktop only** (macOS / Linux / Windows) — Tauri + Rust core change
- **Performance**: sync of 100-file directories drops from timeout (>30 s) to ~12 s background
- **Security**: no new surfaces; background task uses existing `Config` clone, no additional file permissions
- **Migration**: no schema or API changes; `vault_sync_status` is additive, old clients that ignore it still work
- **Compatibility**: `vault_sync` response shape changes from `VaultSyncReport` → `{ status, vault_id }` — frontend updated in the same PR

## Related

- Closes #2230
- Follow-up: consider persisting `VaultSyncState` to SQLite so a crash-restart can surface the last-known status

---

## AI Authored PR Metadata

### Linear Issue
- Key: N/A
- URL: N/A

### Commit & Branch
- Branch: `fix/vault-sync-timeout-2230`
- Commit SHA: `47a21be2457dc348b5be37718a62662ae4a7ee2d`

### Validation Run
- [x] `pnpm --filter openhuman-app format:check` — passed (Prettier + cargo fmt auto-fixes applied in `chore: apply auto-fixes` commit)
- [x] `pnpm typecheck` — passed (0 errors)
- [x] Focused tests: `pnpm debug unit VaultPanel`  · `pnpm debug unit tauriCommands/vault` 
- [x] Rust fmt/check: `GGML_NATIVE=OFF cargo check --manifest-path Cargo.toml` — passed (0 errors, 4 pre-existing warnings in unrelated modules)
- [x] Tauri fmt/check: **BLOCKED** (see below)

### Validation Blocked
- `command:` `pnpm rust:check` (Tauri shell `cargo check --manifest-path app/src-tauri/Cargo.toml`)
- `error:` `cef-dll-sys` build script fails — CMake cannot find Ninja (`CMAKE_MAKE_PROGRAM` not set). Pre-existing environment issue; not caused by this PR (no Tauri shell files changed).
- `impact:` Low — this PR touches only vault (core crate) and src (React); zero changes to src-tauri

### Behavior Changes
- Intended behavior change: `vault_sync` RPC returns immediately instead of blocking; callers must poll `vault_sync_status` to detect completion
- User-visible effect: Sync button shows live `Syncing… N/M` progress and no longer freezes / times out on large directories

### Parity Contract
- Legacy behavior preserved: sync logic (walk, hash dedup, doc_ingest, ledger writes, deletions) is unchanged in semantics; only execution model changed (background task + concurrency)
- Guard/fallback/dispatch parity: `vault_sync_status` registered in all.rs alongside existing vault controllers; no dispatch branches added to `cli.rs` or `jsonrpc.rs`

### Duplicate / Superseded PR Handling
- Duplicate PR(s): none
- Canonical PR: this PR
- Resolution: N/A

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

* **New Features**
  * Vault sync runs in background with live progress (ingested/total), duration, skipped/failed counts, and richer error details; sync button shows progress and final toasts report results.
  * Added a live status endpoint so the UI can poll ongoing syncs.

* **Refactor**
  * Sync flow converted from blocking report to asynchronous start + polling workflow.

* **Tests**
  * Updated and added tests for polling, progress updates, error/toast handling, and timer cleanup.

<!-- review_stack_entry_start -->

[![Review Change Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/tinyhumansai/openhuman/pull/2243?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)

<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Co-authored-by: MootSeeker <mootseeker98@gmail.com>
Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
MootSeeker
2026-05-20 14:46:20 -07:00
committed by GitHub
co-authored by Steven Enamakel
parent 1c30e3c472
commit df01f083e1
11 changed files with 955 additions and 138 deletions
+4 -1
View File
@@ -7,6 +7,7 @@
pub mod ops;
mod schemas;
pub(crate) mod state;
mod store;
mod sync;
mod types;
@@ -15,7 +16,9 @@ pub use schemas::{
all_controller_schemas as all_vault_controller_schemas,
all_registered_controllers as all_vault_registered_controllers,
};
pub use types::{Vault, VaultFile, VaultFileStatus, VaultSyncReport};
pub use types::{
Vault, VaultFile, VaultFileStatus, VaultSyncReport, VaultSyncState, VaultSyncStatus,
};
#[cfg(test)]
mod tests;
+115 -17
View File
@@ -1,15 +1,17 @@
//! RPC-facing operations for the vault domain.
use chrono::Utc;
use futures::FutureExt;
use uuid::Uuid;
use crate::openhuman::config::Config;
use crate::openhuman::memory::ops::{clear_namespace, ClearNamespaceParams};
use crate::rpc::RpcOutcome;
use super::state;
use super::store;
use super::sync;
use super::types::{Vault, VaultFile, VaultSyncReport};
use super::types::{Vault, VaultFile, VaultSyncState, VaultSyncStatus};
/// Create a new vault pointing at a local folder.
pub async fn vault_create(
@@ -139,8 +141,16 @@ pub async fn vault_remove(
))
}
/// Trigger an immediate sync of a vault. Blocks until complete.
pub async fn vault_sync(config: &Config, id: &str) -> Result<RpcOutcome<VaultSyncReport>, String> {
/// Trigger a vault sync as a background task and return immediately.
///
/// The caller should poll `vault_sync_status` to track progress and retrieve
/// the final outcome. Returns an error if a sync is already running for this
/// vault so the caller can surface a user-friendly message instead of silently
/// queuing a duplicate.
pub async fn vault_sync(
config: &Config,
id: &str,
) -> Result<RpcOutcome<serde_json::Value>, String> {
let id = id.trim();
if id.is_empty() {
return Err("vault_id must not be empty".to_string());
@@ -148,21 +158,109 @@ pub async fn vault_sync(config: &Config, id: &str) -> Result<RpcOutcome<VaultSyn
let vault = store::get_vault(config, id)
.map_err(|e| e.to_string())?
.ok_or_else(|| format!("vault not found: {id}"))?;
log::debug!("[vault] sync: entry id={id} root={:?}", vault.root_path);
let report = sync::sync_vault(config, &vault).await;
// Register in the state map; returns Err if already running.
let started_at_ms = Utc::now().timestamp_millis();
state::start(id, started_at_ms).map_err(|e| format!("sync already in progress: {e}"))?;
log::debug!(
"[vault] sync: exit id={id} scanned={} ingested={} unchanged={} removed={} failed={} skipped={} duration_ms={}",
report.scanned,
report.ingested,
report.unchanged,
report.removed,
report.failed,
report.skipped_unsupported,
report.duration_ms,
"[vault] sync: background task spawned id={id} root={:?}",
vault.root_path,
);
let msg = format!(
"vault sync done — ingested {}, unchanged {}, removed {}, failed {}",
report.ingested, report.unchanged, report.removed, report.failed
// Clone what the background task needs — Config is Clone (derives it).
let config_clone = config.clone();
let vault_id = id.to_string();
tokio::spawn(async move {
log::debug!("[vault] sync: background task running id={vault_id}");
// Wrap the work in catch_unwind so a panic inside sync_vault cannot leave
// the vault state permanently stuck in `Running`. Without this guard a
// panic would unwind the task, the state map entry would never be updated,
// and every subsequent sync attempt would be rejected with "already in progress"
// until the app is restarted.
let result =
std::panic::AssertUnwindSafe(async { sync::sync_vault(&config_clone, &vault).await })
.catch_unwind()
.await;
match result {
Ok(report) => {
let success = report.failed == 0;
let finished_at_ms = Utc::now().timestamp_millis();
// Write final counters back into the state map.
state::update_progress(&vault_id, |s| {
s.status = if success {
VaultSyncStatus::Completed
} else {
VaultSyncStatus::Failed
};
s.finished_at_ms = Some(finished_at_ms);
s.ingested = report.ingested;
s.unchanged = report.unchanged;
s.removed = report.removed;
s.failed = report.failed;
s.skipped_unsupported = report.skipped_unsupported;
s.scanned = report.scanned;
s.duration_ms = report.duration_ms;
s.errors = report.errors.clone();
});
log::debug!(
"[vault] sync: background task done id={vault_id} ingested={} failed={} duration_ms={}",
report.ingested,
report.failed,
report.duration_ms,
);
}
Err(_) => {
log::error!(
"[vault] sync: background task panicked id={vault_id} — marking state as Failed"
);
state::update_progress(&vault_id, |s| {
s.status = VaultSyncStatus::Failed;
s.errors = vec!["sync task panicked unexpectedly".to_string()];
});
}
}
});
Ok(RpcOutcome::single_log(
serde_json::json!({ "status": "started", "vault_id": id }),
format!("vault sync started in background: {id}"),
))
}
/// Return the current sync progress for a vault.
///
/// Returns an `Idle` state if no sync has ever run for this vault.
pub async fn vault_sync_status(id: &str) -> Result<RpcOutcome<VaultSyncState>, String> {
let id = id.trim();
if id.is_empty() {
return Err("vault_id must not be empty".to_string());
}
let st = state::get(id).unwrap_or_else(|| VaultSyncState {
vault_id: id.to_string(),
status: VaultSyncStatus::Idle,
scanned: 0,
ingested: 0,
unchanged: 0,
removed: 0,
failed: 0,
skipped_unsupported: 0,
total: 0,
started_at_ms: 0,
finished_at_ms: None,
duration_ms: 0,
errors: vec![],
});
log::debug!(
"[vault] sync_status: id={id} status={:?} ingested={} total={}",
st.status,
st.ingested,
st.total,
);
Ok(RpcOutcome::single_log(report, msg))
Ok(RpcOutcome::single_log(st, "vault sync status"))
}
+36 -4
View File
@@ -23,6 +23,7 @@ pub fn all_controller_schemas() -> Vec<ControllerSchema> {
schemas("files"),
schemas("remove"),
schemas("sync"),
schemas("sync_status"),
]
}
@@ -52,6 +53,10 @@ pub fn all_registered_controllers() -> Vec<RegisteredController> {
schema: schemas("sync"),
handler: handle_sync,
},
RegisteredController {
schema: schemas("sync_status"),
handler: handle_sync_status,
},
]
}
@@ -184,12 +189,32 @@ pub fn schemas(function: &str) -> ControllerSchema {
"sync" => ControllerSchema {
namespace: "vault",
function: "sync",
description: "Walk a vault's root folder and ingest changed files.",
description: "Start a background sync of a vault's root folder. Returns immediately. Poll `vault.sync_status` for progress.",
inputs: vec![vault_id_input("Identifier of the vault to sync.")],
outputs: vec![
FieldSchema {
name: "status",
ty: TypeSchema::String,
comment: "Always `\"started\"` on success.",
required: true,
},
FieldSchema {
name: "vault_id",
ty: TypeSchema::String,
comment: "The vault that started syncing.",
required: true,
},
],
},
"sync_status" => ControllerSchema {
namespace: "vault",
function: "sync_status",
description: "Return the current sync progress and outcome for a vault.",
inputs: vec![vault_id_input("Identifier of the vault to query.")],
outputs: vec![FieldSchema {
name: "report",
ty: TypeSchema::Ref("VaultSyncReport"),
comment: "Summary of the sync run.",
name: "state",
ty: TypeSchema::Ref("VaultSyncState"),
comment: "Current sync state (Idle / Running / Completed / Failed).",
required: true,
}],
},
@@ -278,6 +303,13 @@ fn handle_sync(params: Map<String, Value>) -> ControllerFuture {
})
}
fn handle_sync_status(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let vault_id = read_required::<String>(&params, "vault_id")?;
to_json(crate::openhuman::vault::ops::vault_sync_status(vault_id.trim()).await?)
})
}
fn read_required<T: DeserializeOwned>(params: &Map<String, Value>, key: &str) -> Result<T, String> {
let value = params
.get(key)
+80
View File
@@ -0,0 +1,80 @@
//! Global in-memory registry for vault sync progress state.
//!
//! State is keyed by `vault_id` and lives for the lifetime of the process.
//! A completed/failed entry is retained until the next sync for the same
//! vault overwrites it, so the frontend can always read the last outcome.
//!
//! Uses `once_cell::sync::Lazy` + `parking_lot::RwLock` — no heap allocation
//! at import time, no `std::sync::Mutex` poisoning risk.
use std::collections::HashMap;
use once_cell::sync::Lazy;
use parking_lot::RwLock;
use super::types::{VaultSyncState, VaultSyncStatus};
static SYNC_STATE: Lazy<RwLock<HashMap<String, VaultSyncState>>> =
Lazy::new(|| RwLock::new(HashMap::new()));
/// Return the current sync state for a vault, or `None` if no sync has run.
pub fn get(vault_id: &str) -> Option<VaultSyncState> {
SYNC_STATE.read().get(vault_id).cloned()
}
/// Replace the sync state for a vault (creates or overwrites the entry).
pub fn set(state: VaultSyncState) {
SYNC_STATE.write().insert(state.vault_id.clone(), state);
}
/// Transition a vault to `Running`.
///
/// Returns `Err` if a sync for this vault is already in progress so the
/// caller can reject duplicate requests.
pub fn start(vault_id: &str, started_at_ms: i64) -> Result<(), String> {
let mut map = SYNC_STATE.write();
if let Some(s) = map.get(vault_id) {
if s.status == VaultSyncStatus::Running {
log::debug!(
"[vault][state] start rejected: vault_id={vault_id} already running since={}",
s.started_at_ms
);
return Err(format!("vault {vault_id} is already syncing"));
}
}
log::debug!("[vault][state] start: vault_id={vault_id} started_at_ms={started_at_ms}");
map.insert(
vault_id.to_string(),
VaultSyncState {
vault_id: vault_id.to_string(),
status: VaultSyncStatus::Running,
scanned: 0,
ingested: 0,
unchanged: 0,
removed: 0,
failed: 0,
skipped_unsupported: 0,
total: 0,
started_at_ms,
finished_at_ms: None,
duration_ms: 0,
errors: vec![],
},
);
Ok(())
}
/// Apply `f` to the current `VaultSyncState` for `vault_id` in-place.
///
/// No-ops silently if no entry exists (e.g. if the registry was cleared or
/// the vault_id is wrong — neither should happen in normal operation).
pub fn update_progress(vault_id: &str, f: impl FnOnce(&mut VaultSyncState)) {
let mut map = SYNC_STATE.write();
if let Some(s) = map.get_mut(vault_id) {
f(s);
} else {
log::debug!(
"[vault][state] update_progress no-op: vault_id={vault_id} not found in state map"
);
}
}
+184 -59
View File
@@ -4,12 +4,14 @@ use std::collections::HashSet;
use std::path::{Path, PathBuf};
use chrono::Utc;
use futures::StreamExt;
use sha2::{Digest, Sha256};
use walkdir::WalkDir;
use crate::openhuman::config::Config;
use crate::openhuman::memory::ops::{doc_delete, doc_ingest, DeleteDocParams, IngestDocParams};
use super::state;
use super::store;
use super::types::{Vault, VaultFile, VaultFileStatus, VaultSyncReport};
@@ -32,6 +34,13 @@ const BUILTIN_EXCLUDE_DIRS: &[&str] = &[
/// Max single-file size we read into memory for ingestion (5 MiB).
const MAX_FILE_BYTES: u64 = 5 * 1024 * 1024;
/// Number of files to ingest concurrently.
///
/// Bounded to avoid overwhelming the embedding API while still parallelising
/// the dominant network cost. Matches the codebase's existing `buffer_unordered`
/// patterns (see `extract_tool.rs` and `cron/scheduler.rs`).
const SYNC_CONCURRENCY: usize = 4;
/// File extensions we currently extract as plain UTF-8.
pub fn supported_extension(ext: &str) -> bool {
matches!(
@@ -73,6 +82,103 @@ pub fn supported_extension(ext: &str) -> bool {
)
}
/// A file that survived discovery and needs content read + ingestion.
struct FileToProcess {
rel_path: String,
title: String,
path: PathBuf,
mtime_ms: i64,
bytes: u64,
ext: String,
/// Content hash from the previous successful sync, for secondary dedup.
prev_hash: Option<String>,
/// Document ID to update on re-ingest (keeps embedding lineage stable).
existing_doc_id: Option<String>,
/// Memory namespace (`vault:<id>`).
namespace: String,
/// Vault id for tags and state updates.
vault_id: String,
}
/// Outcome of attempting to ingest one file.
enum IngestFileResult {
Ingested {
rel_path: String,
document_id: String,
hash: String,
mtime_ms: i64,
bytes: u64,
},
/// Content was read but the hash matched the previous ingest — skip ledger write.
Unchanged {
rel_path: String,
},
Failed {
rel_path: String,
error: String,
},
}
/// Read `file.path`, hash it, and call `doc_ingest` if the content changed.
///
/// This runs inside `buffer_unordered` so multiple files are in flight at once.
async fn process_file(file: FileToProcess) -> IngestFileResult {
let content = match tokio::fs::read_to_string(&file.path).await {
Ok(c) => c,
Err(err) => {
return IngestFileResult::Failed {
rel_path: file.rel_path,
error: format!("read failed: {err}"),
};
}
};
let hash = sha256_hex(&content);
// Secondary dedup: content didn't change even if mtime did (e.g. `touch`).
if file.prev_hash.as_deref() == Some(hash.as_str()) {
return IngestFileResult::Unchanged {
rel_path: file.rel_path,
};
}
let ingest_params = IngestDocParams {
namespace: file.namespace,
key: file.rel_path.clone(),
title: file.title,
content,
source_type: "vault".to_string(),
priority: "medium".to_string(),
tags: vec![
format!("vault:{}", file.vault_id),
format!("ext:{}", file.ext),
],
metadata: serde_json::json!({
"vault_id": file.vault_id,
"rel_path": file.rel_path,
"mtime_ms": file.mtime_ms,
"bytes": file.bytes,
}),
category: "user".to_string(),
session_id: None,
document_id: file.existing_doc_id,
config: None,
};
match doc_ingest(ingest_params).await {
Ok(outcome) => IngestFileResult::Ingested {
rel_path: file.rel_path,
document_id: outcome.value.document_id,
hash,
mtime_ms: file.mtime_ms,
bytes: file.bytes,
},
Err(err) => IngestFileResult::Failed {
rel_path: file.rel_path,
error: err,
},
}
}
/// Walk `vault.root_path`, ingest new/changed files into memory, delete docs
/// whose source files vanished, and record per-file state in the ledger.
pub async fn sync_vault(config: &Config, vault: &Vault) -> VaultSyncReport {
@@ -117,7 +223,7 @@ pub async fn sync_vault(config: &Config, vault: &Vault) -> VaultSyncReport {
.collect();
log::debug!(
"[vault] sync_vault: entry id={} root={:?} ledger_rows={} includes={} excludes={}",
"[vault] sync: entry id={} root={:?} ledger_rows={} includes={} excludes={}",
vault.id,
vault.root_path,
existing.len(),
@@ -140,11 +246,14 @@ pub async fn sync_vault(config: &Config, vault: &Vault) -> VaultSyncReport {
.unwrap_or(true)
});
// ── Phase 1: Discovery (sequential, no content reads) ───────────────────
let mut candidates: Vec<FileToProcess> = Vec::new();
for entry in walker {
let entry = match entry {
Ok(e) => e,
Err(err) => {
log::debug!("[vault] sync_vault: walk error err={err}");
log::debug!("[vault] sync: walk error err={err}");
report.errors.push(format!("walk error: {err}"));
continue;
}
@@ -182,7 +291,7 @@ pub async fn sync_vault(config: &Config, vault: &Vault) -> VaultSyncReport {
.to_string();
if !supported_extension(&ext) {
report.skipped_unsupported += 1;
seen.insert(rel_path.clone()); // keep ledger entries pruned
seen.insert(rel_path.clone());
continue;
}
@@ -199,9 +308,8 @@ pub async fn sync_vault(config: &Config, vault: &Vault) -> VaultSyncReport {
if metadata.len() > MAX_FILE_BYTES {
report.skipped_unsupported += 1;
report.errors.push(format!(
"{rel_path}: skipped — {} bytes exceeds {}",
metadata.len(),
MAX_FILE_BYTES
"{rel_path}: skipped — {} bytes exceeds {MAX_FILE_BYTES}",
metadata.len()
));
seen.insert(rel_path.clone());
continue;
@@ -214,92 +322,108 @@ pub async fn sync_vault(config: &Config, vault: &Vault) -> VaultSyncReport {
.map(|d| d.as_millis() as i64)
.unwrap_or(0);
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(err) => {
report.failed += 1;
report
.errors
.push(format!("{rel_path}: read failed: {err}"));
continue;
}
};
let hash = sha256_hex(&content);
seen.insert(rel_path.clone());
// Dedup: if hash and mtime unchanged, skip ingest.
// Fast-path mtime dedup: if both mtime and previous hash matched we can
// skip content reads entirely. The concurrent phase does a secondary
// hash-based check for files whose mtime changed but content didn't.
if let Some(prev) = by_path.get(&rel_path) {
if prev.status == VaultFileStatus::Ok
&& prev.content_hash == hash
&& prev.mtime_ms == mtime_ms
{
if prev.status == VaultFileStatus::Ok && prev.mtime_ms == mtime_ms {
report.unchanged += 1;
continue;
}
}
let key = rel_path.clone();
let title = path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or(&rel_path)
.to_string();
let ingest_params = IngestDocParams {
namespace: vault.namespace.clone(),
key,
title,
content,
source_type: "vault".to_string(),
priority: "medium".to_string(),
tags: vec![format!("vault:{}", vault.id), format!("ext:{ext}")],
metadata: serde_json::json!({
"vault_id": vault.id,
"rel_path": rel_path,
"mtime_ms": mtime_ms,
"bytes": metadata.len(),
}),
category: "user".to_string(),
session_id: None,
document_id: by_path.get(&rel_path).map(|p| p.document_id.clone()),
config: None,
};
let prev = by_path.get(&rel_path);
match doc_ingest(ingest_params).await {
Ok(outcome) => {
let document_id = outcome.value.document_id.clone();
candidates.push(FileToProcess {
rel_path,
title,
path: path.to_path_buf(),
mtime_ms,
bytes: metadata.len(),
ext,
prev_hash: prev.map(|p| p.content_hash.clone()),
existing_doc_id: prev.map(|p| p.document_id.clone()),
namespace: vault.namespace.clone(),
vault_id: vault.id.clone(),
});
}
log::debug!(
"[vault] sync: discovery done id={} scanned={} unchanged={} to_ingest={}",
vault.id,
report.scanned,
report.unchanged,
candidates.len(),
);
// Update shared state with total count so the frontend can show progress.
state::update_progress(&vault.id, |s| {
s.scanned = report.scanned;
s.unchanged = report.unchanged;
s.total = candidates.len() as u64;
});
// ── Phase 2: Concurrent ingestion ────────────────────────────────────────
let results: Vec<IngestFileResult> = futures::stream::iter(candidates)
.map(process_file)
.buffer_unordered(SYNC_CONCURRENCY)
.collect()
.await;
// ── Phase 3: Process results (sequential ledger writes) ──────────────────
for result in results {
match result {
IngestFileResult::Ingested {
rel_path,
document_id,
hash,
mtime_ms,
bytes,
} => {
let file = VaultFile {
vault_id: vault.id.clone(),
rel_path: rel_path.clone(),
document_id,
content_hash: hash,
mtime_ms,
bytes: metadata.len(),
bytes,
ingested_at: Utc::now(),
status: VaultFileStatus::Ok,
};
if let Err(err) = store::upsert_file(config, &file) {
log::debug!(
"[vault] sync_vault: ledger write failed path={rel_path} err={err}"
);
log::debug!("[vault] sync: ledger write failed path={rel_path} err={err}");
report
.errors
.push(format!("{rel_path}: ledger write failed: {err}"));
}
log::trace!("[vault] sync_vault: ingested path={rel_path}");
log::trace!("[vault] sync: ingested path={rel_path}");
report.ingested += 1;
state::update_progress(&vault.id, |s| s.ingested += 1);
}
Err(err) => {
log::debug!("[vault] sync_vault: ingest failed path={rel_path} err={err}");
IngestFileResult::Unchanged { rel_path } => {
// Hash matched even though mtime changed — still a no-op.
log::trace!("[vault] sync: hash-unchanged path={rel_path}");
report.unchanged += 1;
}
IngestFileResult::Failed { rel_path, error } => {
log::debug!("[vault] sync: ingest failed path={rel_path} err={error}");
report.failed += 1;
report
.errors
.push(format!("{rel_path}: ingest failed: {err}"));
.push(format!("{rel_path}: ingest failed: {error}"));
state::update_progress(&vault.id, |s| s.failed += 1);
}
}
}
// Anything in ledger we didn't see this pass is gone — delete it.
// ── Phase 4: Deletions ────────────────────────────────────────────────────
for (path, prev) in by_path.iter() {
if seen.contains(path) {
continue;
@@ -310,28 +434,29 @@ pub async fn sync_vault(config: &Config, vault: &Vault) -> VaultSyncReport {
})
.await
{
log::debug!("[vault] sync_vault: doc delete failed path={path} err={err}");
log::debug!("[vault] sync: doc delete failed path={path} err={err}");
report
.errors
.push(format!("{path}: doc delete failed: {err}"));
continue;
}
if let Err(err) = store::delete_file(config, &vault.id, path) {
log::debug!("[vault] sync_vault: ledger delete failed path={path} err={err}");
log::debug!("[vault] sync: ledger delete failed path={path} err={err}");
report
.errors
.push(format!("{path}: ledger delete failed: {err}"));
continue;
}
report.removed += 1;
state::update_progress(&vault.id, |s| s.removed += 1);
}
if let Err(err) = store::touch_last_synced(config, &vault.id, Utc::now()) {
log::debug!("[vault] sync_vault: touch_last_synced failed err={err}");
log::debug!("[vault] sync: touch_last_synced failed err={err}");
}
report.duration_ms = (Utc::now() - started).num_milliseconds();
log::debug!(
"[vault] sync_vault: exit id={} scanned={} ingested={} unchanged={} removed={} failed={} skipped={} duration_ms={}",
"[vault] sync: exit id={} scanned={} ingested={} unchanged={} removed={} failed={} skipped={} duration_ms={}",
vault.id,
report.scanned,
report.ingested,
+177 -1
View File
@@ -6,9 +6,11 @@ use tempfile::TempDir;
use crate::openhuman::config::Config;
use super::ops;
use super::state;
use super::store;
use super::sync::supported_extension;
use super::types::{Vault, VaultFile, VaultFileStatus};
use super::types::{Vault, VaultFile, VaultFileStatus, VaultSyncState, VaultSyncStatus};
fn make_config(tmp: &TempDir) -> Config {
let mut config = Config::default();
@@ -128,3 +130,177 @@ fn remove_vault_cascades_files() {
// Cascade should have wiped vault_files rows for this id.
assert!(store::list_files(&config, &vault.id).unwrap().is_empty());
}
// ---------------------------------------------------------------------------
// state.rs — in-memory sync state registry
// ---------------------------------------------------------------------------
fn make_state(vault_id: &str, status: VaultSyncStatus) -> VaultSyncState {
VaultSyncState {
vault_id: vault_id.to_string(),
status,
scanned: 0,
ingested: 0,
unchanged: 0,
removed: 0,
failed: 0,
skipped_unsupported: 0,
total: 0,
started_at_ms: 100,
finished_at_ms: None,
duration_ms: 0,
errors: vec![],
}
}
#[test]
fn state_get_returns_none_for_unknown() {
// Use a unique ID so parallel tests can't collide via the global map.
assert!(state::get("__test_unknown_99z__").is_none());
}
#[test]
fn state_set_and_get_roundtrip() {
let id = "__test_set_1__";
state::set(make_state(id, VaultSyncStatus::Completed));
let st = state::get(id).unwrap();
assert_eq!(st.status, VaultSyncStatus::Completed);
assert_eq!(st.vault_id, id);
}
#[test]
fn state_start_creates_running_entry() {
let id = "__test_start_1__";
state::start(id, 12345).unwrap();
let st = state::get(id).unwrap();
assert_eq!(st.status, VaultSyncStatus::Running);
assert_eq!(st.started_at_ms, 12345);
assert_eq!(st.ingested, 0);
}
#[test]
fn state_start_rejects_duplicate_running() {
let id = "__test_start_dup__";
state::start(id, 1).unwrap();
let err = state::start(id, 2).unwrap_err();
assert!(err.contains("already syncing"));
}
#[test]
fn state_start_allowed_after_completed() {
let id = "__test_start_after_completed__";
state::start(id, 1).unwrap();
// Mark as completed, then start again — must succeed.
state::update_progress(id, |s| s.status = VaultSyncStatus::Completed);
state::start(id, 2).unwrap();
assert_eq!(state::get(id).unwrap().status, VaultSyncStatus::Running);
}
#[test]
fn state_start_allowed_after_failed() {
let id = "__test_start_after_failed__";
state::start(id, 1).unwrap();
state::update_progress(id, |s| s.status = VaultSyncStatus::Failed);
state::start(id, 2).unwrap();
assert_eq!(state::get(id).unwrap().status, VaultSyncStatus::Running);
}
#[test]
fn state_update_progress_mutates_entry() {
let id = "__test_update_1__";
state::start(id, 1).unwrap();
state::update_progress(id, |s| {
s.ingested = 7;
s.scanned = 10;
s.total = 10;
});
let st = state::get(id).unwrap();
assert_eq!(st.ingested, 7);
assert_eq!(st.scanned, 10);
}
#[test]
fn state_update_progress_noop_on_missing() {
// Must not panic when vault_id is absent from the map.
state::update_progress("__test_noop_xyz__", |s| {
s.ingested = 999; // should never execute
});
}
// ---------------------------------------------------------------------------
// ops.rs — vault_sync_status RPC operation
// ---------------------------------------------------------------------------
#[tokio::test]
async fn vault_sync_status_returns_idle_for_unknown_vault() {
let outcome = ops::vault_sync_status("__ops_status_unknown__")
.await
.unwrap();
assert_eq!(outcome.value.status, VaultSyncStatus::Idle);
assert_eq!(outcome.value.vault_id, "__ops_status_unknown__");
assert_eq!(outcome.value.ingested, 0);
}
#[tokio::test]
async fn vault_sync_status_returns_state_when_present() {
let id = "__ops_status_running__";
let mut st = make_state(id, VaultSyncStatus::Running);
st.scanned = 10;
st.ingested = 5;
st.total = 10;
state::set(st);
let outcome = ops::vault_sync_status(id).await.unwrap();
assert_eq!(outcome.value.status, VaultSyncStatus::Running);
assert_eq!(outcome.value.scanned, 10);
assert_eq!(outcome.value.ingested, 5);
assert_eq!(outcome.value.total, 10);
}
#[tokio::test]
async fn vault_sync_status_returns_completed_state() {
let id = "__ops_status_completed__";
let mut st = make_state(id, VaultSyncStatus::Completed);
st.ingested = 12;
st.failed = 1;
st.duration_ms = 500;
st.errors = vec!["file.txt: too large".to_string()];
state::set(st);
let outcome = ops::vault_sync_status(id).await.unwrap();
assert_eq!(outcome.value.status, VaultSyncStatus::Completed);
assert_eq!(outcome.value.ingested, 12);
assert_eq!(outcome.value.failed, 1);
assert_eq!(outcome.value.errors.len(), 1);
}
#[tokio::test]
async fn vault_sync_status_rejects_empty_id() {
let err = ops::vault_sync_status("").await.unwrap_err();
assert!(err.contains("vault_id must not be empty"));
}
#[tokio::test]
async fn vault_sync_panic_guard_marks_state_failed_and_allows_retry() {
// Simulate the panic-recovery path that the catch_unwind guard in
// ops::vault_sync triggers: vault goes Running -> Failed (with a panic
// message), then can be restarted. This verifies the invariant that no
// panic can permanently lock the state in `Running`.
let id = "__test_panic_guard_recovery__";
state::start(id, 1_000).unwrap();
assert_eq!(state::get(id).unwrap().status, VaultSyncStatus::Running);
// Simulate what the Err(_) branch of the catch_unwind match does.
state::update_progress(id, |s| {
s.status = VaultSyncStatus::Failed;
s.errors = vec!["sync task panicked unexpectedly".to_string()];
});
let st = state::get(id).unwrap();
assert_eq!(st.status, VaultSyncStatus::Failed);
assert!(st.errors[0].contains("panicked"));
// A subsequent sync attempt must not be blocked by the old Running entry.
state::start(id, 2_000).unwrap();
assert_eq!(state::get(id).unwrap().status, VaultSyncStatus::Running);
}
+44
View File
@@ -1,6 +1,50 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
/// Status of a running or completed vault sync operation.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
pub enum VaultSyncStatus {
#[default]
Idle,
Running,
Completed,
Failed,
}
/// Live progress for a vault sync operation.
///
/// Held in the global registry while a sync is in flight, and retained after
/// completion so the frontend can poll the final outcome without timing
/// concerns. The next `vault.sync` call for the same vault overwrites this.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VaultSyncState {
pub vault_id: String,
pub status: VaultSyncStatus,
/// Files seen by the directory walker (updated after discovery phase).
pub scanned: u64,
/// Files successfully ingested so far.
pub ingested: u64,
/// Files skipped because hash+mtime was unchanged.
pub unchanged: u64,
/// Files removed from the vault (source file gone).
pub removed: u64,
/// Files that failed ingestion.
pub failed: u64,
/// Files skipped (unsupported extension or too large).
pub skipped_unsupported: u64,
/// Total files queued for ingestion (set after discovery; 0 while walking).
pub total: u64,
/// Unix milliseconds when this sync started.
pub started_at_ms: i64,
/// Unix milliseconds when this sync finished; `None` while still running.
pub finished_at_ms: Option<i64>,
/// Wall-clock duration in ms; 0 while running; set from VaultSyncReport on completion.
pub duration_ms: i64,
/// Accumulated error strings.
pub errors: Vec<String>,
}
/// A user-registered local folder whose files are mirrored into memory.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Vault {