mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
Co-authored-by: sanil-23 <sanil@vezures.xyz> Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
co-authored by
sanil-23
Claude
parent
5e47a6666a
commit
d7e8daa156
+58
-10
@@ -6,6 +6,8 @@ use uuid::Uuid;
|
||||
|
||||
use crate::openhuman::config::Config;
|
||||
use crate::openhuman::memory::ops::{clear_namespace, ClearNamespaceParams};
|
||||
use crate::openhuman::memory_store::chunks::store::delete_chunks_by_source_prefix;
|
||||
use crate::openhuman::memory_store::chunks::types::SourceKind;
|
||||
use crate::rpc::RpcOutcome;
|
||||
|
||||
use super::state;
|
||||
@@ -113,23 +115,68 @@ pub async fn vault_remove(
|
||||
log::debug!("[vault] remove: id={id} removed={removed} purge_memory={purge_memory}");
|
||||
|
||||
let mut purged = false;
|
||||
let mut memory_tree_chunks_deleted: usize = 0;
|
||||
if removed && purge_memory {
|
||||
if let Some(v) = vault {
|
||||
// Memory-tree cleanup is the canonical path post-#2705: vault
|
||||
// sync writes to `mem_tree_chunks` / `mem_tree_ingested_sources`
|
||||
// keyed by `vault:{id}:{rel_path}`. A prefix delete with
|
||||
// `vault:{id}:` catches every per-file row for this vault. The
|
||||
// companion `clear_namespace` call below still drains any
|
||||
// pre-#2705 ledger rows that landed in the legacy
|
||||
// `memory_docs` table during the migration window.
|
||||
let cfg_for_blocking = config.clone();
|
||||
let prefix = format!("vault:{}:", v.id);
|
||||
let tree_result = tokio::task::spawn_blocking(move || {
|
||||
delete_chunks_by_source_prefix(&cfg_for_blocking, SourceKind::Document, &prefix)
|
||||
})
|
||||
.await;
|
||||
match tree_result {
|
||||
Ok(Ok(removed_chunks)) => {
|
||||
memory_tree_chunks_deleted = removed_chunks;
|
||||
log::debug!(
|
||||
"[vault] remove: id={id} memory_tree_chunks_deleted={removed_chunks}"
|
||||
);
|
||||
}
|
||||
Ok(Err(err)) => {
|
||||
log::warn!("[vault] remove: id={id} memory_tree_purge_failed err={err}");
|
||||
return Ok(RpcOutcome::single_log(
|
||||
serde_json::json!({
|
||||
"vault_id": id,
|
||||
"removed": removed,
|
||||
"purged": false,
|
||||
"purge_error": format!("memory_tree purge failed: {err}"),
|
||||
}),
|
||||
format!("vault removed with purge error: {id}"),
|
||||
));
|
||||
}
|
||||
Err(join_err) => {
|
||||
log::warn!(
|
||||
"[vault] remove: id={id} memory_tree_purge_join_failed err={join_err}"
|
||||
);
|
||||
return Ok(RpcOutcome::single_log(
|
||||
serde_json::json!({
|
||||
"vault_id": id,
|
||||
"removed": removed,
|
||||
"purged": false,
|
||||
"purge_error": format!("memory_tree purge join error: {join_err}"),
|
||||
}),
|
||||
format!("vault removed with purge error: {id}"),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Best-effort legacy UnifiedMemory purge for pre-#2705 ledger
|
||||
// rows whose chunks still live in `memory_docs`. Failure here
|
||||
// doesn't undo the canonical memory_tree cleanup above.
|
||||
if let Err(err) = clear_namespace(ClearNamespaceParams {
|
||||
namespace: v.namespace.clone(),
|
||||
})
|
||||
.await
|
||||
{
|
||||
log::warn!("[vault] remove: id={id} purge_namespace_failed err={err}");
|
||||
return Ok(RpcOutcome::single_log(
|
||||
serde_json::json!({
|
||||
"vault_id": id,
|
||||
"removed": removed,
|
||||
"purged": false,
|
||||
"purge_error": err,
|
||||
}),
|
||||
format!("vault removed with purge error: {id}"),
|
||||
));
|
||||
log::debug!(
|
||||
"[vault] remove: id={id} legacy_clear_namespace_failed (best-effort) err={err}"
|
||||
);
|
||||
}
|
||||
purged = true;
|
||||
}
|
||||
@@ -140,6 +187,7 @@ pub async fn vault_remove(
|
||||
"vault_id": id,
|
||||
"removed": removed,
|
||||
"purged": purged,
|
||||
"memory_tree_chunks_deleted": memory_tree_chunks_deleted,
|
||||
}),
|
||||
format!("vault removed: {id}"),
|
||||
))
|
||||
|
||||
+604
-54
@@ -1,20 +1,62 @@
|
||||
//! Walk a vault's root directory and ingest changed/new files into memory.
|
||||
//! Walk a vault's root directory and ingest changed/new files into the
|
||||
//! memory_tree backend.
|
||||
//!
|
||||
//! # Memory-tree pipeline (not the legacy `memory_docs` path)
|
||||
//!
|
||||
//! Each file is ingested via [`crate::openhuman::memory::ingest_pipeline::ingest_document`]
|
||||
//! against a stable `source_id` of the form `vault:{vault_id}:{rel_path}`.
|
||||
//! That populates `mem_tree_chunks` + `mem_tree_ingested_sources` — which is
|
||||
//! what every modern retrieval surface (`memory.search`, `tree.read_chunk`,
|
||||
//! `tree.browse`, the agent's recall path, summary trees) reads from.
|
||||
//!
|
||||
//! Prior to #2705 this path called `doc_ingest`, which routed through the
|
||||
//! legacy `UnifiedMemory` backend and wrote to `memory_docs` instead. The
|
||||
//! UI's "synced" message was technically correct (UnifiedMemory accepted the
|
||||
//! writes) but invisible to retrieval. Migrating to the memory-tree pipeline
|
||||
//! closes that silent-failure mode.
|
||||
//!
|
||||
//! ## Source-id semantics
|
||||
//!
|
||||
//! - `source_id = vault:{vault_id}:{rel_path}` is stable for a given file path
|
||||
//! in a given vault. The pipeline's `already_ingested(SourceKind::Document,
|
||||
//! source_id)` gate is content-blind, so for content updates the vault
|
||||
//! layer must delete the prior chunks (via [`delete_chunks_by_source`])
|
||||
//! *before* re-ingesting, otherwise the new content is short-circuited.
|
||||
//! - The vault's own per-file `content_hash` ledger entry is the authoritative
|
||||
//! "did the bytes change?" check — when it matches we skip the pipeline
|
||||
//! entirely (no delete, no re-ingest).
|
||||
//! - When a previously-ledger'd file disappears from the walk, the same
|
||||
//! `delete_chunks_by_source` cleans up the orphan rows so memory_tree
|
||||
//! stays in sync with the on-disk vault.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::Utc;
|
||||
use chrono::{TimeZone, 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 crate::openhuman::memory::ingest_pipeline::{self, IngestResult};
|
||||
use crate::openhuman::memory::ops::{doc_delete, DeleteDocParams};
|
||||
use crate::openhuman::memory_store::chunks::store::delete_chunks_by_source;
|
||||
use crate::openhuman::memory_store::chunks::types::SourceKind;
|
||||
use crate::openhuman::memory_sync::canonicalize::document::DocumentInput;
|
||||
|
||||
use super::state;
|
||||
use super::store;
|
||||
use super::types::{Vault, VaultFile, VaultFileStatus, VaultSyncReport};
|
||||
|
||||
/// Build the memory-tree source_id for one file in a vault. Stable across
|
||||
/// re-syncs of the same `(vault, rel_path)`, so the pipeline's idempotency
|
||||
/// gate works correctly and the vault ledger can map back to chunks for
|
||||
/// cleanup.
|
||||
fn vault_source_id(vault_id: &str, rel_path: &str) -> String {
|
||||
format!("vault:{vault_id}:{rel_path}")
|
||||
}
|
||||
|
||||
/// Built-in exclude patterns we never traverse. Kept tiny and obvious.
|
||||
const BUILTIN_EXCLUDE_DIRS: &[&str] = &[
|
||||
".git",
|
||||
@@ -92,10 +134,6 @@ struct FileToProcess {
|
||||
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,
|
||||
}
|
||||
@@ -119,10 +157,12 @@ enum IngestFileResult {
|
||||
},
|
||||
}
|
||||
|
||||
/// Read `file.path`, hash it, and call `doc_ingest` if the content changed.
|
||||
/// Read `file.path`, hash it, and route it through the memory-tree ingestion
|
||||
/// pipeline. Skips the pipeline entirely when content is unchanged from the
|
||||
/// previous successful sync.
|
||||
///
|
||||
/// This runs inside `buffer_unordered` so multiple files are in flight at once.
|
||||
async fn process_file(file: FileToProcess) -> IngestFileResult {
|
||||
/// Runs inside `buffer_unordered` so multiple files are in flight at once.
|
||||
async fn process_file(config: Arc<Config>, file: FileToProcess) -> IngestFileResult {
|
||||
let content = match tokio::fs::read_to_string(&file.path).await {
|
||||
Ok(c) => c,
|
||||
Err(err) => {
|
||||
@@ -136,45 +176,124 @@ async fn process_file(file: FileToProcess) -> IngestFileResult {
|
||||
|
||||
// Secondary dedup: content didn't change even if mtime did (e.g. `touch`).
|
||||
if file.prev_hash.as_deref() == Some(hash.as_str()) {
|
||||
log::trace!(
|
||||
"[vault] sync: hash-match skip path={} source_id={}",
|
||||
file.rel_path,
|
||||
vault_source_id(&file.vault_id, &file.rel_path),
|
||||
);
|
||||
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,
|
||||
};
|
||||
let source_id = vault_source_id(&file.vault_id, &file.rel_path);
|
||||
|
||||
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,
|
||||
},
|
||||
// For content updates (prev_hash was Some but didn't match), the
|
||||
// memory-tree pipeline's `already_ingested` gate would short-circuit
|
||||
// the new content because the source_id is stable per file path. Drop
|
||||
// the old chunks first so the re-ingest actually runs.
|
||||
if file.prev_hash.is_some() {
|
||||
let cfg_for_blocking = Arc::clone(&config);
|
||||
let source_for_blocking = source_id.clone();
|
||||
let delete_result = tokio::task::spawn_blocking(move || {
|
||||
delete_chunks_by_source(
|
||||
&cfg_for_blocking,
|
||||
SourceKind::Document,
|
||||
&source_for_blocking,
|
||||
)
|
||||
})
|
||||
.await;
|
||||
match delete_result {
|
||||
Ok(Ok(removed)) => {
|
||||
log::debug!(
|
||||
"[vault] sync: re-ingest cleanup path={} source_id={} removed_chunks={}",
|
||||
file.rel_path,
|
||||
source_id,
|
||||
removed
|
||||
);
|
||||
}
|
||||
Ok(Err(err)) => {
|
||||
// Failing the delete pre-empts a corrupt-state re-ingest —
|
||||
// surface as Failed instead of silently leaving stale rows
|
||||
// alongside new ones.
|
||||
return IngestFileResult::Failed {
|
||||
rel_path: file.rel_path,
|
||||
error: format!("delete_chunks_by_source failed before re-ingest: {err}"),
|
||||
};
|
||||
}
|
||||
Err(join_err) => {
|
||||
return IngestFileResult::Failed {
|
||||
rel_path: file.rel_path,
|
||||
error: format!("delete task join error: {join_err}"),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pipeline modified_at = file mtime, so chunk metadata reflects when
|
||||
// the user last touched the file rather than when sync ran.
|
||||
let modified_at = Utc
|
||||
.timestamp_millis_opt(file.mtime_ms)
|
||||
.single()
|
||||
.unwrap_or_else(Utc::now);
|
||||
|
||||
let doc = DocumentInput {
|
||||
provider: "vault".to_string(),
|
||||
title: file.title,
|
||||
body: content,
|
||||
modified_at,
|
||||
source_ref: Some(file.rel_path.clone()),
|
||||
};
|
||||
let tags = vec![
|
||||
format!("vault:{}", file.vault_id),
|
||||
format!("ext:{}", file.ext),
|
||||
];
|
||||
|
||||
// `&config` deref-coerces the `Arc<Config>` to `&Config`; the pipeline
|
||||
// owns no Config references beyond this call, so the ref-count survives
|
||||
// the await without an explicit clone.
|
||||
match ingest_pipeline::ingest_document(&config, &source_id, "vault", tags, doc).await {
|
||||
Ok(IngestResult {
|
||||
chunks_written,
|
||||
already_ingested,
|
||||
..
|
||||
}) => {
|
||||
// The delete-first guard above prevents `already_ingested` on the
|
||||
// normal content-update path. If we still see it here it means
|
||||
// the vault ledger and `mem_tree_ingested_sources` are out of
|
||||
// sync (ledger wiped while the memory_tree row survived, or vice
|
||||
// versa) — the ledger gets a fresh row, but nothing new reaches
|
||||
// retrieval. That's the exact false-success mode this PR set out
|
||||
// to kill, so surface it loudly instead of swallowing it.
|
||||
if already_ingested && chunks_written == 0 {
|
||||
log::warn!(
|
||||
"[vault] sync: ledger↔memory_tree desync detected — \
|
||||
`already_ingested=true` for source_id={source_id} \
|
||||
(path={}) but ledger had no matching row; no new chunks \
|
||||
reached retrieval. Manual `delete_chunks_by_source` + \
|
||||
resync may be required.",
|
||||
file.rel_path,
|
||||
);
|
||||
} else {
|
||||
log::debug!(
|
||||
"[vault] sync: ingested path={} source_id={} chunks_written={} already_ingested={}",
|
||||
file.rel_path,
|
||||
source_id,
|
||||
chunks_written,
|
||||
already_ingested,
|
||||
);
|
||||
}
|
||||
IngestFileResult::Ingested {
|
||||
rel_path: file.rel_path,
|
||||
document_id: source_id,
|
||||
hash,
|
||||
mtime_ms: file.mtime_ms,
|
||||
bytes: file.bytes,
|
||||
}
|
||||
}
|
||||
Err(err) => IngestFileResult::Failed {
|
||||
rel_path: file.rel_path,
|
||||
error: err,
|
||||
error: format!("ingest_document failed: {err}"),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -349,8 +468,6 @@ pub async fn sync_vault(config: &Config, vault: &Vault) -> VaultSyncReport {
|
||||
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(),
|
||||
});
|
||||
}
|
||||
@@ -371,8 +488,14 @@ pub async fn sync_vault(config: &Config, vault: &Vault) -> VaultSyncReport {
|
||||
});
|
||||
|
||||
// ── Phase 2: Concurrent ingestion ────────────────────────────────────────
|
||||
//
|
||||
// Each task takes an `Arc<Config>` so we share one allocation across all
|
||||
// candidates instead of deep-cloning the config per file. A 5k-file vault
|
||||
// therefore pays one `Config::clone()` + N atomic ref-count bumps, vs the
|
||||
// previous N full clones — measurably cheaper on cold backfills.
|
||||
let config_for_workers = Arc::new(config.clone());
|
||||
let results: Vec<IngestFileResult> = futures::stream::iter(candidates)
|
||||
.map(process_file)
|
||||
.map(move |file| process_file(Arc::clone(&config_for_workers), file))
|
||||
.buffer_unordered(SYNC_CONCURRENCY)
|
||||
.collect()
|
||||
.await;
|
||||
@@ -424,21 +547,99 @@ pub async fn sync_vault(config: &Config, vault: &Vault) -> VaultSyncReport {
|
||||
}
|
||||
|
||||
// ── Phase 4: Deletions ────────────────────────────────────────────────────
|
||||
//
|
||||
// Files that vanished from the vault since the last sync. We drop their
|
||||
// memory-tree rows so retrieval never resurfaces deleted content. The
|
||||
// `delete_chunks_by_source` helper handles `mem_tree_chunks`,
|
||||
// `mem_tree_ingested_sources`, and the on-disk content sidecars in one
|
||||
// transaction.
|
||||
//
|
||||
// Hoist the `Arc<Config>` for the blocking deletes once instead of
|
||||
// re-cloning the full `Config` per vanished file (mirrors the Phase 2
|
||||
// worker-pool optimisation).
|
||||
let config_for_deletes = Arc::new(config.clone());
|
||||
for (path, prev) in by_path.iter() {
|
||||
if seen.contains(path) {
|
||||
continue;
|
||||
}
|
||||
if let Err(err) = doc_delete(DeleteDocParams {
|
||||
namespace: vault.namespace.clone(),
|
||||
document_id: prev.document_id.clone(),
|
||||
|
||||
// Two ledger generations to handle here:
|
||||
//
|
||||
// * Post-#2705 rows: `document_id` is already `vault:{id}:{rel_path}`,
|
||||
// so the memory_tree `delete_chunks_by_source` call below cleans it
|
||||
// up exactly.
|
||||
// * Pre-#2705 rows: `document_id` is a legacy UnifiedMemory id
|
||||
// (`{ts}_{hex}`-shaped) whose chunks live in `memory_docs`, *not*
|
||||
// in `mem_tree_*`. Recomputing the memory_tree source_id and
|
||||
// running `delete_chunks_by_source` deletes nothing on those rows;
|
||||
// without a parallel `doc_delete` the legacy data leaks until
|
||||
// UnifiedMemory removal lands (#2585 follow-up). We do both during
|
||||
// the migration window so vanished files actually go away.
|
||||
let stored_id = prev.document_id.clone();
|
||||
let is_legacy_ledger_row = !stored_id.starts_with("vault:");
|
||||
let source_id = if is_legacy_ledger_row {
|
||||
log::debug!(
|
||||
"[vault] sync: legacy ledger doc_id detected during delete \
|
||||
path={path} stored_id={stored_id} — falling back to recomputed \
|
||||
source_id + parallel UnifiedMemory doc_delete"
|
||||
);
|
||||
vault_source_id(&vault.id, path)
|
||||
} else {
|
||||
stored_id.clone()
|
||||
};
|
||||
|
||||
// Legacy UnifiedMemory cleanup. Best-effort: a 404 / missing-doc
|
||||
// error on the legacy path shouldn't block the memory_tree delete
|
||||
// below, which is the canonical store going forward.
|
||||
if is_legacy_ledger_row {
|
||||
if let Err(err) = doc_delete(DeleteDocParams {
|
||||
namespace: vault.namespace.clone(),
|
||||
document_id: stored_id.clone(),
|
||||
})
|
||||
.await
|
||||
{
|
||||
log::debug!(
|
||||
"[vault] sync: legacy doc_delete failed (likely already absent) \
|
||||
path={path} document_id={stored_id} err={err} — continuing with memory_tree cleanup"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let cfg_for_blocking = Arc::clone(&config_for_deletes);
|
||||
let source_for_blocking = source_id.clone();
|
||||
let path_label = path.clone();
|
||||
let delete_result = tokio::task::spawn_blocking(move || {
|
||||
delete_chunks_by_source(
|
||||
&cfg_for_blocking,
|
||||
SourceKind::Document,
|
||||
&source_for_blocking,
|
||||
)
|
||||
})
|
||||
.await
|
||||
{
|
||||
log::debug!("[vault] sync: doc delete failed path={path} err={err}");
|
||||
report
|
||||
.errors
|
||||
.push(format!("{path}: doc delete failed: {err}"));
|
||||
continue;
|
||||
.await;
|
||||
match delete_result {
|
||||
Ok(Ok(removed)) => {
|
||||
log::debug!(
|
||||
"[vault] sync: deleted vanished file path={} source_id={} removed_chunks={}",
|
||||
path_label,
|
||||
source_id,
|
||||
removed
|
||||
);
|
||||
}
|
||||
Ok(Err(err)) => {
|
||||
log::debug!(
|
||||
"[vault] sync: delete_chunks_by_source failed path={path_label} err={err}"
|
||||
);
|
||||
report
|
||||
.errors
|
||||
.push(format!("{path_label}: chunk delete failed: {err}"));
|
||||
continue;
|
||||
}
|
||||
Err(join_err) => {
|
||||
report
|
||||
.errors
|
||||
.push(format!("{path_label}: delete task join error: {join_err}"));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if let Err(err) = store::delete_file(config, &vault.id, path) {
|
||||
log::debug!("[vault] sync: ledger delete failed path={path} err={err}");
|
||||
@@ -495,3 +696,352 @@ fn sha256_hex(content: &str) -> String {
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod sync_tests {
|
||||
use super::*;
|
||||
use crate::openhuman::memory_store::chunks::store::{count_chunks, is_source_ingested};
|
||||
use crate::openhuman::vault::ops;
|
||||
use tempfile::TempDir;
|
||||
|
||||
/// Test-config pattern mirrors `memory::sync_pipeline_e2e_test::test_config`:
|
||||
/// tempdir-scoped workspace + embeddings disabled so the pipeline doesn't
|
||||
/// require a live provider. Embedding-strict OFF lets the pipeline accept
|
||||
/// chunks even without a working embedder.
|
||||
fn test_config() -> (TempDir, Config) {
|
||||
let tmp = TempDir::new().expect("tempdir");
|
||||
let mut cfg = Config::default();
|
||||
cfg.workspace_dir = tmp.path().to_path_buf();
|
||||
cfg.memory_tree.embedding_endpoint = None;
|
||||
cfg.memory_tree.embedding_model = None;
|
||||
cfg.memory_tree.embedding_strict = false;
|
||||
(tmp, cfg)
|
||||
}
|
||||
|
||||
fn sample_vault(id: &str, root: &Path) -> Vault {
|
||||
Vault {
|
||||
id: id.to_string(),
|
||||
name: "Test Vault".to_string(),
|
||||
root_path: root.to_string_lossy().to_string(),
|
||||
host_os: None,
|
||||
namespace: format!("vault:{id}"),
|
||||
include_globs: vec![],
|
||||
exclude_globs: vec![],
|
||||
created_at: Utc::now(),
|
||||
last_synced_at: None,
|
||||
file_count: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// **The #2705 regression test.**
|
||||
///
|
||||
/// Before this fix, vault sync routed through `doc_ingest` →
|
||||
/// `UnifiedMemory::ingest_document` → `memory_docs` table. The
|
||||
/// `mem_tree_chunks` / `mem_tree_ingested_sources` tables — which every
|
||||
/// modern retrieval surface reads from — were left empty, and the UI's
|
||||
/// "synced" message gave users a false-success signal.
|
||||
///
|
||||
/// This test pins the invariant that vault sync must populate the
|
||||
/// memory-tree backend so the silent-failure mode can't reappear. It
|
||||
/// asserts both the chunk row count goes up and that each per-file
|
||||
/// source_id is registered in `mem_tree_ingested_sources`.
|
||||
#[tokio::test]
|
||||
async fn sync_writes_to_memory_tree() {
|
||||
let (_tmp, cfg) = test_config();
|
||||
|
||||
let vault_root = TempDir::new().expect("vault root");
|
||||
let vault = sample_vault("vault-2705", vault_root.path());
|
||||
|
||||
// Insert the vault row first — the vault_files ledger has a FK to
|
||||
// vaults, so per-file writes during Phase 3 would silently rollback
|
||||
// otherwise.
|
||||
store::insert_vault(&cfg, &vault).expect("insert vault");
|
||||
|
||||
// Two non-trivial markdown files so the chunker definitely produces
|
||||
// ≥1 chunk per file (minimal content can otherwise be canonicalised
|
||||
// into nothing).
|
||||
std::fs::write(
|
||||
vault_root.path().join("note-one.md"),
|
||||
"# Note One\n\nThis is a substantive note about Project Phoenix. \
|
||||
It mentions Alice as the owner and contains enough text to ensure \
|
||||
the chunker produces at least one chunk. Phoenix ships in Q3.",
|
||||
)
|
||||
.expect("write note-one.md");
|
||||
std::fs::write(
|
||||
vault_root.path().join("note-two.md"),
|
||||
"# Note Two\n\nDifferent content about Project Atlas. Bob owns this \
|
||||
one. The team plans a launch in Q4 after staging review. Atlas is \
|
||||
unrelated to Phoenix.",
|
||||
)
|
||||
.expect("write note-two.md");
|
||||
|
||||
let chunks_before = count_chunks(&cfg).expect("count_chunks before");
|
||||
let report = sync_vault(&cfg, &vault).await;
|
||||
|
||||
assert_eq!(
|
||||
report.failed, 0,
|
||||
"no files should fail in a clean test setup; errors: {:?}",
|
||||
report.errors
|
||||
);
|
||||
assert_eq!(
|
||||
report.ingested, 2,
|
||||
"both .md files should ingest; report: {report:?}"
|
||||
);
|
||||
|
||||
// Core regression assertion: chunks landed in memory_tree (NOT
|
||||
// memory_docs). Pre-fix, chunks_after would equal chunks_before
|
||||
// because vault sync wrote to the legacy backend instead.
|
||||
let chunks_after = count_chunks(&cfg).expect("count_chunks after");
|
||||
assert!(
|
||||
chunks_after > chunks_before,
|
||||
"vault sync must populate mem_tree_chunks (#2705): {chunks_before} → {chunks_after}"
|
||||
);
|
||||
|
||||
// Per-file source registration in mem_tree_ingested_sources.
|
||||
// `is_source_ingested` returns false on the legacy backend even after
|
||||
// a "successful" doc_ingest run.
|
||||
let cfg_for_blocking = cfg.clone();
|
||||
let registered = tokio::task::spawn_blocking(move || {
|
||||
(
|
||||
is_source_ingested(
|
||||
&cfg_for_blocking,
|
||||
SourceKind::Document,
|
||||
"vault:vault-2705:note-one.md",
|
||||
)
|
||||
.unwrap_or(false),
|
||||
is_source_ingested(
|
||||
&cfg_for_blocking,
|
||||
SourceKind::Document,
|
||||
"vault:vault-2705:note-two.md",
|
||||
)
|
||||
.unwrap_or(false),
|
||||
)
|
||||
})
|
||||
.await
|
||||
.expect("source-check task join");
|
||||
assert!(
|
||||
registered.0 && registered.1,
|
||||
"both source_ids must be registered in mem_tree_ingested_sources (#2705); \
|
||||
note-one={} note-two={}",
|
||||
registered.0,
|
||||
registered.1
|
||||
);
|
||||
|
||||
// Vault ledger stores the memory-tree source_id, not a legacy
|
||||
// UnifiedMemory document_id. This is the contract the deletion
|
||||
// path relies on (`stored_id.starts_with("vault:")`).
|
||||
let ledger = store::list_files(&cfg, "vault-2705").expect("list_files");
|
||||
assert_eq!(ledger.len(), 2, "ledger should have one row per file");
|
||||
for entry in &ledger {
|
||||
assert!(
|
||||
entry.document_id.starts_with("vault:vault-2705:"),
|
||||
"ledger document_id must hold the memory-tree source_id (got {})",
|
||||
entry.document_id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Stable across re-syncs: a second sync with no content changes leaves
|
||||
/// the chunk count untouched (idempotency invariant) and reports every
|
||||
/// file as `unchanged`.
|
||||
#[tokio::test]
|
||||
async fn second_sync_with_no_changes_is_idempotent() {
|
||||
let (_tmp, cfg) = test_config();
|
||||
let vault_root = TempDir::new().expect("vault root");
|
||||
let vault = sample_vault("vault-idem", vault_root.path());
|
||||
|
||||
store::insert_vault(&cfg, &vault).expect("insert vault");
|
||||
|
||||
std::fs::write(
|
||||
vault_root.path().join("stable.md"),
|
||||
"# Stable\n\nThis note doesn't change between syncs. Phoenix Q3.",
|
||||
)
|
||||
.expect("write stable.md");
|
||||
|
||||
let first = sync_vault(&cfg, &vault).await;
|
||||
assert_eq!(first.ingested, 1, "first sync ingests the file");
|
||||
let chunks_after_first = count_chunks(&cfg).expect("count after first");
|
||||
|
||||
let second = sync_vault(&cfg, &vault).await;
|
||||
assert_eq!(
|
||||
second.unchanged, 1,
|
||||
"second sync should hash-skip the unchanged file"
|
||||
);
|
||||
assert_eq!(second.ingested, 0, "no re-ingest on unchanged content");
|
||||
let chunks_after_second = count_chunks(&cfg).expect("count after second");
|
||||
assert_eq!(
|
||||
chunks_after_first, chunks_after_second,
|
||||
"idempotent re-sync must not duplicate chunks"
|
||||
);
|
||||
}
|
||||
|
||||
/// **Regression: `vault_remove(purge_memory=true)` must clear memory_tree.**
|
||||
///
|
||||
/// Post-#2705, vault content lives in `mem_tree_chunks` /
|
||||
/// `mem_tree_ingested_sources` keyed by `vault:{id}:{rel_path}`. The
|
||||
/// pre-fix purge path only called `clear_namespace`, which targets the
|
||||
/// legacy `memory_docs` table that vault sync no longer writes to —
|
||||
/// removing a vault with purge would silently orphan every memory-tree
|
||||
/// row and retrieval would keep surfacing content from a deleted vault.
|
||||
/// This test pins the prefix-delete contract so the silent-failure mode
|
||||
/// can't reappear on the removal side.
|
||||
#[tokio::test]
|
||||
async fn vault_remove_with_purge_clears_memory_tree() {
|
||||
let (_tmp, cfg) = test_config();
|
||||
|
||||
let vault_root = TempDir::new().expect("vault root");
|
||||
let vault = sample_vault("vault-remove-2720", vault_root.path());
|
||||
|
||||
// Use the real `vault_create` op so the row goes through the same
|
||||
// path production callers exercise (and so namespace/host_os are
|
||||
// realistic). `vault_create` canonicalises root_path, so the
|
||||
// returned vault id is the one to operate on below.
|
||||
let created =
|
||||
ops::vault_create(&cfg, &vault.name, vault.root_path.as_str(), vec![], vec![])
|
||||
.await
|
||||
.expect("vault_create")
|
||||
.value;
|
||||
let vault_id = created.id.clone();
|
||||
|
||||
std::fs::write(
|
||||
vault_root.path().join("doomed.md"),
|
||||
"# Doomed\n\nThis note exists only long enough to be purged \
|
||||
with its parent vault. Phoenix Q4.",
|
||||
)
|
||||
.expect("write doomed.md");
|
||||
|
||||
let report = sync_vault(&cfg, &created).await;
|
||||
assert_eq!(
|
||||
report.failed, 0,
|
||||
"clean ingest; errors: {:?}",
|
||||
report.errors
|
||||
);
|
||||
assert_eq!(report.ingested, 1);
|
||||
|
||||
let source_id = vault_source_id(&vault_id, "doomed.md");
|
||||
let chunks_before = count_chunks(&cfg).expect("count_chunks before remove");
|
||||
assert!(
|
||||
chunks_before > 0,
|
||||
"sanity: memory_tree should contain the vault chunks before remove"
|
||||
);
|
||||
let registered_before = {
|
||||
let cfg_clone = cfg.clone();
|
||||
let src = source_id.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
is_source_ingested(&cfg_clone, SourceKind::Document, &src).unwrap_or(false)
|
||||
})
|
||||
.await
|
||||
.expect("source-check join")
|
||||
};
|
||||
assert!(
|
||||
registered_before,
|
||||
"sanity: source must be registered pre-remove"
|
||||
);
|
||||
|
||||
let outcome = ops::vault_remove(&cfg, &vault_id, true)
|
||||
.await
|
||||
.expect("vault_remove");
|
||||
let payload = outcome.value;
|
||||
assert_eq!(payload["removed"], serde_json::json!(true));
|
||||
assert_eq!(payload["purged"], serde_json::json!(true));
|
||||
let purged_chunks = payload["memory_tree_chunks_deleted"]
|
||||
.as_u64()
|
||||
.expect("memory_tree_chunks_deleted field");
|
||||
assert!(
|
||||
purged_chunks > 0,
|
||||
"purge must report removed chunks; payload={payload}"
|
||||
);
|
||||
|
||||
// Core regression assertion: no memory_tree rows survive the purge.
|
||||
let registered_after = {
|
||||
let cfg_clone = cfg.clone();
|
||||
let src = source_id.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
is_source_ingested(&cfg_clone, SourceKind::Document, &src).unwrap_or(true)
|
||||
})
|
||||
.await
|
||||
.expect("source-check join")
|
||||
};
|
||||
assert!(
|
||||
!registered_after,
|
||||
"vault_remove(purge=true) must clear mem_tree_ingested_sources for source_id={source_id}"
|
||||
);
|
||||
let chunks_after = count_chunks(&cfg).expect("count_chunks after remove");
|
||||
assert!(
|
||||
chunks_after < chunks_before,
|
||||
"memory_tree chunks must shrink after purge: {chunks_before} → {chunks_after}"
|
||||
);
|
||||
}
|
||||
|
||||
/// `vault_remove(purge_memory=false)` must leave memory_tree rows in
|
||||
/// place. Guards the boolean from silently flipping to always-purge.
|
||||
#[tokio::test]
|
||||
async fn vault_remove_without_purge_leaves_memory_tree_intact() {
|
||||
let (_tmp, cfg) = test_config();
|
||||
let vault_root = TempDir::new().expect("vault root");
|
||||
let vault = sample_vault("vault-keep-2720", vault_root.path());
|
||||
|
||||
let created =
|
||||
ops::vault_create(&cfg, &vault.name, vault.root_path.as_str(), vec![], vec![])
|
||||
.await
|
||||
.expect("vault_create")
|
||||
.value;
|
||||
let vault_id = created.id.clone();
|
||||
|
||||
std::fs::write(
|
||||
vault_root.path().join("kept.md"),
|
||||
"# Kept\n\nThis content should survive a no-purge vault removal. \
|
||||
Atlas Q1 plan.",
|
||||
)
|
||||
.expect("write kept.md");
|
||||
|
||||
let report = sync_vault(&cfg, &created).await;
|
||||
assert_eq!(
|
||||
report.failed, 0,
|
||||
"clean ingest; errors: {:?}",
|
||||
report.errors
|
||||
);
|
||||
let chunks_before = count_chunks(&cfg).expect("count_chunks before");
|
||||
|
||||
let outcome = ops::vault_remove(&cfg, &vault_id, false)
|
||||
.await
|
||||
.expect("vault_remove");
|
||||
let payload = outcome.value;
|
||||
assert_eq!(payload["removed"], serde_json::json!(true));
|
||||
assert_eq!(payload["purged"], serde_json::json!(false));
|
||||
assert_eq!(
|
||||
payload["memory_tree_chunks_deleted"],
|
||||
serde_json::json!(0),
|
||||
"no-purge removal must not touch memory_tree"
|
||||
);
|
||||
|
||||
let chunks_after = count_chunks(&cfg).expect("count_chunks after");
|
||||
assert_eq!(
|
||||
chunks_before, chunks_after,
|
||||
"no-purge removal must leave chunk count unchanged"
|
||||
);
|
||||
}
|
||||
|
||||
/// `vault_source_id` is stable across calls — this is what makes the
|
||||
/// ledger ↔ memory_tree link work for cleanup on file deletion and for
|
||||
/// the content-update delete-then-reingest dance.
|
||||
#[test]
|
||||
fn vault_source_id_is_stable_and_namespaced() {
|
||||
let a = vault_source_id("v1", "notes/foo.md");
|
||||
let b = vault_source_id("v1", "notes/foo.md");
|
||||
assert_eq!(a, b, "must be deterministic for the same (vault, path)");
|
||||
assert_eq!(a, "vault:v1:notes/foo.md");
|
||||
|
||||
// Different vaults / paths produce different ids — defends against
|
||||
// the pipeline's `already_ingested` gate cross-contaminating
|
||||
// distinct files.
|
||||
assert_ne!(
|
||||
vault_source_id("v1", "notes/foo.md"),
|
||||
vault_source_id("v2", "notes/foo.md")
|
||||
);
|
||||
assert_ne!(
|
||||
vault_source_id("v1", "notes/foo.md"),
|
||||
vault_source_id("v1", "notes/bar.md")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+63
-23
@@ -2,7 +2,14 @@
|
||||
//!
|
||||
//! Runs the public `vault.*` operations against a real temp workspace:
|
||||
//! create a vault, sync supported files, verify per-file ledger + memory
|
||||
//! metadata, then modify/delete/add files and sync again.
|
||||
//! tree state, then modify/delete/add files and sync again.
|
||||
//!
|
||||
//! Memory-side assertions target the **memory_tree backend** (the canonical
|
||||
//! RAG layer for `memory.search` / `tree.read_chunk` / agent recall).
|
||||
//! Prior to #2720 vault sync wrote to the legacy `UnifiedMemory.memory_docs`
|
||||
//! table — silently invisible to retrieval. The fix migrated vault sync to
|
||||
//! the memory-tree pipeline, so this test now probes `mem_tree_chunks` and
|
||||
//! `mem_tree_ingested_sources` instead of `list_documents`.
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
@@ -12,6 +19,8 @@ use tempfile::tempdir;
|
||||
|
||||
use openhuman_core::openhuman::config::Config;
|
||||
use openhuman_core::openhuman::memory::global as memory_global;
|
||||
use openhuman_core::openhuman::memory_store::chunks::store::{count_chunks, is_source_ingested};
|
||||
use openhuman_core::openhuman::memory_store::chunks::types::SourceKind;
|
||||
use openhuman_core::openhuman::vault::ops;
|
||||
use openhuman_core::openhuman::vault::VaultSyncStatus;
|
||||
|
||||
@@ -44,14 +53,6 @@ async fn wait_for_sync(vault_id: &str) -> openhuman_core::openhuman::vault::Vaul
|
||||
panic!("vault sync did not finish within polling window");
|
||||
}
|
||||
|
||||
fn documents_from_payload(payload: &serde_json::Value) -> Vec<serde_json::Value> {
|
||||
payload
|
||||
.get("documents")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn vault_sync_roundtrip_updates_memory_and_ledger() {
|
||||
let _guard = test_lock();
|
||||
@@ -113,20 +114,36 @@ async fn vault_sync_roundtrip_updates_memory_and_ledger() {
|
||||
assert!(files.iter().any(|file| file.rel_path == "notes/one.md"));
|
||||
assert!(files.iter().any(|file| file.rel_path == "docs/two.json"));
|
||||
|
||||
let docs = memory_global::client()
|
||||
.expect("global memory client")
|
||||
.list_documents(Some(&vault.namespace))
|
||||
.await
|
||||
.expect("list vault documents");
|
||||
let docs = documents_from_payload(&docs);
|
||||
assert_eq!(docs.len(), 2);
|
||||
assert!(docs.iter().any(|doc| {
|
||||
doc.get("key").and_then(serde_json::Value::as_str) == Some("notes/one.md")
|
||||
&& doc.get("sourceType").and_then(serde_json::Value::as_str) == Some("vault")
|
||||
}));
|
||||
assert!(docs.iter().any(|doc| {
|
||||
doc.get("key").and_then(serde_json::Value::as_str) == Some("docs/two.json")
|
||||
}));
|
||||
// After #2720, vault sync writes to memory_tree (mem_tree_chunks +
|
||||
// mem_tree_ingested_sources). Both files must register as sources, and
|
||||
// the chunk count must be > 0 (otherwise the chunker / pipeline silently
|
||||
// dropped them — the exact failure mode this test guards against).
|
||||
let chunks_after_first = count_chunks(&config).expect("count_chunks after first sync");
|
||||
assert!(
|
||||
chunks_after_first > 0,
|
||||
"vault sync must populate mem_tree_chunks; got {chunks_after_first}"
|
||||
);
|
||||
let one_id = format!("vault:{}:notes/one.md", vault.id);
|
||||
let two_id = format!("vault:{}:docs/two.json", vault.id);
|
||||
assert!(
|
||||
is_source_ingested(&config, SourceKind::Document, &one_id).expect("source check one.md"),
|
||||
"notes/one.md missing from mem_tree_ingested_sources (source_id={one_id})"
|
||||
);
|
||||
assert!(
|
||||
is_source_ingested(&config, SourceKind::Document, &two_id).expect("source check two.json"),
|
||||
"docs/two.json missing from mem_tree_ingested_sources (source_id={two_id})"
|
||||
);
|
||||
|
||||
// Vault ledger continues to track the per-file row count and rel_paths
|
||||
// — same contract as before #2720; only the `document_id` semantic
|
||||
// changed (now holds the memory-tree source_id).
|
||||
for file in &files {
|
||||
assert!(
|
||||
file.document_id.starts_with("vault:"),
|
||||
"ledger document_id must encode memory-tree source_id, got {}",
|
||||
file.document_id
|
||||
);
|
||||
}
|
||||
|
||||
let note_ledger = files
|
||||
.iter()
|
||||
@@ -165,4 +182,27 @@ async fn vault_sync_roundtrip_updates_memory_and_ledger() {
|
||||
assert!(files.iter().any(|file| file.rel_path == "notes/one.md"));
|
||||
assert!(files.iter().any(|file| file.rel_path == "docs/three.toml"));
|
||||
assert!(!files.iter().any(|file| file.rel_path == "docs/two.json"));
|
||||
|
||||
// Memory-tree side of the lifecycle (post-#2720):
|
||||
// - notes/one.md : content updated → delete_chunks_by_source then
|
||||
// re-ingest; source must still be registered.
|
||||
// - docs/two.json : file removed → delete_chunks_by_source dropped
|
||||
// it; source must no longer register as ingested.
|
||||
// - docs/three.toml: brand-new file → freshly registered.
|
||||
let three_id = format!("vault:{}:docs/three.toml", vault.id);
|
||||
assert!(
|
||||
is_source_ingested(&config, SourceKind::Document, &one_id)
|
||||
.expect("source check one.md after update"),
|
||||
"notes/one.md must remain ingested after re-sync of updated content (source_id={one_id})"
|
||||
);
|
||||
assert!(
|
||||
is_source_ingested(&config, SourceKind::Document, &three_id)
|
||||
.expect("source check three.toml"),
|
||||
"docs/three.toml (new file) missing from mem_tree_ingested_sources (source_id={three_id})"
|
||||
);
|
||||
assert!(
|
||||
!is_source_ingested(&config, SourceKind::Document, &two_id).expect("source check two.json"),
|
||||
"docs/two.json was deleted on disk; vault sync's Phase 4 must remove \
|
||||
it from mem_tree_ingested_sources (source_id={two_id})"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user