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:
commit
f820a72b04
123 changed files with 18288 additions and 0 deletions
11
crates/vault-watch/Cargo.toml
Normal file
11
crates/vault-watch/Cargo.toml
Normal 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
|
||||
214
crates/vault-watch/src/classifier.rs
Normal file
214
crates/vault-watch/src/classifier.rs
Normal 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: ¬ify::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 { .. }));
|
||||
}
|
||||
}
|
||||
108
crates/vault-watch/src/events.rs
Normal file
108
crates/vault-watch/src/events.rs
Normal 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",
|
||||
}
|
||||
}
|
||||
}
|
||||
4
crates/vault-watch/src/lib.rs
Normal file
4
crates/vault-watch/src/lib.rs
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
pub mod classifier;
|
||||
pub mod events;
|
||||
pub mod watcher;
|
||||
pub mod write_filter;
|
||||
83
crates/vault-watch/src/watcher.rs
Normal file
83
crates/vault-watch/src/watcher.rs
Normal 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
|
||||
}
|
||||
}
|
||||
67
crates/vault-watch/src/write_filter.rs
Normal file
67
crates/vault-watch/src/write_filter.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue