feat: Memory subtabs + WebGL memory graph; drop Knowledge Vaults (#3040)

This commit is contained in:
Steven Enamakel
2026-05-30 20:21:58 -07:00
committed by GitHub
parent 8677eb643c
commit 36a3f3fa65
59 changed files with 1616 additions and 6208 deletions
-3
View File
@@ -191,8 +191,6 @@ fn build_registered_controllers() -> Vec<RegisteredController> {
controllers.extend(crate::openhuman::skills::all_skills_registered_controllers());
// User workspace and file management
controllers.extend(crate::openhuman::workspace::all_workspace_registered_controllers());
// Knowledge vaults — folder-of-files mirrored into memory
controllers.extend(crate::openhuman::vault::all_vault_registered_controllers());
// Skill tool registry
controllers.extend(crate::openhuman::tools::all_tools_registered_controllers());
// Unified read-only registry across MCP stdio tools and controller-backed tools
@@ -341,7 +339,6 @@ fn build_declared_controller_schemas() -> Vec<ControllerSchema> {
schemas.extend(crate::openhuman::javascript::all_javascript_controller_schemas());
schemas.extend(crate::openhuman::skills::all_skills_controller_schemas());
schemas.extend(crate::openhuman::workspace::all_workspace_controller_schemas());
schemas.extend(crate::openhuman::vault::all_vault_controller_schemas());
schemas.extend(crate::openhuman::tools::all_tools_controller_schemas());
schemas.extend(crate::openhuman::tool_registry::all_tool_registry_controller_schemas());
schemas.extend(crate::openhuman::memory::all_memory_controller_schemas());
+4 -3
View File
@@ -1242,9 +1242,10 @@ fn is_prompt_injection_blocked_message(lower: &str) -> bool {
/// boundary when a user typed/picked a path that doesn't resolve to an
/// existing directory:
///
/// - `"root_path is not a directory: <path>"` —
/// [`crate::openhuman::vault::ops::vault_create`] when the chosen vault
/// folder doesn't exist or points at a file (Sentry TAURI-RUST-4QH).
/// - `"root_path is not a directory: <path>"` — historically emitted by the
/// now-removed knowledge-vault `vault_create` path when the chosen folder
/// didn't exist or pointed at a file (Sentry TAURI-RUST-4QH). Kept as a
/// classifier fixture since the wire shape may recur from other callers.
/// - `"hosted path is not a directory: <path>"` —
/// [`crate::openhuman::http_host::path_utils`] when an HTTP host config
/// references a missing directory. Not yet observed in Sentry but
+5 -4
View File
@@ -352,10 +352,11 @@ fn does_not_classify_unrelated_messages_as_context_window_exceeded() {
#[test]
fn classifies_vault_create_root_path_not_a_directory_as_filesystem_user_path_invalid() {
// TAURI-RUST-4QH: verbatim wire shape from
// `openhuman::vault::ops::vault_create` line 37 when the
// user-picked vault folder doesn't resolve to an existing
// directory. Bubbles up as the RPC dispatcher's
// TAURI-RUST-4QH: verbatim wire shape historically emitted by the
// now-removed knowledge-vault `vault_create` path when the
// user-picked folder didn't resolve to an existing directory.
// Retained as a classifier fixture in case the shape recurs from
// another caller. Bubbles up as the RPC dispatcher's
// `display_message` and reaches `report_error_or_expected` —
// must classify so no Sentry event fires.
assert_eq!(
-10
View File
@@ -310,16 +310,6 @@ pub(super) const CAPABILITIES: &[Capability] = &[
status: CapabilityStatus::Beta,
privacy: LOCAL_RAW,
},
Capability {
id: "intelligence.vault_markdown_writes",
name: "Vault Markdown Writes",
domain: "intelligence",
category: CapabilityCategory::Intelligence,
description: "Show whether a user-added local vault is writable, and write explicitly approved markdown/wiki artifacts back into that vault without leaving the device.",
how_to: "Intelligence > Memory > Knowledge vaults",
status: CapabilityStatus::Beta,
privacy: LOCAL_RAW,
},
Capability {
id: "intelligence.embedding_provider_config",
name: "Configure Embedding Provider",
-1
View File
@@ -103,7 +103,6 @@ fn catalog_includes_additional_user_facing_surfaces() {
"intelligence.mcp_server",
"intelligence.searxng_search",
"intelligence.tool_registry",
"intelligence.vault_markdown_writes",
"intelligence.embedding_provider_config",
"intelligence.embedding_provider_test",
"conversation.subagent_mascots",
+78 -5
View File
@@ -879,8 +879,9 @@ pub struct DeleteChunkResponse {
/// Which graph the UI is asking for.
///
/// `Tree` returns summary nodes connected by parent_id (current
/// Obsidian-style summary tree). `Contacts` returns raw chunks
/// `Tree` returns the summary tree (summary nodes connected by
/// parent_id) plus the leaf chunks hanging off it, bounded to ~1000
/// nodes with summaries prioritized. `Contacts` returns raw chunks
/// connected to the person entities they mention via the inverted
/// `mem_tree_entity_index` — i.e. the document↔contact graph.
///
@@ -1071,10 +1072,26 @@ pub async fn obsidian_vault_status_rpc(
}
/// Tree mode: summary nodes joined to their owning tree for the
/// human-readable scope. Edges are encoded implicitly via
/// `GraphNode.parent_id`.
/// human-readable scope, plus the leaf chunks that hang off them. Edges
/// are encoded implicitly via `GraphNode.parent_id` (a chunk's
/// `parent_id` is its `parent_summary_id`, which matches a summary node's
/// `id`).
///
/// Budget: summary (tree) nodes are **always kept in full** — they are
/// the skeleton of the graph — then leaf chunks fill the remaining budget
/// up to [`MAX_TREE_NODES`], most-recent first. Without the leaves the UI
/// graph showed only the handful of sealed summaries (e.g. ~20) while
/// Obsidian, which renders every `.md` on disk, showed hundreds; the
/// chunks are the bulk of the tree. Unsealed chunks have a null
/// `parent_summary_id` and render as orphan nodes — matching Obsidian's
/// `showOrphans` view.
fn collect_tree_graph(cfg: &Config) -> Result<(Vec<GraphNode>, Vec<GraphEdge>)> {
let nodes = with_connection(cfg, |conn| {
/// Hard cap on total nodes returned in Tree mode. The custom
/// force-simulation in the UI does all-pairs repulsion (O(n²)), so
/// this bounds both the wire payload and the layout cost.
const MAX_TREE_NODES: usize = 1000;
let mut nodes = with_connection(cfg, |conn| {
let mut stmt = conn.prepare(
"SELECT s.id, s.tree_id, s.tree_kind, t.scope, s.level, s.parent_id,
s.child_ids_json, s.time_range_start_ms, s.time_range_end_ms
@@ -1119,6 +1136,62 @@ fn collect_tree_graph(cfg: &Config) -> Result<(Vec<GraphNode>, Vec<GraphEdge>)>
.context("collect tree-mode summary rows")?;
Ok(rows)
})?;
// Fill the remaining budget with leaf chunks, most-recent first.
// Summaries are kept whole; only the chunk tail is truncated when a
// very large workspace would blow past MAX_TREE_NODES.
let chunk_budget = MAX_TREE_NODES.saturating_sub(nodes.len());
if chunk_budget == 0 {
return Ok((nodes, Vec::new()));
}
let chunk_nodes = with_connection(cfg, |conn| {
let mut stmt = conn.prepare(
"SELECT id, parent_summary_id, content, time_range_start_ms, time_range_end_ms
FROM mem_tree_chunks
ORDER BY timestamp_ms DESC
LIMIT ?1",
)?;
let rows = stmt
.query_map(params![chunk_budget as i64], |row| {
let id: String = row.get(0)?;
let parent_id: Option<String> = row.get(1)?;
let content: String = row.get(2)?;
let time_range_start_ms: i64 = row.get(3)?;
let time_range_end_ms: i64 = row.get(4)?;
// First line, trimmed for graph hover legibility — same
// treatment as Contacts mode chunk labels.
let label = content
.lines()
.next()
.unwrap_or("")
.chars()
.take(72)
.collect::<String>();
Ok(GraphNode {
kind: "chunk".into(),
id,
label,
tree_kind: None,
tree_scope: None,
tree_id: None,
level: None,
// parent_summary_id matches a summary node's `id`,
// so the UI draws the chunk→summary edge directly.
// Null for unsealed chunks → orphan node.
parent_id: parent_id.filter(|s| !s.is_empty()),
child_count: None,
time_range_start_ms: Some(time_range_start_ms),
time_range_end_ms: Some(time_range_end_ms),
file_basename: None,
entity_kind: None,
})
})?
.collect::<rusqlite::Result<Vec<_>>>()
.context("collect tree-mode leaf chunk rows")?;
Ok(rows)
})?;
nodes.extend(chunk_nodes);
Ok((nodes, Vec::new()))
}
+112
View File
@@ -720,6 +720,118 @@ fn clear_composio_sync_state_removes_only_target_namespace() {
assert_eq!(other_count, 1);
}
// ── tree-mode graph export (summaries + leaf chunks) ────────────────────
/// Insert a tree row and one summary node under it.
fn insert_tree_summary(cfg: &Config, tree_id: &str, scope: &str, summary_id: &str, level: i64) {
with_connection(cfg, |conn| {
conn.execute(
"INSERT OR IGNORE INTO mem_tree_trees (id, kind, scope, created_at_ms)
VALUES (?1, 'source', ?2, 0)",
params![tree_id, scope],
)?;
conn.execute(
"INSERT INTO mem_tree_summaries (
id, tree_id, tree_kind, level, child_ids_json, content, token_count,
entities_json, topics_json, time_range_start_ms, time_range_end_ms,
score, sealed_at_ms, deleted
) VALUES (?1, ?2, 'source', ?3, '[]', 'summary body', 1, '[]', '[]', 0, 0, 0.0, 0, 0)",
params![summary_id, tree_id, level],
)?;
Ok(())
})
.unwrap();
}
/// Insert a leaf chunk, optionally linked to a parent summary.
fn insert_chunk_with_parent(
cfg: &Config,
id: &str,
parent_summary_id: Option<&str>,
timestamp_ms: i64,
content: &str,
) {
with_connection(cfg, |conn| {
conn.execute(
"INSERT INTO mem_tree_chunks (
id, source_kind, source_id, source_ref, owner, timestamp_ms,
time_range_start_ms, time_range_end_ms, tags_json, content,
token_count, seq_in_source, created_at_ms, lifecycle_status,
content_path, parent_summary_id
) VALUES (?1, 'chat', 'slack:#eng', NULL, 'tester', ?2, ?2, ?2, '[]', ?3, 1, 0, ?2, 'seeded', NULL, ?4)",
params![id, timestamp_ms, content, parent_summary_id],
)?;
Ok(())
})
.unwrap();
}
#[tokio::test]
async fn tree_graph_includes_leaf_chunks_linked_to_their_summary() {
let (_tmp, cfg) = test_config();
insert_tree_summary(&cfg, "tree-1", "slack:#eng", "summary:1:L1-aaa", 1);
insert_chunk_with_parent(
&cfg,
"chunk-sealed",
Some("summary:1:L1-aaa"),
1_700_000_000_000,
"first line of sealed chunk\nmore body",
);
insert_chunk_with_parent(
&cfg,
"chunk-orphan",
None,
1_700_000_000_001,
"orphan chunk body",
);
let resp = graph_export_rpc(&cfg, GraphMode::Tree).await.unwrap().value;
// Before this fix tree mode returned only the summary node; the
// leaves were invisible in the UI while Obsidian showed them all.
assert_eq!(resp.nodes.len(), 3, "summary + both leaf chunks");
let summary = resp.nodes.iter().find(|n| n.kind == "summary").unwrap();
assert_eq!(summary.id, "summary:1:L1-aaa");
let sealed = resp.nodes.iter().find(|n| n.id == "chunk-sealed").unwrap();
assert_eq!(sealed.kind, "chunk");
// A chunk's parent_id == its parent summary's node id, so the UI's
// parent_id edge logic draws the chunk→summary link directly.
assert_eq!(sealed.parent_id.as_deref(), Some("summary:1:L1-aaa"));
// Label is the trimmed first line of the chunk content.
assert_eq!(sealed.label, "first line of sealed chunk");
let orphan = resp.nodes.iter().find(|n| n.id == "chunk-orphan").unwrap();
assert!(
orphan.parent_id.is_none(),
"unsealed chunk has no parent → renders as an orphan node"
);
// Tree mode encodes edges via parent_id; the explicit edges array
// stays empty.
assert!(resp.edges.is_empty());
}
#[tokio::test]
async fn tree_graph_keeps_summaries_first_then_chunks() {
let (_tmp, cfg) = test_config();
insert_tree_summary(&cfg, "tree-1", "slack:#eng", "summary:1:L1-aaa", 1);
insert_chunk_with_parent(
&cfg,
"chunk-1",
Some("summary:1:L1-aaa"),
1_700_000_000_000,
"a chunk",
);
let resp = graph_export_rpc(&cfg, GraphMode::Tree).await.unwrap().value;
// Summaries are emitted ahead of chunks so a budget truncation drops
// chunk tails, never the tree skeleton.
assert_eq!(resp.nodes[0].kind, "summary");
assert!(resp.nodes.iter().any(|n| n.kind == "chunk"));
}
#[tokio::test]
async fn obsidian_status_registered_when_override_config_lists_content_root() {
let (_tmp, cfg) = test_config();
@@ -8,42 +8,42 @@
"collapse-color-groups": false,
"colorGroups": [
{
"query": "file:summary-L1",
"query": "path:L1",
"color": {
"a": 1,
"rgb": 14701138
}
},
{
"query": "file:summary-L2",
"query": "path:L2",
"color": {
"a": 1,
"rgb": 14725458
}
},
{
"query": "file:summary-L3",
"query": "path:L3",
"color": {
"a": 1,
"rgb": 11657298
}
},
{
"query": "file:summary-L4",
"query": "path:L4",
"color": {
"a": 1,
"rgb": 5420768
}
},
{
"query": "file:summary-L5",
"query": "path:L5",
"color": {
"a": 1,
"rgb": 5431504
}
},
{
"query": "file:summary-L6",
"query": "path:L6",
"color": {
"a": 1,
"rgb": 14701261
+5 -5
View File
@@ -5,13 +5,13 @@
//!
//! | Submodule | Source | Notes |
//! | --- | --- | --- |
//! | `vault` | Files dropped into the Obsidian vault by the user | Watch + diff |
//! | `folder` | Files under a user-added folder memory source | Watch + diff |
//! | `harness` | Agent harness turns (memory_archivist's caller side) | Push-based |
//! | `dictation` | Local audio capture transcripts | Push-based |
//!
//! ## Status
//!
//! Scaffold only. Today the vault watch lives in `vault/sync.rs`,
//! harness capture in `agent_experience/`, and dictation in
//! `dictation_hotkeys/`. Each will land here as a [`SyncPipeline`] impl
//! in a follow-up.
//! Scaffold only. Today folder ingestion lives in
//! `memory_sources/readers/folder.rs`, harness capture in
//! `agent_experience/`, and dictation in `dictation_hotkeys/`. Each will
//! land here as a [`SyncPipeline`] impl in a follow-up.
-1
View File
@@ -107,7 +107,6 @@ pub mod tool_timeout;
pub mod tools;
pub mod update;
pub mod util;
pub mod vault;
pub mod voice;
pub mod wallet;
pub mod web3;
@@ -11,7 +11,6 @@ mod read_diff;
mod run_linter;
mod run_tests;
mod update_memory_md;
mod vault_write_markdown;
pub use apply_patch::ApplyPatchTool;
pub use csv_export::CsvExportTool;
@@ -26,4 +25,3 @@ pub use read_diff::ReadDiffTool;
pub use run_linter::RunLinterTool;
pub use run_tests::RunTestsTool;
pub use update_memory_md::UpdateMemoryMdTool;
pub use vault_write_markdown::VaultWriteMarkdownTool;
@@ -1,177 +0,0 @@
//! Tool: vault_write_markdown — approved markdown/wiki writes into user vaults.
use std::sync::Arc;
use async_trait::async_trait;
use serde_json::json;
use crate::openhuman::config::Config;
use crate::openhuman::security::{CommandClass, GateDecision, SecurityPolicy};
use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult};
pub struct VaultWriteMarkdownTool {
config: Arc<Config>,
security: Arc<SecurityPolicy>,
}
impl VaultWriteMarkdownTool {
pub fn new(config: Arc<Config>, security: Arc<SecurityPolicy>) -> Self {
Self { config, security }
}
}
#[async_trait]
impl Tool for VaultWriteMarkdownTool {
fn name(&self) -> &str {
"vault_write_markdown"
}
fn description(&self) -> &str {
"Create or update an approved .md/.markdown wiki note inside a registered knowledge vault. \
Use only after the user has approved writing generated content into that vault."
}
fn parameters_schema(&self) -> serde_json::Value {
json!({
"type": "object",
"required": ["vault_id", "rel_path", "content"],
"properties": {
"vault_id": {
"type": "string",
"description": "Identifier of the registered vault to write into."
},
"rel_path": {
"type": "string",
"description": "Relative path under the vault root. Must end with .md or .markdown."
},
"content": {
"type": "string",
"description": "Markdown content to write."
},
"overwrite": {
"type": "boolean",
"description": "Set true to update an existing markdown file."
}
}
})
}
fn permission_level(&self) -> PermissionLevel {
PermissionLevel::Write
}
fn external_effect_with_args(&self, _args: &serde_json::Value) -> bool {
self.security.gate_decision(CommandClass::Write) == GateDecision::Prompt
}
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
if !self.security.can_act() {
return Ok(ToolResult::error(
"[policy-blocked] Action blocked: autonomy is read-only",
));
}
if self.security.is_rate_limited() {
return Ok(ToolResult::error(
"Rate limit exceeded: too many actions in the last hour",
));
}
let vault_id = args
.get("vault_id")
.and_then(|value| value.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing 'vault_id' parameter"))?;
let rel_path = args
.get("rel_path")
.and_then(|value| value.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing 'rel_path' parameter"))?;
let content = args
.get("content")
.and_then(|value| value.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing 'content' parameter"))?;
let overwrite = args
.get("overwrite")
.and_then(|value| value.as_bool())
.unwrap_or(false);
if !self.security.record_action() {
return Ok(ToolResult::error(
"Rate limit exceeded: action budget exhausted",
));
}
tracing::debug!(
vault_id = %vault_id,
rel_path = %rel_path,
overwrite,
content_bytes = content.len(),
"[vault_write_markdown] execute"
);
match crate::openhuman::vault::ops::vault_write_markdown(
&self.config,
vault_id,
rel_path,
content,
overwrite,
true,
)
.await
{
Ok(outcome) => Ok(ToolResult::json(json!(outcome.value))),
Err(err) => Ok(ToolResult::error(err)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::openhuman::security::AutonomyLevel;
fn security(workspace: std::path::PathBuf, autonomy: AutonomyLevel) -> Arc<SecurityPolicy> {
Arc::new(SecurityPolicy {
autonomy,
workspace_dir: workspace,
..SecurityPolicy::default()
})
}
#[test]
fn vault_write_markdown_tool_schema_requires_core_fields() {
let dir = tempfile::tempdir().unwrap();
let config = Arc::new(Config::default());
let tool = VaultWriteMarkdownTool::new(
config,
security(dir.path().to_path_buf(), AutonomyLevel::Supervised),
);
assert_eq!(tool.name(), "vault_write_markdown");
assert_eq!(tool.permission_level(), PermissionLevel::Write);
assert!(tool.external_effect_with_args(&json!({})));
let schema = tool.parameters_schema();
let required = schema["required"].as_array().unwrap();
assert!(required.iter().any(|value| value == "vault_id"));
assert!(required.iter().any(|value| value == "rel_path"));
assert!(required.iter().any(|value| value == "content"));
}
#[tokio::test]
async fn vault_write_markdown_tool_blocks_read_only_autonomy() {
let dir = tempfile::tempdir().unwrap();
let config = Arc::new(Config::default());
let tool = VaultWriteMarkdownTool::new(
config,
security(dir.path().to_path_buf(), AutonomyLevel::ReadOnly),
);
let result = tool
.execute(json!({
"vault_id": "v-1",
"rel_path": "wiki/a.md",
"content": "# A"
}))
.await
.unwrap();
assert!(result.is_error);
assert!(result.text().contains("read-only"));
}
}
-4
View File
@@ -133,10 +133,6 @@ pub fn all_tools_with_runtime(
shell,
Box::new(FileReadTool::new(security.clone())),
Box::new(FileWriteTool::new(security.clone())),
Box::new(VaultWriteMarkdownTool::new(
config.clone(),
security.clone(),
)),
// Coding-harness baseline tools (issue #1205): file navigation
// + atomic editing primitives. Use these instead of falling
// through to `shell` for grep/find/sed work.
-38
View File
@@ -177,43 +177,6 @@ fn all_tools_includes_spawn_parallel_agents() {
);
}
#[test]
fn all_tools_includes_vault_write_markdown() {
let tmp = TempDir::new().unwrap();
let security = Arc::new(SecurityPolicy::default());
let mem_cfg = MemoryConfig {
backend: "markdown".into(),
..MemoryConfig::default()
};
let mem: Arc<dyn Memory> =
Arc::from(crate::openhuman::memory_store::create_memory(&mem_cfg, tmp.path()).unwrap());
let browser = BrowserConfig {
enabled: false,
allowed_domains: vec![],
session_name: None,
..BrowserConfig::default()
};
let http = crate::openhuman::config::HttpRequestConfig::default();
let cfg = test_config(&tmp);
let tools = all_tools(
Arc::new(cfg.clone()),
&security,
AuditLogger::disabled(),
mem,
&browser,
&http,
tmp.path(),
&HashMap::new(),
&cfg,
);
let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
assert!(
names.contains(&"vault_write_markdown"),
"vault_write_markdown must be registered so agents can write approved markdown into user vaults; got: {names:?}"
);
}
#[test]
fn all_tools_always_registers_curl() {
// Regression guard: `curl` is always registered (gated only by
@@ -419,7 +382,6 @@ fn all_tools_default_registry_contains_expected_baseline_surface() {
"shell",
"file_read",
"file_write",
"vault_write_markdown",
"grep",
"glob",
"list",
+1 -4
View File
@@ -9,10 +9,7 @@ const TOOL_ID_TO_RUST_NAMES: &[(&str, &[&str])] = &[
("install_tool", &["install_tool"]),
("git_operations", &["git_operations"]),
("file_read", &["file_read", "read_diff", "csv_export"]),
(
"file_write",
&["file_write", "update_memory_md", "vault_write_markdown"],
),
("file_write", &["file_write", "update_memory_md"]),
("screenshot", &["screenshot"]),
("image_info", &["image_info"]),
("browser_open", &["browser_open"]),
-95
View File
@@ -1,95 +0,0 @@
# vault
Knowledge vault domain — a NotebookLM-style "folder of files" mirrored into the memory-tree backend. A `Vault` points at a local directory; on sync the module walks that directory, routes supported files to a plain-UTF-8 extractor by extension, and feeds them through the memory ingestion pipeline under a vault-derived namespace. Per-file dedup uses `(rel_path, mtime, content hash)` so re-syncs only touch what actually changed, and vanished files have their memory rows deleted to keep retrieval in sync with disk.
## Responsibilities
- Register / list / fetch / remove user-owned local folders as "vaults" backed by SQLite.
- Walk a vault's root directory, prune built-in noise dirs (`.git`, `node_modules`, `target`, …), apply user include/exclude substring patterns, and filter by supported extension + max file size (5 MiB).
- Ingest new/changed files into the **memory-tree** backend (`mem_tree_chunks` / `mem_tree_ingested_sources`) via the ingest pipeline, keyed by a stable `source_id = vault:{vault_id}:{rel_path}`.
- Two-tier dedup: fast-path mtime skip during discovery, secondary content-hash skip during concurrent ingestion.
- For content updates, delete prior chunks before re-ingest (the pipeline's `already_ingested` gate is content-blind on a stable source_id).
- Delete memory rows for files that vanished since the last sync; record per-file outcome in a ledger.
- Run sync as a background tokio task; expose live progress + final outcome via an in-process state registry, polled by the frontend.
- On vault removal, optionally purge all memory rows for the vault (memory-tree prefix delete + best-effort legacy `clear_namespace`).
- Hide vaults that belong to an incompatible host OS / path shape (cross-machine config sharing safety).
## Key files
| File | Role |
| --- | --- |
| `src/openhuman/vault/mod.rs` | Export-focused module root: docstring, `mod`/`pub mod` decls, re-exports of types + controller-schema pair. |
| `src/openhuman/vault/types.rs` | Serde domain types: `Vault`, `VaultFile`, `VaultFileStatus`, `VaultSyncState`, `VaultSyncStatus`, `VaultSyncReport`. |
| `src/openhuman/vault/ops.rs` | RPC-facing business logic: `vault_create/list/get/files/remove/sync/sync_status`, namespace derivation, background-task spawn with panic guard, memory purge on remove. |
| `src/openhuman/vault/schemas.rs` | Controller schemas + `handle_*` fns delegating to `ops`; `all_controller_schemas` / `all_registered_controllers`. |
| `src/openhuman/vault/store.rs` | SQLite persistence (`vault.db`): vault + per-file ledger tables, migrations, host-OS compatibility filtering. |
| `src/openhuman/vault/sync.rs` | The directory-walk + 4-phase ingest engine (discovery → concurrent ingest → ledger writes → deletions); supported-extension list; memory-tree source_id helper. Inline `sync_tests`. |
| `src/openhuman/vault/state.rs` | Process-global in-memory sync-progress registry (`once_cell::Lazy` + `parking_lot::RwLock`), keyed by `vault_id`. |
| `src/openhuman/vault/tests.rs` | `#[cfg(test)]` module (additional tests beyond the inline `sync.rs` suite). |
## Public surface
Re-exported from `mod.rs`:
- Types: `Vault`, `VaultFile`, `VaultFileStatus`, `VaultSyncReport`, `VaultSyncState`, `VaultSyncStatus`.
- `all_vault_controller_schemas` / `all_vault_registered_controllers` (the controller-registry pair, wired in `src/core/all.rs`).
- `ops` is `pub mod``vault_create`, `vault_list`, `vault_get`, `vault_files`, `vault_remove`, `vault_sync`, `vault_sync_status`.
## RPC / controllers
Namespace `vault` (invoked as `openhuman.vault_<function>`):
| Method | Inputs | Output |
| --- | --- | --- |
| `vault.create` | `name`, `root_path` (absolute dir), `include_globs?`, `exclude_globs?` | `Vault` |
| `vault.list` | — | `Vec<Vault>` (current-host only) |
| `vault.get` | `vault_id` | `Vault` |
| `vault.files` | `vault_id` | `Vec<VaultFile>` (per-file ledger) |
| `vault.remove` | `vault_id`, `purge_memory?` | `{ vault_id, removed, purged, memory_tree_chunks_deleted, purge_error? }` |
| `vault.sync` | `vault_id` | `{ status: "started", vault_id }` (background; rejects if already running) |
| `vault.sync_status` | `vault_id` | `VaultSyncState` (Idle / Running / Completed / Failed) |
`include_globs` / `exclude_globs` are substring matches (case-insensitive), not true globs despite the name.
## Agent tools
None — the module owns no `tools.rs`.
## Events
None — no `bus.rs`; the module publishes/subscribes to no `DomainEvent`s. Sync progress is surfaced via the in-memory `state` registry + `vault.sync_status` polling rather than the event bus.
## Persistence
- **SQLite** at `{workspace_dir}/vault/vault.db` (`store.rs`):
- `vaults` — id, name, root_path, host_os, namespace (UNIQUE), include/exclude globs (JSON), created_at, last_synced_at.
- `vault_files` — per-file ledger keyed by `(vault_id, rel_path)`, FK→`vaults` with `ON DELETE CASCADE`; stores `document_id` (= memory-tree source_id post-#2705), `content_hash`, `mtime_ms`, `bytes`, `ingested_at`, `status`. The dedup ledger.
- Lazy schema init + an additive `host_os` column migration (cached per DB path).
- **Process-global in-memory** (`state.rs`): `VaultSyncState` per vault, retained after completion until the next sync overwrites it. Lost on process restart.
- **Memory-tree** (owned by the `memory` domain, written via the ingest pipeline): chunks under `source_id = vault:{vault_id}:{rel_path}`.
## Dependencies
- `crate::openhuman::config` (`Config`, `config::rpc::load_config_with_timeout`) — workspace dir for the DB, config passed into ingest/delete calls.
- `crate::openhuman::memory::ingest_pipeline``ingest_document` / `IngestResult`: the canonical memory-tree ingest path.
- `crate::openhuman::memory::ops``clear_namespace` (legacy purge on remove) and `doc_delete` (best-effort legacy UnifiedMemory cleanup for pre-#2705 ledger rows).
- `crate::openhuman::memory_store::chunks``delete_chunks_by_source` / `delete_chunks_by_source_prefix` (chunk cleanup on re-ingest, file deletion, and vault purge); `SourceKind::Document`.
- `crate::openhuman::memory_sync::canonicalize::document::DocumentInput` — the document shape handed to the ingest pipeline.
- `crate::core::all` (`ControllerFuture`, `RegisteredController`) + `crate::core::{ControllerSchema, FieldSchema, TypeSchema}` — controller registration plumbing.
- `crate::rpc::RpcOutcome` — RPC return contract.
- External crates: `rusqlite`, `walkdir`, `sha2`, `uuid`, `chrono`, `futures` (`buffer_unordered`), `tokio`, `once_cell`, `parking_lot`.
## Used by
- `src/core/all.rs` — registers vault controllers + schemas into the global controller registry (the only wiring point; vault is controller-only, no CLI/JSON-RPC branches).
- `src/core/observability.rs` (+ tests) — references `vault_create` / `openhuman.vault_create` as an example in path-boundary / autonomy-policy diagnostics, not a runtime dependency.
## Notes / gotchas
- **Memory-tree, not `memory_docs` (#2705).** Sync ingests via the memory-tree pipeline; the pre-#2705 path wrote to legacy `UnifiedMemory` (`memory_docs`), which the UI reported as "synced" but retrieval never saw. The ledger now stores the memory-tree `source_id` (`vault:{id}:{rel_path}`); deletion/purge logic keys off `document_id.starts_with("vault:")` to distinguish post- vs pre-#2705 rows and run the right cleanup.
- **Namespace is a hashed digest, not the raw UUID.** `vault_namespace_for_id` derives an alphabet-only SHA-256 suffix (`vault-<24 a-z chars>`) because memory writes reject namespace/key values that resemble PII / strict identifier patterns.
- **`include_globs`/`exclude_globs` are substring matches**, lowercased, not real glob patterns — the field name is misleading.
- **Background sync + panic guard.** `vault_sync` spawns a tokio task and returns immediately; the work is wrapped in `catch_unwind` so a panic marks state `Failed` instead of leaving it stuck in `Running` (which would reject every future sync until restart). Concurrency is bounded to 4 (`buffer_unordered`).
- **Host-OS gating.** `list_vaults`/`get_vault` hide vaults whose stamped `host_os` (or, for legacy rows, whose `root_path` shape) doesn't match the current machine — prevents surfacing another machine's folders from shared config.
- **Ledger ↔ memory_tree desync is logged loudly.** If ingest returns `already_ingested && chunks_written == 0` after the delete-first guard, the code emits a `warn!` because it means new content never reached retrieval (the exact false-success class #2705 was meant to kill).
- `vault_create` canonicalizes `root_path` (falling back to the trimmed input if canonicalization fails), so the stored id/path may differ from the literal input.
-25
View File
@@ -1,25 +0,0 @@
//! Knowledge vault — folder-of-files ingested into memory (NotebookLM-style).
//!
//! A `Vault` points at a local directory; on `vault.sync` we walk it, route
//! files to extractors by extension, and feed them into the memory pipeline
//! under a vault-derived namespace. Per-file dedup uses (path, mtime, content
//! hash) so re-syncs only touch what changed.
pub mod ops;
mod schemas;
pub(crate) mod state;
mod store;
mod sync;
mod types;
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, VaultSyncState, VaultSyncStatus,
VaultWriteMarkdownReport, VaultWriteState,
};
#[cfg(test)]
mod tests;
-492
View File
@@ -1,492 +0,0 @@
//! RPC-facing operations for the vault domain.
use chrono::Utc;
use futures::FutureExt;
use sha2::{Digest, Sha256};
use std::path::{Component, Path, PathBuf};
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;
use super::store;
use super::sync;
use super::types::{
Vault, VaultFile, VaultSyncState, VaultSyncStatus, VaultWriteMarkdownReport, VaultWriteState,
};
/// Derive a stable memory namespace for a vault without embedding the raw UUID.
///
/// Memory writes reject namespace/key values that resemble PII. Raw UUID hex can
/// occasionally match strict alphanumeric identifier patterns, so vault
/// namespaces use an alphabet-only digest suffix instead.
pub(crate) fn vault_namespace_for_id(id: &str) -> String {
let digest = Sha256::digest(id.as_bytes());
let suffix: String = digest
.iter()
.take(24)
.map(|byte| char::from(b'a' + (byte % 26)))
.collect();
format!("vault-{suffix}")
}
/// Create a new vault pointing at a local folder.
pub async fn vault_create(
config: &Config,
name: &str,
root_path: &str,
include_globs: Vec<String>,
exclude_globs: Vec<String>,
) -> Result<RpcOutcome<Vault>, String> {
let trimmed_name = name.trim();
if trimmed_name.is_empty() {
return Err("vault name must not be empty".to_string());
}
let trimmed_root = root_path.trim();
if trimmed_root.is_empty() {
return Err("root_path must not be empty".to_string());
}
let root = std::path::Path::new(trimmed_root);
if !root.is_absolute() {
return Err(format!("root_path must be absolute: {trimmed_root}"));
}
if !root.is_dir() {
return Err(format!("root_path is not a directory: {trimmed_root}"));
}
let id = Uuid::new_v4().to_string();
log::debug!(
"[vault] create: name={trimmed_name:?} root={trimmed_root:?} id={id} \
include_globs={} exclude_globs={}",
include_globs.len(),
exclude_globs.len(),
);
let namespace = vault_namespace_for_id(&id);
let canonical_root = root
.canonicalize()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|_| trimmed_root.to_string());
let (write_state, write_state_reason) = store::vault_write_state_for_root_path(&canonical_root);
let vault = Vault {
id: id.clone(),
name: trimmed_name.to_string(),
root_path: canonical_root,
host_os: Some(store::current_host_os().to_string()),
namespace,
include_globs,
exclude_globs,
created_at: Utc::now(),
last_synced_at: None,
file_count: 0,
write_state,
write_state_reason,
};
store::insert_vault(config, &vault).map_err(|e| e.to_string())?;
Ok(RpcOutcome::single_log(
vault,
format!("vault created: {id}"),
))
}
pub async fn vault_list(config: &Config) -> Result<RpcOutcome<Vec<Vault>>, String> {
let vaults = store::list_vaults(config).map_err(|e| e.to_string())?;
log::debug!("[vault] list: count={}", vaults.len());
Ok(RpcOutcome::single_log(vaults, "vaults listed"))
}
pub async fn vault_get(config: &Config, id: &str) -> Result<RpcOutcome<Vault>, String> {
let id = id.trim();
if id.is_empty() {
return Err("vault_id must not be empty".to_string());
}
let vault = store::get_vault(config, id)
.map_err(|e| e.to_string())?
.ok_or_else(|| format!("vault not found: {id}"))?;
log::debug!("[vault] get: id={id} files={}", vault.file_count);
Ok(RpcOutcome::single_log(vault, "vault loaded"))
}
/// Write an explicitly approved markdown/wiki artifact into a registered vault.
pub async fn vault_write_markdown(
config: &Config,
id: &str,
rel_path: &str,
content: &str,
overwrite: bool,
approved: bool,
) -> Result<RpcOutcome<VaultWriteMarkdownReport>, String> {
let id = id.trim();
if id.is_empty() {
return Err("vault_id must not be empty".to_string());
}
if !approved {
log::debug!("[vault] write_markdown: rejected missing approval id={id}");
return Err("vault markdown writes require explicit user approval".to_string());
}
let vault = store::get_vault(config, id)
.map_err(|e| e.to_string())?
.ok_or_else(|| format!("vault not found: {id}"))?;
let (write_state, write_reason) = store::vault_write_state_for_root_path(&vault.root_path);
if write_state != VaultWriteState::Writable {
log::debug!(
"[vault] write_markdown: rejected non-writable id={id} state={write_state:?} reason={write_reason:?}"
);
return Err(store::vault_write_state_reason_message(write_reason.as_deref()).to_string());
}
let rel = validate_markdown_rel_path(rel_path)?;
let bytes = content.as_bytes().len() as u64;
let root = std::fs::canonicalize(&vault.root_path)
.map_err(|err| format!("failed to resolve vault folder: {err}"))?;
ensure_existing_ancestors_stay_in_root(&root, &rel)?;
let target = root.join(&rel);
let parent = target
.parent()
.ok_or_else(|| "target path has no parent directory".to_string())?;
std::fs::create_dir_all(parent)
.map_err(|err| format!("failed to create vault note directory: {err}"))?;
let parent_canon = std::fs::canonicalize(parent)
.map_err(|err| format!("failed to resolve vault note directory: {err}"))?;
if !parent_canon.starts_with(&root) {
return Err("vault note path resolves outside the vault folder".to_string());
}
if let Ok(meta) = std::fs::symlink_metadata(&target) {
if meta.file_type().is_symlink() {
return Err("refusing to write through a symlink inside the vault".to_string());
}
}
let created = !target.exists();
if !created && !overwrite {
return Err(
"vault markdown file already exists; set overwrite=true to update it".to_string(),
);
}
log::debug!(
"[vault] write_markdown: writing id={id} rel_path={} bytes={bytes} overwrite={overwrite} created={created}",
rel.display()
);
std::fs::write(&target, content)
.map_err(|err| format!("failed to write vault markdown file: {err}"))?;
Ok(RpcOutcome::single_log(
VaultWriteMarkdownReport {
vault_id: id.to_string(),
rel_path: rel.to_string_lossy().replace('\\', "/"),
bytes_written: bytes,
created,
},
format!("vault markdown written: {id}"),
))
}
pub async fn vault_files(config: &Config, id: &str) -> Result<RpcOutcome<Vec<VaultFile>>, String> {
let id = id.trim();
if id.is_empty() {
return Err("vault_id must not be empty".to_string());
}
store::get_vault(config, id)
.map_err(|e| e.to_string())?
.ok_or_else(|| format!("vault not found: {id}"))?;
let files = store::list_files(config, id).map_err(|e| e.to_string())?;
log::debug!("[vault] files: id={id} count={}", files.len());
Ok(RpcOutcome::single_log(files, "vault files listed"))
}
fn validate_markdown_rel_path(raw: &str) -> Result<PathBuf, String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err("rel_path must not be empty".to_string());
}
let path = Path::new(trimmed);
if path.is_absolute() {
return Err("rel_path must be relative to the vault folder".to_string());
}
let mut clean = PathBuf::new();
for component in path.components() {
match component {
Component::Normal(part) => clean.push(part),
Component::CurDir => {}
Component::ParentDir => {
return Err("rel_path must not contain '..' segments".to_string());
}
Component::RootDir | Component::Prefix(_) => {
return Err("rel_path must stay inside the vault folder".to_string());
}
}
}
if clean.as_os_str().is_empty() {
return Err("rel_path must name a markdown file".to_string());
}
let ext = clean
.extension()
.and_then(|value| value.to_str())
.unwrap_or_default();
if !matches!(ext.to_ascii_lowercase().as_str(), "md" | "markdown") {
return Err("rel_path must end with .md or .markdown".to_string());
}
Ok(clean)
}
fn ensure_existing_ancestors_stay_in_root(root: &Path, rel_path: &Path) -> Result<(), String> {
let Some(parent) = rel_path.parent() else {
return Ok(());
};
let mut current = root.to_path_buf();
for component in parent.components() {
let Component::Normal(part) = component else {
continue;
};
let next = current.join(part);
match std::fs::symlink_metadata(&next) {
Ok(meta) if meta.file_type().is_symlink() => {
let resolved = std::fs::canonicalize(&next)
.map_err(|err| format!("failed to resolve vault note directory: {err}"))?;
if !resolved.starts_with(root) {
return Err(
"vault note directory resolves outside the vault folder".to_string()
);
}
current = resolved;
}
Ok(meta) if meta.is_dir() => {
current = next;
}
Ok(_) => {
return Err("vault note parent path is not a directory".to_string());
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
break;
}
Err(err) => {
return Err(format!("failed to inspect vault note directory: {err}"));
}
}
}
Ok(())
}
pub async fn vault_remove(
config: &Config,
id: &str,
purge_memory: bool,
) -> Result<RpcOutcome<serde_json::Value>, String> {
let id = id.trim();
if id.is_empty() {
return Err("vault_id must not be empty".to_string());
}
let vault = store::get_vault(config, id).map_err(|e| e.to_string())?;
let removed = store::remove_vault(config, id).map_err(|e| e.to_string())?;
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::debug!(
"[vault] remove: id={id} legacy_clear_namespace_failed (best-effort) err={err}"
);
}
purged = true;
}
}
Ok(RpcOutcome::single_log(
serde_json::json!({
"vault_id": id,
"removed": removed,
"purged": purged,
"memory_tree_chunks_deleted": memory_tree_chunks_deleted,
}),
format!("vault removed: {id}"),
))
}
/// 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());
}
let vault = store::get_vault(config, id)
.map_err(|e| e.to_string())?
.ok_or_else(|| format!("vault not found: {id}"))?;
// 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: background task spawned id={id} root={:?}",
vault.root_path,
);
// 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(st, "vault sync status"))
}
-395
View File
@@ -1,395 +0,0 @@
use serde::de::DeserializeOwned;
use serde_json::{Map, Value};
use crate::core::all::{ControllerFuture, RegisteredController};
use crate::core::{ControllerSchema, FieldSchema, TypeSchema};
use crate::openhuman::config::rpc as config_rpc;
use crate::rpc::RpcOutcome;
fn vault_id_input(comment: &'static str) -> FieldSchema {
FieldSchema {
name: "vault_id",
ty: TypeSchema::String,
comment,
required: true,
}
}
pub fn all_controller_schemas() -> Vec<ControllerSchema> {
vec![
schemas("create"),
schemas("list"),
schemas("get"),
schemas("files"),
schemas("write_markdown"),
schemas("remove"),
schemas("sync"),
schemas("sync_status"),
]
}
pub fn all_registered_controllers() -> Vec<RegisteredController> {
vec![
RegisteredController {
schema: schemas("create"),
handler: handle_create,
},
RegisteredController {
schema: schemas("list"),
handler: handle_list,
},
RegisteredController {
schema: schemas("get"),
handler: handle_get,
},
RegisteredController {
schema: schemas("files"),
handler: handle_files,
},
RegisteredController {
schema: schemas("write_markdown"),
handler: handle_write_markdown,
},
RegisteredController {
schema: schemas("remove"),
handler: handle_remove,
},
RegisteredController {
schema: schemas("sync"),
handler: handle_sync,
},
RegisteredController {
schema: schemas("sync_status"),
handler: handle_sync_status,
},
]
}
pub fn schemas(function: &str) -> ControllerSchema {
match function {
"create" => ControllerSchema {
namespace: "vault",
function: "create",
description: "Register a new local folder as a knowledge vault.",
inputs: vec![
FieldSchema {
name: "name",
ty: TypeSchema::String,
comment: "Display name for the vault.",
required: true,
},
FieldSchema {
name: "root_path",
ty: TypeSchema::String,
comment: "Absolute path to the folder on disk.",
required: true,
},
FieldSchema {
name: "include_globs",
ty: TypeSchema::Option(Box::new(TypeSchema::Array(Box::new(
TypeSchema::String,
)))),
comment: "Optional include patterns (substring match).",
required: false,
},
FieldSchema {
name: "exclude_globs",
ty: TypeSchema::Option(Box::new(TypeSchema::Array(Box::new(
TypeSchema::String,
)))),
comment: "Optional exclude patterns (substring match, case-insensitive).",
required: false,
},
],
outputs: vec![FieldSchema {
name: "vault",
ty: TypeSchema::Ref("Vault"),
comment: "The newly created vault.",
required: true,
}],
},
"list" => ControllerSchema {
namespace: "vault",
function: "list",
description: "List all registered vaults.",
inputs: vec![],
outputs: vec![FieldSchema {
name: "vaults",
ty: TypeSchema::Array(Box::new(TypeSchema::Ref("Vault"))),
comment: "All registered vaults.",
required: true,
}],
},
"get" => ControllerSchema {
namespace: "vault",
function: "get",
description: "Fetch one vault by id.",
inputs: vec![vault_id_input("Identifier of the vault to fetch.")],
outputs: vec![FieldSchema {
name: "vault",
ty: TypeSchema::Ref("Vault"),
comment: "The requested vault.",
required: true,
}],
},
"files" => ControllerSchema {
namespace: "vault",
function: "files",
description: "List per-file ledger entries for a vault.",
inputs: vec![vault_id_input("Identifier of the vault.")],
outputs: vec![FieldSchema {
name: "files",
ty: TypeSchema::Array(Box::new(TypeSchema::Ref("VaultFile"))),
comment: "Per-file ledger rows.",
required: true,
}],
},
"write_markdown" => ControllerSchema {
namespace: "vault",
function: "write_markdown",
description: "Write an explicitly approved markdown/wiki file inside a writable vault.",
inputs: vec![
vault_id_input("Identifier of the vault to write into."),
FieldSchema {
name: "rel_path",
ty: TypeSchema::String,
comment: "Relative .md/.markdown path inside the vault.",
required: true,
},
FieldSchema {
name: "content",
ty: TypeSchema::String,
comment: "Markdown content to write.",
required: true,
},
FieldSchema {
name: "overwrite",
ty: TypeSchema::Option(Box::new(TypeSchema::Bool)),
comment: "When true, update an existing markdown file.",
required: false,
},
FieldSchema {
name: "approved",
ty: TypeSchema::Bool,
comment: "Must be true after explicit user approval for arbitrary vault writes.",
required: true,
},
],
outputs: vec![FieldSchema {
name: "report",
ty: TypeSchema::Ref("VaultWriteMarkdownReport"),
comment: "Write result with relative path and byte count.",
required: true,
}],
},
"remove" => ControllerSchema {
namespace: "vault",
function: "remove",
description: "Remove a vault. Optionally purge its memory namespace.",
inputs: vec![
vault_id_input("Identifier of the vault to remove."),
FieldSchema {
name: "purge_memory",
ty: TypeSchema::Option(Box::new(TypeSchema::Bool)),
comment: "When true, also clear the vault's memory namespace.",
required: false,
},
],
outputs: vec![FieldSchema {
name: "result",
ty: TypeSchema::Object {
fields: vec![
FieldSchema {
name: "vault_id",
ty: TypeSchema::String,
comment: "Identifier requested for removal.",
required: true,
},
FieldSchema {
name: "removed",
ty: TypeSchema::Bool,
comment: "True when the vault row was deleted.",
required: true,
},
FieldSchema {
name: "purged",
ty: TypeSchema::Bool,
comment: "True when the memory namespace was also cleared.",
required: true,
},
FieldSchema {
name: "purge_error",
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
comment: "Error detail when purge was requested but failed.",
required: false,
},
],
},
comment: "Removal result payload.",
required: true,
}],
},
"sync" => ControllerSchema {
namespace: "vault",
function: "sync",
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: "state",
ty: TypeSchema::Ref("VaultSyncState"),
comment: "Current sync state (Idle / Running / Completed / Failed).",
required: true,
}],
},
_other => ControllerSchema {
namespace: "vault",
function: "unknown",
description: "Unknown vault controller function.",
inputs: vec![FieldSchema {
name: "function",
ty: TypeSchema::String,
comment: "Unknown function requested for schema lookup.",
required: true,
}],
outputs: vec![FieldSchema {
name: "error",
ty: TypeSchema::String,
comment: "Lookup error details.",
required: true,
}],
},
}
}
fn handle_create(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
let name = read_required::<String>(&params, "name")?;
let root_path = read_required::<String>(&params, "root_path")?;
let include_globs =
read_optional::<Vec<String>>(&params, "include_globs")?.unwrap_or_default();
let exclude_globs =
read_optional::<Vec<String>>(&params, "exclude_globs")?.unwrap_or_default();
to_json(
crate::openhuman::vault::ops::vault_create(
&config,
&name,
&root_path,
include_globs,
exclude_globs,
)
.await?,
)
})
}
fn handle_list(_params: Map<String, Value>) -> ControllerFuture {
Box::pin(async {
let config = config_rpc::load_config_with_timeout().await?;
to_json(crate::openhuman::vault::ops::vault_list(&config).await?)
})
}
fn handle_get(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
let vault_id = read_required::<String>(&params, "vault_id")?;
to_json(crate::openhuman::vault::ops::vault_get(&config, vault_id.trim()).await?)
})
}
fn handle_files(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
let vault_id = read_required::<String>(&params, "vault_id")?;
to_json(crate::openhuman::vault::ops::vault_files(&config, vault_id.trim()).await?)
})
}
fn handle_write_markdown(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
let vault_id = read_required::<String>(&params, "vault_id")?;
let rel_path = read_required::<String>(&params, "rel_path")?;
let content = read_required::<String>(&params, "content")?;
let overwrite = read_optional::<bool>(&params, "overwrite")?.unwrap_or(false);
let approved = read_required::<bool>(&params, "approved")?;
to_json(
crate::openhuman::vault::ops::vault_write_markdown(
&config, &vault_id, &rel_path, &content, overwrite, approved,
)
.await?,
)
})
}
fn handle_remove(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
let vault_id = read_required::<String>(&params, "vault_id")?;
let purge_memory = read_optional::<bool>(&params, "purge_memory")?.unwrap_or(false);
to_json(
crate::openhuman::vault::ops::vault_remove(&config, vault_id.trim(), purge_memory)
.await?,
)
})
}
fn handle_sync(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
let vault_id = read_required::<String>(&params, "vault_id")?;
to_json(crate::openhuman::vault::ops::vault_sync(&config, vault_id.trim()).await?)
})
}
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)
.cloned()
.ok_or_else(|| format!("missing required param '{key}'"))?;
serde_json::from_value(value).map_err(|e| format!("invalid '{key}': {e}"))
}
fn read_optional<T: DeserializeOwned>(
params: &Map<String, Value>,
key: &str,
) -> Result<Option<T>, String> {
match params.get(key) {
None | Some(Value::Null) => Ok(None),
Some(value) => serde_json::from_value(value.clone())
.map(Some)
.map_err(|e| format!("invalid '{key}': {e}")),
}
}
fn to_json<T: serde::Serialize>(outcome: RpcOutcome<T>) -> Result<Value, String> {
outcome.into_cli_compatible_json()
}
-80
View File
@@ -1,80 +0,0 @@
//! 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"
);
}
}
-404
View File
@@ -1,404 +0,0 @@
use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::{Mutex, OnceLock};
use anyhow::{anyhow, Context, Result};
use chrono::{DateTime, Utc};
use rusqlite::{params, Connection, OptionalExtension};
use crate::openhuman::config::Config;
use super::types::{Vault, VaultFile, VaultFileStatus, VaultWriteState};
static MIGRATED_VAULT_DBS: OnceLock<Mutex<HashSet<PathBuf>>> = OnceLock::new();
pub(crate) const VAULT_WRITE_REASON_EMPTY_PATH: &str = "empty_path";
pub(crate) const VAULT_WRITE_REASON_UNAVAILABLE: &str = "unavailable";
pub(crate) const VAULT_WRITE_REASON_NOT_DIRECTORY: &str = "not_directory";
pub(crate) const VAULT_WRITE_REASON_READ_ONLY: &str = "read_only";
pub(crate) const VAULT_WRITE_REASON_WRITABLE: &str = "writable";
pub(crate) fn with_connection<T>(
config: &Config,
f: impl FnOnce(&Connection) -> Result<T>,
) -> Result<T> {
let db_path = config.workspace_dir.join("vault").join("vault.db");
if let Some(parent) = db_path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("Failed to create vault directory: {}", parent.display()))?;
}
let conn = Connection::open(&db_path)
.with_context(|| format!("Failed to open vault DB: {}", db_path.display()))?;
conn.execute_batch(
"PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS vaults (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
root_path TEXT NOT NULL,
host_os TEXT,
namespace TEXT NOT NULL UNIQUE,
include_globs TEXT NOT NULL DEFAULT '[]',
exclude_globs TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL,
last_synced_at TEXT
);
CREATE TABLE IF NOT EXISTS vault_files (
vault_id TEXT NOT NULL,
rel_path TEXT NOT NULL,
document_id TEXT NOT NULL,
content_hash TEXT NOT NULL,
mtime_ms INTEGER NOT NULL,
bytes INTEGER NOT NULL,
ingested_at TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'ok',
PRIMARY KEY (vault_id, rel_path),
FOREIGN KEY (vault_id) REFERENCES vaults(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_vault_files_vault ON vault_files(vault_id);",
)
.context("Failed to initialize vault schema")?;
let migrated = MIGRATED_VAULT_DBS.get_or_init(|| Mutex::new(HashSet::new()));
let mut migrated_paths = migrated
.lock()
.map_err(|_| anyhow!("Failed to lock vault migration cache"))?;
if !migrated_paths.contains(&db_path) {
ensure_host_os_column(&conn).context("Failed to migrate vault schema")?;
migrated_paths.insert(db_path.clone());
}
f(&conn)
}
pub fn insert_vault(config: &Config, vault: &Vault) -> Result<()> {
insert_vault_inner(config, vault, true)
}
#[cfg(test)]
pub(crate) fn insert_vault_preserving_host_for_tests(config: &Config, vault: &Vault) -> Result<()> {
insert_vault_inner(config, vault, false)
}
fn insert_vault_inner(config: &Config, vault: &Vault, stamp_current_host: bool) -> Result<()> {
with_connection(config, |conn| {
let host_os = normalized_host_os(vault.host_os.as_deref()).or_else(|| {
if stamp_current_host {
Some(current_host_os())
} else {
None
}
});
conn.execute(
"INSERT INTO vaults (id, name, root_path, host_os, namespace, include_globs, exclude_globs, created_at, last_synced_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
params![
vault.id,
vault.name,
vault.root_path,
host_os,
vault.namespace,
serde_json::to_string(&vault.include_globs)?,
serde_json::to_string(&vault.exclude_globs)?,
vault.created_at.to_rfc3339(),
vault.last_synced_at.map(|t| t.to_rfc3339()),
],
)
.context("Failed to insert vault")?;
Ok(())
})
}
pub fn list_vaults(config: &Config) -> Result<Vec<Vault>> {
with_connection(config, |conn| {
let mut stmt = conn.prepare(
"SELECT v.id, v.name, v.root_path, v.host_os, v.namespace, v.include_globs, v.exclude_globs,
v.created_at, v.last_synced_at,
(SELECT COUNT(*) FROM vault_files vf WHERE vf.vault_id = v.id AND vf.status = 'ok')
FROM vaults v
ORDER BY v.created_at DESC",
)?;
let rows = stmt.query_map([], row_to_vault)?;
let mut out = Vec::new();
for row in rows {
let vault = row?;
if vault_belongs_to_current_host(&vault) {
out.push(vault);
} else {
log::debug!(
"[vault] hiding incompatible vault id={} host_os={:?}",
vault.id,
vault.host_os
);
}
}
Ok(out)
})
}
pub fn get_vault(config: &Config, id: &str) -> Result<Option<Vault>> {
with_connection(config, |conn| {
let vault = conn
.query_row(
"SELECT v.id, v.name, v.root_path, v.host_os, v.namespace, v.include_globs, v.exclude_globs,
v.created_at, v.last_synced_at,
(SELECT COUNT(*) FROM vault_files vf WHERE vf.vault_id = v.id AND vf.status = 'ok')
FROM vaults v WHERE v.id = ?1",
params![id],
row_to_vault,
)
.optional()
.context("Failed to read vault")?;
Ok(vault.filter(vault_belongs_to_current_host))
})
}
pub fn remove_vault(config: &Config, id: &str) -> Result<bool> {
with_connection(config, |conn| {
let n = conn
.execute("DELETE FROM vaults WHERE id = ?1", params![id])
.context("Failed to delete vault")?;
Ok(n > 0)
})
}
pub fn touch_last_synced(config: &Config, id: &str, when: DateTime<Utc>) -> Result<()> {
with_connection(config, |conn| {
conn.execute(
"UPDATE vaults SET last_synced_at = ?2 WHERE id = ?1",
params![id, when.to_rfc3339()],
)?;
Ok(())
})
}
pub fn list_files(config: &Config, vault_id: &str) -> Result<Vec<VaultFile>> {
with_connection(config, |conn| {
let mut stmt = conn.prepare(
"SELECT vault_id, rel_path, document_id, content_hash, mtime_ms, bytes, ingested_at, status
FROM vault_files WHERE vault_id = ?1 ORDER BY rel_path",
)?;
let rows = stmt.query_map(params![vault_id], row_to_file)?;
let mut out = Vec::new();
for row in rows {
out.push(row?);
}
Ok(out)
})
}
pub fn upsert_file(config: &Config, file: &VaultFile) -> Result<()> {
with_connection(config, |conn| {
conn.execute(
"INSERT INTO vault_files (vault_id, rel_path, document_id, content_hash, mtime_ms, bytes, ingested_at, status)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
ON CONFLICT(vault_id, rel_path) DO UPDATE SET
document_id = excluded.document_id,
content_hash = excluded.content_hash,
mtime_ms = excluded.mtime_ms,
bytes = excluded.bytes,
ingested_at = excluded.ingested_at,
status = excluded.status",
params![
file.vault_id,
file.rel_path,
file.document_id,
file.content_hash,
file.mtime_ms,
file.bytes as i64,
file.ingested_at.to_rfc3339(),
file.status.as_str(),
],
)?;
Ok(())
})
}
pub fn delete_file(config: &Config, vault_id: &str, rel_path: &str) -> Result<()> {
with_connection(config, |conn| {
conn.execute(
"DELETE FROM vault_files WHERE vault_id = ?1 AND rel_path = ?2",
params![vault_id, rel_path],
)?;
Ok(())
})
}
fn row_to_vault(row: &rusqlite::Row<'_>) -> rusqlite::Result<Vault> {
let include_raw: String = row.get(5)?;
let exclude_raw: String = row.get(6)?;
let created_raw: String = row.get(7)?;
let last_raw: Option<String> = row.get(8)?;
let file_count: i64 = row.get(9)?;
let root_path: String = row.get(2)?;
let (write_state, write_state_reason) = vault_write_state_for_root_path(&root_path);
Ok(Vault {
id: row.get(0)?,
name: row.get(1)?,
root_path,
host_os: row.get(3)?,
namespace: row.get(4)?,
include_globs: serde_json::from_str(&include_raw).unwrap_or_default(),
exclude_globs: serde_json::from_str(&exclude_raw).unwrap_or_default(),
created_at: parse_dt(&created_raw),
last_synced_at: last_raw.as_deref().map(parse_dt),
file_count: file_count.max(0) as u64,
write_state,
write_state_reason,
})
}
fn row_to_file(row: &rusqlite::Row<'_>) -> rusqlite::Result<VaultFile> {
let ingested_raw: String = row.get(6)?;
let status_raw: String = row.get(7)?;
let bytes: i64 = row.get(5)?;
Ok(VaultFile {
vault_id: row.get(0)?,
rel_path: row.get(1)?,
document_id: row.get(2)?,
content_hash: row.get(3)?,
mtime_ms: row.get(4)?,
bytes: bytes.max(0) as u64,
ingested_at: parse_dt(&ingested_raw),
status: VaultFileStatus::parse(&status_raw),
})
}
fn parse_dt(raw: &str) -> DateTime<Utc> {
DateTime::parse_from_rfc3339(raw)
.map(|t| t.with_timezone(&Utc))
.unwrap_or_else(|_| Utc::now())
}
fn ensure_host_os_column(conn: &Connection) -> Result<()> {
let mut stmt = conn.prepare("PRAGMA table_info(vaults)")?;
let columns = stmt.query_map([], |row| row.get::<_, String>(1))?;
let mut has_host_os = false;
for column in columns {
if column?.eq_ignore_ascii_case("host_os") {
has_host_os = true;
break;
}
}
if !has_host_os {
conn.execute("ALTER TABLE vaults ADD COLUMN host_os TEXT", [])?;
}
Ok(())
}
pub(crate) fn current_host_os() -> &'static str {
std::env::consts::OS
}
pub(crate) fn vault_write_state_for_root_path(
root_path: &str,
) -> (VaultWriteState, Option<String>) {
let trimmed = root_path.trim();
if trimmed.is_empty() {
return (
VaultWriteState::Unavailable,
Some(VAULT_WRITE_REASON_EMPTY_PATH.to_string()),
);
}
let path = std::path::Path::new(trimmed);
let metadata = match std::fs::metadata(path) {
Ok(metadata) => metadata,
Err(_) => {
return (
VaultWriteState::Unavailable,
Some(VAULT_WRITE_REASON_UNAVAILABLE.to_string()),
)
}
};
if !metadata.is_dir() {
return (
VaultWriteState::Unavailable,
Some(VAULT_WRITE_REASON_NOT_DIRECTORY.to_string()),
);
}
if metadata.permissions().readonly() {
return (
VaultWriteState::ReadOnly,
Some(VAULT_WRITE_REASON_READ_ONLY.to_string()),
);
}
(
VaultWriteState::Writable,
Some(VAULT_WRITE_REASON_WRITABLE.to_string()),
)
}
pub(crate) fn vault_write_state_reason_message(reason_code: Option<&str>) -> &'static str {
match reason_code {
Some(VAULT_WRITE_REASON_EMPTY_PATH) => "Vault folder path is empty.",
Some(VAULT_WRITE_REASON_UNAVAILABLE) => "Vault folder is not available on this device.",
Some(VAULT_WRITE_REASON_NOT_DIRECTORY) => "Vault path is not a directory.",
Some(VAULT_WRITE_REASON_READ_ONLY) => "Vault folder is read-only on this device.",
Some(VAULT_WRITE_REASON_WRITABLE) => {
"Approved markdown/wiki writes can be saved in this vault."
}
_ => "Vault write state is unknown.",
}
}
pub(crate) fn path_looks_compatible_with_host_os(raw_path: &str, host_os: &str) -> bool {
let path = raw_path.trim();
if path.is_empty() {
return false;
}
if is_windows_host_os(host_os) {
return looks_like_windows_absolute_path(path);
}
looks_like_unix_absolute_path(path)
}
fn vault_belongs_to_current_host(vault: &Vault) -> bool {
let current = current_host_os();
let Some(host_os) = normalized_host_os(vault.host_os.as_deref()) else {
return path_looks_compatible_with_host_os(&vault.root_path, current);
};
host_os.eq_ignore_ascii_case(current)
&& path_looks_compatible_with_host_os(&vault.root_path, current)
}
fn normalized_host_os(raw: Option<&str>) -> Option<&str> {
raw.map(str::trim).filter(|host_os| !host_os.is_empty())
}
fn is_windows_host_os(host_os: &str) -> bool {
host_os.eq_ignore_ascii_case("windows") || host_os.eq_ignore_ascii_case("win32")
}
fn looks_like_windows_absolute_path(path: &str) -> bool {
looks_like_windows_drive_path(path) || looks_like_windows_unc_path(path)
}
fn looks_like_windows_drive_path(path: &str) -> bool {
let bytes = path.as_bytes();
bytes.len() >= 3
&& bytes[0].is_ascii_alphabetic()
&& bytes[1] == b':'
&& matches!(bytes[2], b'\\' | b'/')
}
/// Only backslash-style UNC (`\\server\share`). Forward-slash `//…` is
/// POSIX-legal and must not be classified as Windows.
fn looks_like_windows_unc_path(path: &str) -> bool {
let bytes = path.as_bytes();
bytes.len() >= 3 && bytes[0] == b'\\' && bytes[1] == b'\\' && !matches!(bytes[2], b'\\' | b'/')
}
fn looks_like_unix_absolute_path(path: &str) -> bool {
path.starts_with('/')
&& !looks_like_windows_drive_path(path)
&& !looks_like_windows_unc_path(path)
}
File diff suppressed because it is too large Load Diff
-643
View File
@@ -1,643 +0,0 @@
//! Unit tests for the vault domain. Hits a real SQLite db in a tempdir,
//! but skips memory ingestion (covered in higher-level integration tests).
use std::path::PathBuf;
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, VaultSyncState, VaultSyncStatus, VaultWriteState,
};
fn make_config(tmp: &TempDir) -> Config {
let mut config = Config::default();
config.workspace_dir = tmp.path().to_path_buf();
config
}
fn sample_vault(root: PathBuf) -> Vault {
Vault {
id: "vault-test-1".to_string(),
name: "Test".to_string(),
root_path: root.to_string_lossy().to_string(),
host_os: None,
namespace: "vault:vault-test-1".to_string(),
include_globs: vec![],
exclude_globs: vec![],
created_at: chrono::Utc::now(),
last_synced_at: None,
file_count: 0,
write_state: VaultWriteState::Writable,
write_state_reason: None,
}
}
fn incompatible_path_for_current_host() -> &'static str {
if cfg!(windows) {
"/home/leigh/OHvault"
} else {
r"C:\Users\leigh\OHvault"
}
}
#[test]
fn path_compatibility_rejects_cross_platform_absolute_paths() {
assert!(store::path_looks_compatible_with_host_os(
r"C:\Users\leigh\OHvault",
"windows"
));
assert!(store::path_looks_compatible_with_host_os(
r"\\server\share\OHvault",
"windows"
));
// Forward-slash `//…` is POSIX-legal, not Windows UNC.
assert!(!store::path_looks_compatible_with_host_os(
"//server/share/OHvault",
"windows"
));
assert!(!store::path_looks_compatible_with_host_os(
"/home/leigh/OHvault",
"windows"
));
assert!(store::path_looks_compatible_with_host_os(
"/home/leigh/OHvault",
"linux"
));
assert!(store::path_looks_compatible_with_host_os(
"/Users/leigh/OHvault",
"macos"
));
assert!(!store::path_looks_compatible_with_host_os(
r"C:\Users\leigh\OHvault",
"linux"
));
assert!(!store::path_looks_compatible_with_host_os(
r"\\server\share\OHvault",
"macos"
));
// Forward-slash `//…` is POSIX-legal — compatible with Unix hosts.
assert!(store::path_looks_compatible_with_host_os(
"//server/share/OHvault",
"macos"
));
assert!(store::path_looks_compatible_with_host_os(
"//server/share/OHvault",
"linux"
));
}
#[test]
fn store_stamps_new_vaults_with_current_host_os() {
let tmp = TempDir::new().unwrap();
let config = make_config(&tmp);
let vault = sample_vault(tmp.path().to_path_buf());
store::insert_vault(&config, &vault).unwrap();
let listed = store::list_vaults(&config).unwrap();
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].host_os.as_deref(), Some(std::env::consts::OS));
assert_eq!(listed[0].write_state, VaultWriteState::Writable);
assert_eq!(
listed[0].write_state_reason.as_deref(),
Some(store::VAULT_WRITE_REASON_WRITABLE)
);
}
#[test]
fn store_marks_missing_vault_folder_unavailable_instead_of_hiding_it() {
let tmp = TempDir::new().unwrap();
let config = make_config(&tmp);
let vault_root = tmp.path().join("vault-root");
std::fs::create_dir_all(&vault_root).unwrap();
let vault = sample_vault(vault_root.clone());
store::insert_vault(&config, &vault).unwrap();
std::fs::remove_dir_all(&vault_root).unwrap();
let listed = store::list_vaults(&config).unwrap();
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].write_state, VaultWriteState::Unavailable);
assert_eq!(
listed[0].write_state_reason.as_deref(),
Some(store::VAULT_WRITE_REASON_UNAVAILABLE)
);
}
#[test]
fn store_filters_legacy_vaults_whose_path_belongs_to_another_host_family() {
let tmp = TempDir::new().unwrap();
let config = make_config(&tmp);
let mut vault = sample_vault(PathBuf::from(incompatible_path_for_current_host()));
vault.host_os = None;
store::insert_vault_preserving_host_for_tests(&config, &vault).unwrap();
assert!(store::list_vaults(&config).unwrap().is_empty());
assert!(store::get_vault(&config, &vault.id).unwrap().is_none());
}
#[test]
fn store_filters_vaults_created_on_a_different_host_os() {
let tmp = TempDir::new().unwrap();
let config = make_config(&tmp);
let mut vault = sample_vault(tmp.path().to_path_buf());
vault.host_os = Some(if cfg!(windows) { "linux" } else { "windows" }.to_string());
store::insert_vault_preserving_host_for_tests(&config, &vault).unwrap();
assert!(store::list_vaults(&config).unwrap().is_empty());
assert!(store::get_vault(&config, &vault.id).unwrap().is_none());
}
#[test]
fn supported_extension_accepts_md_and_code() {
assert!(supported_extension("md"));
assert!(supported_extension("MD"));
assert!(supported_extension("rs"));
assert!(supported_extension("tsx"));
assert!(!supported_extension("png"));
assert!(!supported_extension("zip"));
assert!(!supported_extension(""));
}
#[test]
fn store_insert_get_list_remove_roundtrip() {
let tmp = TempDir::new().unwrap();
let config = make_config(&tmp);
let vault = sample_vault(tmp.path().to_path_buf());
store::insert_vault(&config, &vault).unwrap();
let listed = store::list_vaults(&config).unwrap();
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].id, vault.id);
assert_eq!(listed[0].namespace, vault.namespace);
assert_eq!(listed[0].file_count, 0);
let fetched = store::get_vault(&config, &vault.id).unwrap();
assert!(fetched.is_some());
assert_eq!(fetched.unwrap().name, "Test");
let removed = store::remove_vault(&config, &vault.id).unwrap();
assert!(removed);
assert!(store::list_vaults(&config).unwrap().is_empty());
}
#[test]
fn store_files_upsert_and_delete() {
let tmp = TempDir::new().unwrap();
let config = make_config(&tmp);
let vault = sample_vault(tmp.path().to_path_buf());
store::insert_vault(&config, &vault).unwrap();
let file = VaultFile {
vault_id: vault.id.clone(),
rel_path: "notes/one.md".to_string(),
document_id: "doc-1".to_string(),
content_hash: "h1".to_string(),
mtime_ms: 100,
bytes: 42,
ingested_at: chrono::Utc::now(),
status: VaultFileStatus::Ok,
};
store::upsert_file(&config, &file).unwrap();
let listed = store::list_files(&config, &vault.id).unwrap();
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].document_id, "doc-1");
// Re-upsert with same key should update, not duplicate.
let mut updated = file.clone();
updated.content_hash = "h2".to_string();
updated.mtime_ms = 200;
store::upsert_file(&config, &updated).unwrap();
let listed = store::list_files(&config, &vault.id).unwrap();
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].content_hash, "h2");
assert_eq!(listed[0].mtime_ms, 200);
// File count on vault list should reflect 1 OK row.
let vaults = store::list_vaults(&config).unwrap();
assert_eq!(vaults[0].file_count, 1);
store::delete_file(&config, &vault.id, "notes/one.md").unwrap();
assert!(store::list_files(&config, &vault.id).unwrap().is_empty());
}
#[test]
fn remove_vault_cascades_files() {
let tmp = TempDir::new().unwrap();
let config = make_config(&tmp);
let vault = sample_vault(tmp.path().to_path_buf());
store::insert_vault(&config, &vault).unwrap();
let file = VaultFile {
vault_id: vault.id.clone(),
rel_path: "a.md".to_string(),
document_id: "doc-a".to_string(),
content_hash: "h".to_string(),
mtime_ms: 1,
bytes: 1,
ingested_at: chrono::Utc::now(),
status: VaultFileStatus::Ok,
};
store::upsert_file(&config, &file).unwrap();
store::remove_vault(&config, &vault.id).unwrap();
// 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_create_returns_current_host_os() {
let tmp = TempDir::new().unwrap();
let config = make_config(&tmp);
let outcome = ops::vault_create(
&config,
"Test",
tmp.path().to_str().unwrap(),
vec![],
vec![],
)
.await
.unwrap();
assert_eq!(outcome.value.host_os.as_deref(), Some(std::env::consts::OS));
assert_eq!(outcome.value.write_state, VaultWriteState::Writable);
assert_eq!(
outcome.value.write_state_reason.as_deref(),
Some(store::VAULT_WRITE_REASON_WRITABLE)
);
}
#[tokio::test]
async fn vault_create_uses_pii_safe_memory_namespace() {
let tmp = TempDir::new().unwrap();
let config = make_config(&tmp);
let outcome = ops::vault_create(
&config,
"Test",
tmp.path().to_str().unwrap(),
vec![],
vec![],
)
.await
.unwrap();
let namespace = &outcome.value.namespace;
assert!(namespace.starts_with("vault-"));
assert!(!namespace.contains(&outcome.value.id));
assert!(!crate::openhuman::memory_store::safety::has_likely_secret(
namespace
));
assert!(!crate::openhuman::memory_store::safety::pii::has_likely_pii(namespace));
}
#[test]
fn vault_namespace_derivation_does_not_embed_pii_like_ids() {
let namespace = ops::vault_namespace_for_id("VECJ880326XK4");
assert!(namespace.starts_with("vault-"));
assert!(!namespace.contains("VECJ880326XK4"));
assert!(!crate::openhuman::memory_store::safety::has_likely_secret(
&namespace
));
assert!(!crate::openhuman::memory_store::safety::pii::has_likely_pii(&namespace));
}
#[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_write_markdown_requires_explicit_approval() {
let tmp = TempDir::new().unwrap();
let config = make_config(&tmp);
let vault = ops::vault_create(
&config,
"Test",
tmp.path().to_str().unwrap(),
vec![],
vec![],
)
.await
.unwrap()
.value;
let err = ops::vault_write_markdown(
&config,
&vault.id,
"wiki/summary.md",
"# Summary\n",
false,
false,
)
.await
.unwrap_err();
assert!(err.contains("explicit user approval"));
}
#[tokio::test]
async fn vault_write_markdown_creates_and_updates_relative_markdown() {
let tmp = TempDir::new().unwrap();
let config = make_config(&tmp);
let vault_root = tmp.path().join("vault-root");
std::fs::create_dir_all(&vault_root).unwrap();
let vault = ops::vault_create(
&config,
"Test",
vault_root.to_str().unwrap(),
vec![],
vec![],
)
.await
.unwrap()
.value;
let first = ops::vault_write_markdown(
&config,
&vault.id,
"wiki/summary.md",
"# Summary\n\nInitial.",
false,
true,
)
.await
.unwrap()
.value;
assert!(first.created);
assert_eq!(first.rel_path, "wiki/summary.md");
assert_eq!(first.bytes_written, "# Summary\n\nInitial.".len() as u64);
assert_eq!(
std::fs::read_to_string(vault_root.join("wiki/summary.md")).unwrap(),
"# Summary\n\nInitial."
);
let duplicate = ops::vault_write_markdown(
&config,
&vault.id,
"wiki/summary.md",
"# Summary\n\nUpdated.",
false,
true,
)
.await
.unwrap_err();
assert!(duplicate.contains("already exists"));
let updated = ops::vault_write_markdown(
&config,
&vault.id,
"wiki/summary.md",
"# Summary\n\nUpdated.",
true,
true,
)
.await
.unwrap()
.value;
assert!(!updated.created);
assert_eq!(
std::fs::read_to_string(vault_root.join("wiki/summary.md")).unwrap(),
"# Summary\n\nUpdated."
);
}
#[tokio::test]
async fn vault_write_markdown_rejects_escape_paths_and_non_markdown() {
let tmp = TempDir::new().unwrap();
let config = make_config(&tmp);
let vault = ops::vault_create(
&config,
"Test",
tmp.path().to_str().unwrap(),
vec![],
vec![],
)
.await
.unwrap()
.value;
let traversal = ops::vault_write_markdown(&config, &vault.id, "../x.md", "x", false, true)
.await
.unwrap_err();
assert!(traversal.contains(".."));
let non_markdown =
ops::vault_write_markdown(&config, &vault.id, "notes/out.txt", "x", false, true)
.await
.unwrap_err();
assert!(non_markdown.contains(".md"));
}
#[cfg(unix)]
#[tokio::test]
async fn vault_write_markdown_rejects_symlink_escape() {
use std::os::unix::fs::symlink;
let tmp = TempDir::new().unwrap();
let config = make_config(&tmp);
let vault_root = tmp.path().join("vault-root");
let outside = tmp.path().join("outside");
std::fs::create_dir_all(&vault_root).unwrap();
std::fs::create_dir_all(&outside).unwrap();
symlink(&outside, vault_root.join("linked")).unwrap();
let vault = ops::vault_create(
&config,
"Test",
vault_root.to_str().unwrap(),
vec![],
vec![],
)
.await
.unwrap()
.value;
let err = ops::vault_write_markdown(&config, &vault.id, "linked/escape.md", "x", false, true)
.await
.unwrap_err();
assert!(err.contains("outside the vault"));
assert!(!outside.join("escape.md").exists());
}
#[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);
}
-138
View File
@@ -1,138 +0,0 @@
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 {
pub id: String,
pub name: String,
pub root_path: String,
#[serde(default)]
pub host_os: Option<String>,
pub namespace: String,
pub include_globs: Vec<String>,
pub exclude_globs: Vec<String>,
pub created_at: DateTime<Utc>,
pub last_synced_at: Option<DateTime<Utc>>,
pub file_count: u64,
#[serde(default)]
pub write_state: VaultWriteState,
#[serde(default)]
pub write_state_reason: Option<String>,
}
/// Whether OpenHuman can write approved markdown artifacts back into a vault.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum VaultWriteState {
Writable,
ReadOnly,
#[default]
Unavailable,
}
/// Per-file ledger entry used for dedup on re-sync.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct VaultFile {
pub vault_id: String,
pub rel_path: String,
pub document_id: String,
pub content_hash: String,
pub mtime_ms: i64,
pub bytes: u64,
pub ingested_at: DateTime<Utc>,
pub status: VaultFileStatus,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum VaultFileStatus {
Ok,
Skipped,
Failed,
}
impl VaultFileStatus {
pub(crate) fn as_str(&self) -> &'static str {
match self {
Self::Ok => "ok",
Self::Skipped => "skipped",
Self::Failed => "failed",
}
}
pub(crate) fn parse(raw: &str) -> Self {
match raw {
"skipped" => Self::Skipped,
"failed" => Self::Failed,
_ => Self::Ok,
}
}
}
/// Summary returned from `vault.sync`.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct VaultSyncReport {
pub vault_id: String,
pub scanned: u64,
pub ingested: u64,
pub unchanged: u64,
pub removed: u64,
pub failed: u64,
pub skipped_unsupported: u64,
pub duration_ms: i64,
pub errors: Vec<String>,
}
/// Result returned after writing an approved markdown/wiki artifact into a vault.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct VaultWriteMarkdownReport {
pub vault_id: String,
pub rel_path: String,
pub bytes_written: u64,
pub created: bool,
}