Initial implementation of vault-os

Complete implementation across all 13 phases:

- vault-core: types, YAML frontmatter parsing, entity classification,
  filesystem ops, config, prompt composition, validation, search
- vault-watch: filesystem watcher with daemon write filtering, event
  classification
- vault-scheduler: cron engine, process executor, task runner with
  retry logic and concurrency limiting
- vault-api: Axum REST API (15 route modules), WebSocket with broadcast,
  AI assistant proxy, validation, templates
- Dashboard: React + TypeScript + Tailwind v4 with kanban, CodeMirror
  editor, dynamic view system, AI chat sidebar
- Nix flake with dev shell and NixOS module
- Graceful shutdown, inotify overflow recovery, tracing instrumentation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Harald Hoyer 2026-03-03 01:21:17 +01:00
commit f820a72b04
123 changed files with 18288 additions and 0 deletions

View file

@ -0,0 +1,11 @@
[package]
name = "vault-watch"
version.workspace = true
edition.workspace = true
[dependencies]
vault-core.workspace = true
notify.workspace = true
tokio.workspace = true
tracing.workspace = true
thiserror.workspace = true

View file

@ -0,0 +1,214 @@
use crate::events::VaultEvent;
use notify::event::{CreateKind, ModifyKind, RemoveKind, RenameMode};
use notify::EventKind;
use std::path::{Path, PathBuf};
use vault_core::entity::{classify_path, EntityKind};
/// Classify a raw notify event into typed VaultEvents.
pub fn classify(
event: &notify::Event,
vault_root: &Path,
) -> Vec<VaultEvent> {
let mut vault_events = Vec::new();
for path in &event.paths {
// Skip non-.md files
if path.extension().is_none_or(|e| e != "md") {
continue;
}
// Skip dotfiles and temp files
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
if name.starts_with('.') || name.starts_with('~') || name.ends_with(".tmp") {
continue;
}
}
let relative = match path.strip_prefix(vault_root) {
Ok(r) => r,
Err(_) => continue,
};
// Skip .vault/ internal files
if relative.starts_with(".vault") {
continue;
}
let kind = classify_path(relative);
match event.kind {
EventKind::Create(CreateKind::File) | EventKind::Create(CreateKind::Any) => {
vault_events.push(make_created(kind, path.clone()));
}
EventKind::Modify(ModifyKind::Data(_))
| EventKind::Modify(ModifyKind::Any)
| EventKind::Modify(ModifyKind::Metadata(_)) => {
vault_events.push(make_modified(kind, path.clone()));
}
EventKind::Remove(RemoveKind::File) | EventKind::Remove(RemoveKind::Any) => {
vault_events.push(make_deleted(kind, path.clone()));
}
_ => {}
}
}
// Handle renames (two paths: from, to)
if matches!(event.kind, EventKind::Modify(ModifyKind::Name(RenameMode::Both)))
&& event.paths.len() == 2
{
let from = &event.paths[0];
let to = &event.paths[1];
if to.extension().is_some_and(|e| e == "md") {
if let Ok(rel_to) = to.strip_prefix(vault_root) {
let kind_to = classify_path(rel_to);
let moved = make_moved(kind_to, from.clone(), to.clone());
// Replace any Created/Deleted pair we may have emitted above
vault_events.clear();
vault_events.push(moved);
}
}
}
vault_events
}
fn make_created(kind: EntityKind, path: PathBuf) -> VaultEvent {
match kind {
EntityKind::Agent => VaultEvent::AgentCreated(path),
EntityKind::Skill => VaultEvent::SkillCreated(path),
EntityKind::CronActive | EntityKind::CronPaused | EntityKind::CronTemplate => {
VaultEvent::CronCreated(path)
}
EntityKind::HumanTask(_) => VaultEvent::HumanTaskCreated(path),
EntityKind::AgentTask(_) => VaultEvent::AgentTaskCreated(path),
EntityKind::Knowledge => VaultEvent::KnowledgeCreated(path),
EntityKind::ViewPage | EntityKind::ViewWidget | EntityKind::ViewLayout | EntityKind::ViewCustom => {
VaultEvent::ViewCreated(path)
}
EntityKind::Notification => VaultEvent::NotificationCreated(path),
EntityKind::Unknown => VaultEvent::FileChanged(path),
}
}
fn make_modified(kind: EntityKind, path: PathBuf) -> VaultEvent {
match kind {
EntityKind::Agent => VaultEvent::AgentModified(path),
EntityKind::Skill => VaultEvent::SkillModified(path),
EntityKind::CronActive | EntityKind::CronPaused | EntityKind::CronTemplate => {
VaultEvent::CronModified(path)
}
EntityKind::HumanTask(_) => VaultEvent::HumanTaskModified(path),
EntityKind::AgentTask(_) => VaultEvent::AgentTaskModified(path),
EntityKind::Knowledge => VaultEvent::KnowledgeModified(path),
EntityKind::ViewPage | EntityKind::ViewWidget | EntityKind::ViewLayout | EntityKind::ViewCustom => {
VaultEvent::ViewModified(path)
}
EntityKind::Notification => VaultEvent::NotificationCreated(path),
EntityKind::Unknown => VaultEvent::FileChanged(path),
}
}
fn make_deleted(kind: EntityKind, path: PathBuf) -> VaultEvent {
match kind {
EntityKind::Agent => VaultEvent::AgentDeleted(path),
EntityKind::Skill => VaultEvent::SkillDeleted(path),
EntityKind::CronActive | EntityKind::CronPaused | EntityKind::CronTemplate => {
VaultEvent::CronDeleted(path)
}
EntityKind::HumanTask(_) => VaultEvent::HumanTaskDeleted(path),
EntityKind::AgentTask(_) => VaultEvent::AgentTaskDeleted(path),
EntityKind::Knowledge => VaultEvent::KnowledgeDeleted(path),
EntityKind::ViewPage | EntityKind::ViewWidget | EntityKind::ViewLayout | EntityKind::ViewCustom => {
VaultEvent::ViewDeleted(path)
}
EntityKind::Notification => VaultEvent::NotificationExpired(path),
EntityKind::Unknown => VaultEvent::FileChanged(path),
}
}
fn make_moved(kind: EntityKind, from: PathBuf, to: PathBuf) -> VaultEvent {
match kind {
EntityKind::CronActive | EntityKind::CronPaused => {
VaultEvent::CronMoved { from, to }
}
EntityKind::HumanTask(_) => VaultEvent::HumanTaskMoved { from, to },
EntityKind::AgentTask(_) => VaultEvent::AgentTaskMoved { from, to },
_ => make_created(kind, to),
}
}
#[cfg(test)]
mod tests {
use super::*;
use notify::event::{CreateKind, DataChange, ModifyKind};
fn make_event(kind: EventKind, paths: Vec<PathBuf>) -> notify::Event {
notify::Event {
kind,
paths,
attrs: Default::default(),
}
}
#[test]
fn test_classify_agent_created() {
let root = PathBuf::from("/vault");
let event = make_event(
EventKind::Create(CreateKind::File),
vec![PathBuf::from("/vault/agents/reviewer.md")],
);
let events = classify(&event, &root);
assert_eq!(events.len(), 1);
assert!(matches!(events[0], VaultEvent::AgentCreated(_)));
}
#[test]
fn test_skip_non_md() {
let root = PathBuf::from("/vault");
let event = make_event(
EventKind::Create(CreateKind::File),
vec![PathBuf::from("/vault/agents/readme.txt")],
);
let events = classify(&event, &root);
assert!(events.is_empty());
}
#[test]
fn test_skip_dotfiles() {
let root = PathBuf::from("/vault");
let event = make_event(
EventKind::Create(CreateKind::File),
vec![PathBuf::from("/vault/agents/.hidden.md")],
);
let events = classify(&event, &root);
assert!(events.is_empty());
}
#[test]
fn test_classify_task_modified() {
let root = PathBuf::from("/vault");
let event = make_event(
EventKind::Modify(ModifyKind::Data(DataChange::Content)),
vec![PathBuf::from("/vault/todos/agent/running/task-1.md")],
);
let events = classify(&event, &root);
assert_eq!(events.len(), 1);
assert!(matches!(events[0], VaultEvent::AgentTaskModified(_)));
}
#[test]
fn test_classify_rename() {
let root = PathBuf::from("/vault");
let event = make_event(
EventKind::Modify(ModifyKind::Name(RenameMode::Both)),
vec![
PathBuf::from("/vault/todos/agent/queued/task.md"),
PathBuf::from("/vault/todos/agent/running/task.md"),
],
);
let events = classify(&event, &root);
assert_eq!(events.len(), 1);
assert!(matches!(events[0], VaultEvent::AgentTaskMoved { .. }));
}
}

View file

@ -0,0 +1,108 @@
use std::path::PathBuf;
#[derive(Debug, Clone)]
pub enum VaultEvent {
AgentCreated(PathBuf),
AgentModified(PathBuf),
AgentDeleted(PathBuf),
SkillCreated(PathBuf),
SkillModified(PathBuf),
SkillDeleted(PathBuf),
CronCreated(PathBuf),
CronModified(PathBuf),
CronDeleted(PathBuf),
CronMoved { from: PathBuf, to: PathBuf },
HumanTaskCreated(PathBuf),
HumanTaskModified(PathBuf),
HumanTaskMoved { from: PathBuf, to: PathBuf },
HumanTaskDeleted(PathBuf),
AgentTaskCreated(PathBuf),
AgentTaskModified(PathBuf),
AgentTaskMoved { from: PathBuf, to: PathBuf },
AgentTaskDeleted(PathBuf),
KnowledgeCreated(PathBuf),
KnowledgeModified(PathBuf),
KnowledgeDeleted(PathBuf),
ViewCreated(PathBuf),
ViewModified(PathBuf),
ViewDeleted(PathBuf),
NotificationCreated(PathBuf),
NotificationExpired(PathBuf),
FileChanged(PathBuf),
}
impl VaultEvent {
/// Get the primary path associated with this event.
pub fn path(&self) -> &PathBuf {
match self {
Self::AgentCreated(p)
| Self::AgentModified(p)
| Self::AgentDeleted(p)
| Self::SkillCreated(p)
| Self::SkillModified(p)
| Self::SkillDeleted(p)
| Self::CronCreated(p)
| Self::CronModified(p)
| Self::CronDeleted(p)
| Self::HumanTaskCreated(p)
| Self::HumanTaskModified(p)
| Self::HumanTaskDeleted(p)
| Self::AgentTaskCreated(p)
| Self::AgentTaskModified(p)
| Self::AgentTaskDeleted(p)
| Self::KnowledgeCreated(p)
| Self::KnowledgeModified(p)
| Self::KnowledgeDeleted(p)
| Self::ViewCreated(p)
| Self::ViewModified(p)
| Self::ViewDeleted(p)
| Self::NotificationCreated(p)
| Self::NotificationExpired(p)
| Self::FileChanged(p) => p,
Self::CronMoved { to, .. }
| Self::HumanTaskMoved { to, .. }
| Self::AgentTaskMoved { to, .. } => to,
}
}
/// Return a string event type name for serialization.
pub fn event_type(&self) -> &'static str {
match self {
Self::AgentCreated(_) => "agent_created",
Self::AgentModified(_) => "agent_modified",
Self::AgentDeleted(_) => "agent_deleted",
Self::SkillCreated(_) => "skill_created",
Self::SkillModified(_) => "skill_modified",
Self::SkillDeleted(_) => "skill_deleted",
Self::CronCreated(_) => "cron_created",
Self::CronModified(_) => "cron_modified",
Self::CronDeleted(_) => "cron_deleted",
Self::CronMoved { .. } => "cron_moved",
Self::HumanTaskCreated(_) => "human_task_created",
Self::HumanTaskModified(_) => "human_task_modified",
Self::HumanTaskMoved { .. } => "human_task_moved",
Self::HumanTaskDeleted(_) => "human_task_deleted",
Self::AgentTaskCreated(_) => "agent_task_created",
Self::AgentTaskModified(_) => "agent_task_modified",
Self::AgentTaskMoved { .. } => "agent_task_moved",
Self::AgentTaskDeleted(_) => "agent_task_deleted",
Self::KnowledgeCreated(_) => "knowledge_created",
Self::KnowledgeModified(_) => "knowledge_modified",
Self::KnowledgeDeleted(_) => "knowledge_deleted",
Self::ViewCreated(_) => "view_created",
Self::ViewModified(_) => "view_modified",
Self::ViewDeleted(_) => "view_deleted",
Self::NotificationCreated(_) => "notification_created",
Self::NotificationExpired(_) => "notification_expired",
Self::FileChanged(_) => "file_changed",
}
}
}

View file

@ -0,0 +1,4 @@
pub mod classifier;
pub mod events;
pub mod watcher;
pub mod write_filter;

View file

@ -0,0 +1,83 @@
use crate::classifier;
use crate::events::VaultEvent;
use crate::write_filter::DaemonWriteFilter;
use notify::{Config, RecommendedWatcher, RecursiveMode, Watcher};
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::mpsc;
#[derive(Debug, thiserror::Error)]
pub enum WatchError {
#[error("Notify error: {0}")]
Notify(#[from] notify::Error),
#[error("Channel closed")]
ChannelClosed,
}
pub struct VaultWatcher {
vault_root: PathBuf,
write_filter: Arc<DaemonWriteFilter>,
_watcher: RecommendedWatcher,
rx: mpsc::Receiver<VaultEvent>,
}
impl VaultWatcher {
pub fn new(
vault_root: PathBuf,
write_filter: Arc<DaemonWriteFilter>,
) -> Result<Self, WatchError> {
let (event_tx, event_rx) = mpsc::channel(256);
let root = vault_root.clone();
let filter = write_filter.clone();
let (notify_tx, mut notify_rx) = mpsc::channel(512);
let mut watcher = RecommendedWatcher::new(
move |res: Result<notify::Event, notify::Error>| {
if let Ok(event) = res {
let _ = notify_tx.blocking_send(event);
}
},
Config::default(),
)?;
watcher.watch(&vault_root, RecursiveMode::Recursive)?;
// Spawn classification task
let tx = event_tx.clone();
tokio::spawn(async move {
while let Some(raw_event) = notify_rx.recv().await {
let vault_events = classifier::classify(&raw_event, &root);
for event in vault_events {
if filter.should_suppress(event.path()) {
tracing::debug!(?event, "Suppressed daemon-originated event");
continue;
}
if tx.send(event).await.is_err() {
return;
}
}
}
});
Ok(Self {
vault_root,
write_filter,
_watcher: watcher,
rx: event_rx,
})
}
pub fn vault_root(&self) -> &PathBuf {
&self.vault_root
}
pub fn write_filter(&self) -> &Arc<DaemonWriteFilter> {
&self.write_filter
}
pub async fn recv(&mut self) -> Option<VaultEvent> {
self.rx.recv().await
}
}

View file

@ -0,0 +1,67 @@
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Mutex;
use std::time::{Duration, Instant};
const WRITE_FILTER_TTL: Duration = Duration::from_secs(5);
/// Filters out filesystem events triggered by daemon-originated writes.
/// Before writing a file, register the path. When an event arrives,
/// check if it should be suppressed.
pub struct DaemonWriteFilter {
pending: Mutex<HashMap<PathBuf, Instant>>,
}
impl DaemonWriteFilter {
pub fn new() -> Self {
Self {
pending: Mutex::new(HashMap::new()),
}
}
/// Register a path that the daemon is about to write.
pub fn register(&self, path: PathBuf) {
let mut pending = self.pending.lock().unwrap();
pending.insert(path, Instant::now());
}
/// Check if an event for this path should be suppressed.
/// Returns true if the event should be suppressed (i.e., it was daemon-originated).
pub fn should_suppress(&self, path: &PathBuf) -> bool {
let mut pending = self.pending.lock().unwrap();
// Clean up stale entries
pending.retain(|_, ts| ts.elapsed() < WRITE_FILTER_TTL);
pending.remove(path).is_some()
}
}
impl Default for DaemonWriteFilter {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_register_and_suppress() {
let filter = DaemonWriteFilter::new();
let path = PathBuf::from("/vault/crons/active/test.md");
filter.register(path.clone());
assert!(filter.should_suppress(&path));
// Second check should not suppress (already consumed)
assert!(!filter.should_suppress(&path));
}
#[test]
fn test_unregistered_not_suppressed() {
let filter = DaemonWriteFilter::new();
let path = PathBuf::from("/vault/agents/test.md");
assert!(!filter.should_suppress(&path));
}
}