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
14
crates/vault-core/Cargo.toml
Normal file
14
crates/vault-core/Cargo.toml
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
[package]
|
||||
name = "vault-core"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde.workspace = true
|
||||
serde_yaml.workspace = true
|
||||
serde_json.workspace = true
|
||||
chrono.workspace = true
|
||||
thiserror.workspace = true
|
||||
uuid.workspace = true
|
||||
tracing.workspace = true
|
||||
cron.workspace = true
|
||||
101
crates/vault-core/src/config.rs
Normal file
101
crates/vault-core/src/config.rs
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
use crate::error::VaultError;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct VaultConfig {
|
||||
#[serde(default)]
|
||||
pub mcp_servers: HashMap<String, McpServerConfig>,
|
||||
#[serde(default)]
|
||||
pub executors: HashMap<String, ExecutorConfig>,
|
||||
#[serde(default)]
|
||||
pub queue: QueueConfig,
|
||||
#[serde(default)]
|
||||
pub assistant: AssistantConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpServerConfig {
|
||||
pub command: String,
|
||||
#[serde(default)]
|
||||
pub args: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub env: HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ExecutorConfig {
|
||||
#[serde(default)]
|
||||
pub command: Option<String>,
|
||||
#[serde(default)]
|
||||
pub base_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub default_model: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct QueueConfig {
|
||||
#[serde(default = "default_max_parallel")]
|
||||
pub max_parallel: usize,
|
||||
#[serde(default = "default_timeout")]
|
||||
pub default_timeout: u64,
|
||||
#[serde(default = "default_retry_delay")]
|
||||
pub retry_delay: u64,
|
||||
}
|
||||
|
||||
impl Default for QueueConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_parallel: default_max_parallel(),
|
||||
default_timeout: default_timeout(),
|
||||
retry_delay: default_retry_delay(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_max_parallel() -> usize {
|
||||
4
|
||||
}
|
||||
fn default_timeout() -> u64 {
|
||||
600
|
||||
}
|
||||
fn default_retry_delay() -> u64 {
|
||||
60
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AssistantConfig {
|
||||
#[serde(default = "default_assistant_model")]
|
||||
pub default_model: String,
|
||||
#[serde(default)]
|
||||
pub models: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for AssistantConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
default_model: default_assistant_model(),
|
||||
models: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_assistant_model() -> String {
|
||||
"local/qwen3".into()
|
||||
}
|
||||
|
||||
impl VaultConfig {
|
||||
/// Load config from `.vault/config.yaml` in the vault root.
|
||||
/// Returns default config if file doesn't exist.
|
||||
pub fn load(vault_root: &Path) -> Result<Self, VaultError> {
|
||||
let config_path = vault_root.join(".vault/config.yaml");
|
||||
if !config_path.exists() {
|
||||
return Ok(Self::default());
|
||||
}
|
||||
let content = std::fs::read_to_string(&config_path)
|
||||
.map_err(|e| VaultError::io(e, &config_path))?;
|
||||
let config: VaultConfig = serde_yaml::from_str(&content)?;
|
||||
Ok(config)
|
||||
}
|
||||
}
|
||||
195
crates/vault-core/src/entity.rs
Normal file
195
crates/vault-core/src/entity.rs
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
use crate::error::VaultError;
|
||||
use crate::types::{AgentTaskStatus, TaskStatus};
|
||||
use serde::{de::DeserializeOwned, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// A vault entity: parsed frontmatter + markdown body + file path.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VaultEntity<T> {
|
||||
pub path: PathBuf,
|
||||
pub frontmatter: T,
|
||||
pub body: String,
|
||||
}
|
||||
|
||||
/// The kind of entity inferred from its relative path within the vault.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum EntityKind {
|
||||
Agent,
|
||||
Skill,
|
||||
CronActive,
|
||||
CronPaused,
|
||||
CronTemplate,
|
||||
HumanTask(TaskStatus),
|
||||
AgentTask(AgentTaskStatus),
|
||||
Knowledge,
|
||||
ViewPage,
|
||||
ViewWidget,
|
||||
ViewLayout,
|
||||
ViewCustom,
|
||||
Notification,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// Classify a relative path within the vault to determine entity kind.
|
||||
pub fn classify_path(relative: &Path) -> EntityKind {
|
||||
let components: Vec<&str> = relative
|
||||
.components()
|
||||
.filter_map(|c| c.as_os_str().to_str())
|
||||
.collect();
|
||||
|
||||
match components.as_slice() {
|
||||
["agents", ..] => EntityKind::Agent,
|
||||
["skills", ..] => EntityKind::Skill,
|
||||
["crons", "active", ..] => EntityKind::CronActive,
|
||||
["crons", "paused", ..] => EntityKind::CronPaused,
|
||||
["crons", "templates", ..] => EntityKind::CronTemplate,
|
||||
["todos", "harald", status, ..] => {
|
||||
EntityKind::HumanTask(task_status_from_dir(status))
|
||||
}
|
||||
["todos", "agent", status, ..] => {
|
||||
EntityKind::AgentTask(agent_task_status_from_dir(status))
|
||||
}
|
||||
["knowledge", ..] => EntityKind::Knowledge,
|
||||
["views", "pages", ..] => EntityKind::ViewPage,
|
||||
["views", "widgets", ..] => EntityKind::ViewWidget,
|
||||
["views", "layouts", ..] => EntityKind::ViewLayout,
|
||||
["views", "custom", ..] => EntityKind::ViewCustom,
|
||||
["views", "notifications", ..] => EntityKind::Notification,
|
||||
_ => EntityKind::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn task_status_from_dir(dir: &str) -> TaskStatus {
|
||||
match dir {
|
||||
"urgent" => TaskStatus::Urgent,
|
||||
"open" => TaskStatus::Open,
|
||||
"in-progress" => TaskStatus::InProgress,
|
||||
"done" => TaskStatus::Done,
|
||||
_ => TaskStatus::Open,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn agent_task_status_from_dir(dir: &str) -> AgentTaskStatus {
|
||||
match dir {
|
||||
"queued" => AgentTaskStatus::Queued,
|
||||
"running" => AgentTaskStatus::Running,
|
||||
"done" => AgentTaskStatus::Done,
|
||||
"failed" => AgentTaskStatus::Failed,
|
||||
_ => AgentTaskStatus::Queued,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn task_status_dir(status: &TaskStatus) -> &'static str {
|
||||
match status {
|
||||
TaskStatus::Urgent => "urgent",
|
||||
TaskStatus::Open => "open",
|
||||
TaskStatus::InProgress => "in-progress",
|
||||
TaskStatus::Done => "done",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn agent_task_status_dir(status: &AgentTaskStatus) -> &'static str {
|
||||
match status {
|
||||
AgentTaskStatus::Queued => "queued",
|
||||
AgentTaskStatus::Running => "running",
|
||||
AgentTaskStatus::Done => "done",
|
||||
AgentTaskStatus::Failed => "failed",
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> VaultEntity<T>
|
||||
where
|
||||
T: DeserializeOwned + Serialize,
|
||||
{
|
||||
pub fn from_content(path: PathBuf, content: &str) -> Result<Self, VaultError> {
|
||||
let (yaml, body) =
|
||||
crate::frontmatter::split_frontmatter_with_path(content, &path)?;
|
||||
let frontmatter: T = crate::frontmatter::parse_entity(yaml)?;
|
||||
Ok(Self {
|
||||
path,
|
||||
frontmatter,
|
||||
body: body.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn to_string(&self) -> Result<String, VaultError> {
|
||||
crate::frontmatter::write_frontmatter(&self.frontmatter, &self.body)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_classify_agent() {
|
||||
assert_eq!(
|
||||
classify_path(Path::new("agents/reviewer.md")),
|
||||
EntityKind::Agent
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_skill() {
|
||||
assert_eq!(
|
||||
classify_path(Path::new("skills/vault/read-vault.md")),
|
||||
EntityKind::Skill
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_cron() {
|
||||
assert_eq!(
|
||||
classify_path(Path::new("crons/active/daily-review.md")),
|
||||
EntityKind::CronActive
|
||||
);
|
||||
assert_eq!(
|
||||
classify_path(Path::new("crons/paused/old-job.md")),
|
||||
EntityKind::CronPaused
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_human_task() {
|
||||
assert_eq!(
|
||||
classify_path(Path::new("todos/harald/urgent/fix-bug.md")),
|
||||
EntityKind::HumanTask(TaskStatus::Urgent)
|
||||
);
|
||||
assert_eq!(
|
||||
classify_path(Path::new("todos/harald/in-progress/feature.md")),
|
||||
EntityKind::HumanTask(TaskStatus::InProgress)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_agent_task() {
|
||||
assert_eq!(
|
||||
classify_path(Path::new("todos/agent/queued/task-1.md")),
|
||||
EntityKind::AgentTask(AgentTaskStatus::Queued)
|
||||
);
|
||||
assert_eq!(
|
||||
classify_path(Path::new("todos/agent/running/task-2.md")),
|
||||
EntityKind::AgentTask(AgentTaskStatus::Running)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_knowledge() {
|
||||
assert_eq!(
|
||||
classify_path(Path::new("knowledge/notes/rust-tips.md")),
|
||||
EntityKind::Knowledge
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_views() {
|
||||
assert_eq!(
|
||||
classify_path(Path::new("views/pages/home.md")),
|
||||
EntityKind::ViewPage
|
||||
);
|
||||
assert_eq!(
|
||||
classify_path(Path::new("views/notifications/alert.md")),
|
||||
EntityKind::Notification
|
||||
);
|
||||
}
|
||||
}
|
||||
34
crates/vault-core/src/error.rs
Normal file
34
crates/vault-core/src/error.rs
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum VaultError {
|
||||
#[error("IO error: {source} (path: {path:?})")]
|
||||
Io {
|
||||
source: std::io::Error,
|
||||
path: PathBuf,
|
||||
},
|
||||
|
||||
#[error("YAML parsing error: {0}")]
|
||||
Yaml(#[from] serde_yaml::Error),
|
||||
|
||||
#[error("Missing frontmatter in {0}")]
|
||||
MissingFrontmatter(PathBuf),
|
||||
|
||||
#[error("Invalid entity at {path}: {reason}")]
|
||||
InvalidEntity { path: PathBuf, reason: String },
|
||||
|
||||
#[error("Not found: {0}")]
|
||||
NotFound(String),
|
||||
|
||||
#[error("Broken reference from {from} to {to}")]
|
||||
BrokenReference { from: PathBuf, to: String },
|
||||
}
|
||||
|
||||
impl VaultError {
|
||||
pub fn io(source: std::io::Error, path: impl Into<PathBuf>) -> Self {
|
||||
Self::Io {
|
||||
source,
|
||||
path: path.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
167
crates/vault-core/src/filesystem.rs
Normal file
167
crates/vault-core/src/filesystem.rs
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
use crate::entity::VaultEntity;
|
||||
use crate::error::VaultError;
|
||||
use serde::{de::DeserializeOwned, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Read and parse a vault entity from a markdown file.
|
||||
pub fn read_entity<T: DeserializeOwned + Serialize>(path: &Path) -> Result<VaultEntity<T>, VaultError> {
|
||||
let content =
|
||||
std::fs::read_to_string(path).map_err(|e| VaultError::io(e, path))?;
|
||||
VaultEntity::from_content(path.to_path_buf(), &content)
|
||||
}
|
||||
|
||||
/// Write a vault entity to disk.
|
||||
pub fn write_entity<T: DeserializeOwned + Serialize>(entity: &VaultEntity<T>) -> Result<(), VaultError> {
|
||||
let content = entity.to_string()?;
|
||||
if let Some(parent) = entity.path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| VaultError::io(e, parent))?;
|
||||
}
|
||||
std::fs::write(&entity.path, content).map_err(|e| VaultError::io(e, &entity.path))
|
||||
}
|
||||
|
||||
/// Move a file from one path to another, creating parent dirs as needed.
|
||||
pub fn move_file(from: &Path, to: &Path) -> Result<(), VaultError> {
|
||||
if let Some(parent) = to.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| VaultError::io(e, parent))?;
|
||||
}
|
||||
std::fs::rename(from, to).map_err(|e| VaultError::io(e, from))
|
||||
}
|
||||
|
||||
/// Ensure the standard vault directory structure exists.
|
||||
pub fn ensure_vault_structure(vault_root: &Path) -> Result<(), VaultError> {
|
||||
let dirs = [
|
||||
"agents",
|
||||
"skills/vault",
|
||||
"crons/active",
|
||||
"crons/paused",
|
||||
"crons/templates",
|
||||
"todos/harald/urgent",
|
||||
"todos/harald/open",
|
||||
"todos/harald/in-progress",
|
||||
"todos/harald/done",
|
||||
"todos/agent/queued",
|
||||
"todos/agent/running",
|
||||
"todos/agent/done",
|
||||
"todos/agent/failed",
|
||||
"knowledge",
|
||||
"views/pages",
|
||||
"views/widgets",
|
||||
"views/layouts",
|
||||
"views/custom",
|
||||
"views/notifications",
|
||||
".vault/logs",
|
||||
".vault/templates",
|
||||
];
|
||||
|
||||
for dir in &dirs {
|
||||
let path = vault_root.join(dir);
|
||||
std::fs::create_dir_all(&path).map_err(|e| VaultError::io(e, &path))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List all .md files in a directory (non-recursive).
|
||||
pub fn list_md_files(dir: &Path) -> Result<Vec<PathBuf>, VaultError> {
|
||||
if !dir.exists() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let mut files = Vec::new();
|
||||
let entries = std::fs::read_dir(dir).map_err(|e| VaultError::io(e, dir))?;
|
||||
for entry in entries {
|
||||
let entry = entry.map_err(|e| VaultError::io(e, dir))?;
|
||||
let path = entry.path();
|
||||
if path.is_file() && path.extension().is_some_and(|e| e == "md") {
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
files.sort();
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
/// List all .md files in a directory tree (recursive).
|
||||
pub fn list_md_files_recursive(dir: &Path) -> Result<Vec<PathBuf>, VaultError> {
|
||||
if !dir.exists() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let mut files = Vec::new();
|
||||
walk_dir_recursive(dir, &mut files)?;
|
||||
files.sort();
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
fn walk_dir_recursive(dir: &Path, files: &mut Vec<PathBuf>) -> Result<(), VaultError> {
|
||||
let entries = std::fs::read_dir(dir).map_err(|e| VaultError::io(e, dir))?;
|
||||
for entry in entries {
|
||||
let entry = entry.map_err(|e| VaultError::io(e, dir))?;
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
// Skip dotfiles/dirs
|
||||
if path
|
||||
.file_name()
|
||||
.is_some_and(|n| n.to_str().is_some_and(|s| s.starts_with('.')))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
walk_dir_recursive(&path, files)?;
|
||||
} else if path.is_file() && path.extension().is_some_and(|e| e == "md") {
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Convert a string to a URL-safe slug.
|
||||
pub fn slugify(s: &str) -> String {
|
||||
s.to_lowercase()
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_alphanumeric() {
|
||||
c
|
||||
} else {
|
||||
'-'
|
||||
}
|
||||
})
|
||||
.collect::<String>()
|
||||
.split('-')
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("-")
|
||||
}
|
||||
|
||||
/// Create a timestamped slug: `YYYYMMDD-HHMMSS-slug`
|
||||
pub fn timestamped_slug(title: &str) -> String {
|
||||
let now = chrono::Utc::now();
|
||||
format!("{}-{}", now.format("%Y%m%d-%H%M%S"), slugify(title))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_slugify() {
|
||||
assert_eq!(slugify("Hello World!"), "hello-world");
|
||||
assert_eq!(slugify("Review PR #1234"), "review-pr-1234");
|
||||
assert_eq!(slugify(" spaces everywhere "), "spaces-everywhere");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_timestamped_slug() {
|
||||
let slug = timestamped_slug("My Task");
|
||||
assert!(slug.ends_with("-my-task"));
|
||||
assert!(slug.len() > 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ensure_vault_structure() {
|
||||
let tmp = std::env::temp_dir().join("vault-os-test-structure");
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
ensure_vault_structure(&tmp).unwrap();
|
||||
assert!(tmp.join("agents").is_dir());
|
||||
assert!(tmp.join("todos/harald/urgent").is_dir());
|
||||
assert!(tmp.join("todos/agent/queued").is_dir());
|
||||
assert!(tmp.join(".vault/logs").is_dir());
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
}
|
||||
}
|
||||
180
crates/vault-core/src/frontmatter.rs
Normal file
180
crates/vault-core/src/frontmatter.rs
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
use crate::error::VaultError;
|
||||
use serde::{de::DeserializeOwned, Serialize};
|
||||
use std::path::Path;
|
||||
|
||||
const DELIMITER: &str = "---";
|
||||
|
||||
/// Split a markdown file into frontmatter YAML and body.
|
||||
/// Returns (yaml_str, body_str). Body preserves original content byte-for-byte.
|
||||
pub fn split_frontmatter(content: &str) -> Result<(&str, &str), VaultError> {
|
||||
let trimmed = content.trim_start();
|
||||
if !trimmed.starts_with(DELIMITER) {
|
||||
return Err(VaultError::MissingFrontmatter(
|
||||
"<unknown>".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Find the opening delimiter
|
||||
let after_first = &trimmed[DELIMITER.len()..];
|
||||
let after_first = after_first.strip_prefix('\n').unwrap_or(
|
||||
after_first.strip_prefix("\r\n").unwrap_or(after_first),
|
||||
);
|
||||
|
||||
// Find the closing delimiter
|
||||
if let Some(end_pos) = find_closing_delimiter(after_first) {
|
||||
let yaml = &after_first[..end_pos];
|
||||
let rest = &after_first[end_pos + DELIMITER.len()..];
|
||||
// Skip the newline after closing ---
|
||||
let body = rest
|
||||
.strip_prefix('\n')
|
||||
.unwrap_or(rest.strip_prefix("\r\n").unwrap_or(rest));
|
||||
Ok((yaml, body))
|
||||
} else {
|
||||
Err(VaultError::MissingFrontmatter(
|
||||
"<unknown>".into(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Split frontmatter with path context for error messages.
|
||||
pub fn split_frontmatter_with_path<'a>(
|
||||
content: &'a str,
|
||||
path: &Path,
|
||||
) -> Result<(&'a str, &'a str), VaultError> {
|
||||
split_frontmatter(content).map_err(|e| match e {
|
||||
VaultError::MissingFrontmatter(_) => VaultError::MissingFrontmatter(path.to_path_buf()),
|
||||
other => other,
|
||||
})
|
||||
}
|
||||
|
||||
fn find_closing_delimiter(s: &str) -> Option<usize> {
|
||||
for (i, line) in s.lines().enumerate() {
|
||||
if line.trim() == DELIMITER {
|
||||
// Calculate byte offset
|
||||
let offset: usize = s.lines().take(i).map(|l| l.len() + 1).sum();
|
||||
return Some(offset);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Parse frontmatter YAML into a typed struct.
|
||||
pub fn parse_entity<T: DeserializeOwned>(yaml: &str) -> Result<T, VaultError> {
|
||||
serde_yaml::from_str(yaml).map_err(VaultError::Yaml)
|
||||
}
|
||||
|
||||
/// Serialize frontmatter and combine with body, preserving body byte-for-byte.
|
||||
pub fn write_frontmatter<T: Serialize>(frontmatter: &T, body: &str) -> Result<String, VaultError> {
|
||||
let yaml = serde_yaml::to_string(frontmatter).map_err(VaultError::Yaml)?;
|
||||
let mut out = String::new();
|
||||
out.push_str(DELIMITER);
|
||||
out.push('\n');
|
||||
out.push_str(&yaml);
|
||||
// serde_yaml adds trailing newline, but ensure delimiter is on its own line
|
||||
if !yaml.ends_with('\n') {
|
||||
out.push('\n');
|
||||
}
|
||||
out.push_str(DELIMITER);
|
||||
out.push('\n');
|
||||
if !body.is_empty() {
|
||||
out.push_str(body);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Update specific fields in frontmatter YAML without re-serializing the entire struct.
|
||||
/// This preserves unknown fields and ordering as much as possible.
|
||||
pub fn update_frontmatter_fields(
|
||||
content: &str,
|
||||
path: &Path,
|
||||
updates: &serde_json::Value,
|
||||
) -> Result<String, VaultError> {
|
||||
let (yaml, body) = split_frontmatter_with_path(content, path)?;
|
||||
|
||||
let mut mapping: serde_yaml::Value = serde_yaml::from_str(yaml).map_err(VaultError::Yaml)?;
|
||||
|
||||
if let (serde_yaml::Value::Mapping(ref mut map), serde_json::Value::Object(ref obj)) =
|
||||
(&mut mapping, updates)
|
||||
{
|
||||
for (key, value) in obj {
|
||||
let yaml_key = serde_yaml::Value::String(key.clone());
|
||||
let yaml_value: serde_yaml::Value =
|
||||
serde_json::from_value(value.clone()).unwrap_or(serde_yaml::Value::Null);
|
||||
map.insert(yaml_key, yaml_value);
|
||||
}
|
||||
}
|
||||
|
||||
let yaml_out = serde_yaml::to_string(&mapping).map_err(VaultError::Yaml)?;
|
||||
let mut out = String::new();
|
||||
out.push_str(DELIMITER);
|
||||
out.push('\n');
|
||||
out.push_str(&yaml_out);
|
||||
if !yaml_out.ends_with('\n') {
|
||||
out.push('\n');
|
||||
}
|
||||
out.push_str(DELIMITER);
|
||||
out.push('\n');
|
||||
if !body.is_empty() {
|
||||
out.push_str(body);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::types::Agent;
|
||||
|
||||
#[test]
|
||||
fn test_split_frontmatter() {
|
||||
let content = "---\nname: test\n---\nHello world\n";
|
||||
let (yaml, body) = split_frontmatter(content).unwrap();
|
||||
assert_eq!(yaml, "name: test\n");
|
||||
assert_eq!(body, "Hello world\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_missing_frontmatter() {
|
||||
let content = "Hello world\n";
|
||||
assert!(split_frontmatter(content).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_roundtrip() {
|
||||
let original_body = "# System Prompt\n\nYou are a helpful agent.\n\n- Rule 1\n- Rule 2\n";
|
||||
let agent = Agent {
|
||||
name: "test-agent".into(),
|
||||
executable: "claude-code".into(),
|
||||
model: Some("sonnet".into()),
|
||||
escalate_to: None,
|
||||
escalate_when: vec![],
|
||||
mcp_servers: vec![],
|
||||
skills: vec!["read-vault".into()],
|
||||
timeout: 600,
|
||||
max_retries: 2,
|
||||
env: Default::default(),
|
||||
};
|
||||
|
||||
let written = write_frontmatter(&agent, original_body).unwrap();
|
||||
let (yaml, body) = split_frontmatter(&written).unwrap();
|
||||
let parsed: Agent = parse_entity(yaml).unwrap();
|
||||
|
||||
assert_eq!(parsed.name, "test-agent");
|
||||
assert_eq!(parsed.executable, "claude-code");
|
||||
assert_eq!(body, original_body);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_fields() {
|
||||
let content = "---\nname: test\nschedule: '* * * * *'\n---\nBody\n";
|
||||
let updates = serde_json::json!({
|
||||
"last_run": "2024-01-01T00:00:00Z",
|
||||
"run_count": 5
|
||||
});
|
||||
let result =
|
||||
update_frontmatter_fields(content, Path::new("test.md"), &updates).unwrap();
|
||||
assert!(result.contains("last_run"));
|
||||
assert!(result.contains("run_count"));
|
||||
assert!(result.contains("Body\n"));
|
||||
}
|
||||
}
|
||||
12
crates/vault-core/src/lib.rs
Normal file
12
crates/vault-core/src/lib.rs
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
pub mod config;
|
||||
pub mod entity;
|
||||
pub mod error;
|
||||
pub mod filesystem;
|
||||
pub mod frontmatter;
|
||||
pub mod prompt;
|
||||
pub mod search;
|
||||
pub mod types;
|
||||
pub mod validation;
|
||||
|
||||
pub use error::VaultError;
|
||||
pub type Result<T> = std::result::Result<T, VaultError>;
|
||||
64
crates/vault-core/src/prompt.rs
Normal file
64
crates/vault-core/src/prompt.rs
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
use crate::entity::VaultEntity;
|
||||
use crate::error::VaultError;
|
||||
use crate::filesystem;
|
||||
use crate::types::{Agent, Skill};
|
||||
use std::path::Path;
|
||||
|
||||
/// Resolve a skill name to its file path under the vault's `skills/` directory.
|
||||
pub fn resolve_skill_path(vault_root: &Path, skill_name: &str) -> Option<std::path::PathBuf> {
|
||||
// Try direct: skills/{name}.md
|
||||
let direct = vault_root.join("skills").join(format!("{}.md", skill_name));
|
||||
if direct.exists() {
|
||||
return Some(direct);
|
||||
}
|
||||
// Try nested: skills/vault/{name}.md
|
||||
let nested = vault_root
|
||||
.join("skills/vault")
|
||||
.join(format!("{}.md", skill_name));
|
||||
if nested.exists() {
|
||||
return Some(nested);
|
||||
}
|
||||
// Try recursive search
|
||||
if let Ok(files) = filesystem::list_md_files_recursive(&vault_root.join("skills")) {
|
||||
for file in files {
|
||||
if let Some(stem) = file.file_stem() {
|
||||
if stem == skill_name {
|
||||
return Some(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Compose the full prompt for an agent execution.
|
||||
/// Agent body + skill bodies appended under `## Skills` sections.
|
||||
pub fn compose_prompt(
|
||||
vault_root: &Path,
|
||||
agent: &VaultEntity<Agent>,
|
||||
task_context: Option<&str>,
|
||||
) -> Result<String, VaultError> {
|
||||
let mut prompt = agent.body.clone();
|
||||
|
||||
// Append skills
|
||||
if !agent.frontmatter.skills.is_empty() {
|
||||
prompt.push_str("\n\n## Skills\n");
|
||||
for skill_name in &agent.frontmatter.skills {
|
||||
if let Some(skill_path) = resolve_skill_path(vault_root, skill_name) {
|
||||
let skill_entity: VaultEntity<Skill> = filesystem::read_entity(&skill_path)?;
|
||||
prompt.push_str(&format!("\n### {}\n", skill_entity.frontmatter.name));
|
||||
prompt.push_str(&skill_entity.body);
|
||||
} else {
|
||||
tracing::warn!(skill = %skill_name, "Skill not found, skipping");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Append task context if provided
|
||||
if let Some(ctx) = task_context {
|
||||
prompt.push_str("\n\n## Task\n\n");
|
||||
prompt.push_str(ctx);
|
||||
}
|
||||
|
||||
Ok(prompt)
|
||||
}
|
||||
164
crates/vault-core/src/search.rs
Normal file
164
crates/vault-core/src/search.rs
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
use crate::filesystem::{list_md_files_recursive, read_entity};
|
||||
use crate::frontmatter::split_frontmatter_with_path;
|
||||
use crate::types::KnowledgeNote;
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct SearchResult {
|
||||
pub path: String,
|
||||
pub title: String,
|
||||
pub snippet: String,
|
||||
pub score: f64,
|
||||
}
|
||||
|
||||
/// Search vault files by query string.
|
||||
/// Matches against frontmatter title, tags, and body content.
|
||||
pub fn search_vault(vault_root: &Path, query: &str) -> Vec<SearchResult> {
|
||||
let query_lower = query.to_lowercase();
|
||||
let terms: Vec<&str> = query_lower.split_whitespace().collect();
|
||||
if terms.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut results = Vec::new();
|
||||
|
||||
// Search across key directories
|
||||
let dirs = ["knowledge", "agents", "skills", "todos/harald", "todos/agent"];
|
||||
|
||||
for dir in dirs {
|
||||
let full_dir = vault_root.join(dir);
|
||||
if !full_dir.exists() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(files) = list_md_files_recursive(&full_dir) {
|
||||
for path in files {
|
||||
if let Some(result) = score_file(&path, vault_root, &terms) {
|
||||
results.push(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
results.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
|
||||
results.truncate(50);
|
||||
results
|
||||
}
|
||||
|
||||
/// Search specifically by tag.
|
||||
pub fn search_by_tag(vault_root: &Path, tag: &str) -> Vec<SearchResult> {
|
||||
let tag_lower = tag.to_lowercase();
|
||||
let mut results = Vec::new();
|
||||
|
||||
let knowledge_dir = vault_root.join("knowledge");
|
||||
if let Ok(files) = list_md_files_recursive(&knowledge_dir) {
|
||||
for path in files {
|
||||
if let Ok(entity) = read_entity::<KnowledgeNote>(&path) {
|
||||
let has_tag = entity
|
||||
.frontmatter
|
||||
.tags
|
||||
.iter()
|
||||
.any(|t| t.to_lowercase() == tag_lower);
|
||||
if has_tag {
|
||||
let relative = path
|
||||
.strip_prefix(vault_root)
|
||||
.unwrap_or(&path)
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let title = entity
|
||||
.frontmatter
|
||||
.title
|
||||
.unwrap_or_else(|| relative.clone());
|
||||
results.push(SearchResult {
|
||||
path: relative,
|
||||
title,
|
||||
snippet: entity.body.chars().take(120).collect(),
|
||||
score: 1.0,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
results
|
||||
}
|
||||
|
||||
fn score_file(path: &Path, vault_root: &Path, terms: &[&str]) -> Option<SearchResult> {
|
||||
let content = std::fs::read_to_string(path).ok()?;
|
||||
let content_lower = content.to_lowercase();
|
||||
|
||||
let relative = path
|
||||
.strip_prefix(vault_root)
|
||||
.unwrap_or(path)
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
let mut score = 0.0;
|
||||
let mut all_matched = true;
|
||||
|
||||
for term in terms {
|
||||
let mut term_score = 0.0;
|
||||
|
||||
// Title matches (higher weight)
|
||||
if relative.to_lowercase().contains(term) {
|
||||
term_score += 3.0;
|
||||
}
|
||||
|
||||
// Body/content matches
|
||||
let count = content_lower.matches(term).count();
|
||||
if count > 0 {
|
||||
term_score += 1.0 + (count as f64).min(5.0) * 0.2;
|
||||
}
|
||||
|
||||
if term_score == 0.0 {
|
||||
all_matched = false;
|
||||
break;
|
||||
}
|
||||
score += term_score;
|
||||
}
|
||||
|
||||
if !all_matched || score == 0.0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Extract title from frontmatter if possible
|
||||
let title = if let Ok((yaml, _body)) = split_frontmatter_with_path(&content, path) {
|
||||
serde_yaml::from_str::<serde_json::Value>(yaml)
|
||||
.ok()
|
||||
.and_then(|v| v.get("title").and_then(|t| t.as_str()).map(String::from))
|
||||
.or_else(|| {
|
||||
serde_yaml::from_str::<serde_json::Value>(yaml)
|
||||
.ok()
|
||||
.and_then(|v| v.get("name").and_then(|t| t.as_str()).map(String::from))
|
||||
})
|
||||
.unwrap_or_else(|| relative.clone())
|
||||
} else {
|
||||
relative.clone()
|
||||
};
|
||||
|
||||
// Extract a snippet around the first match
|
||||
let snippet = extract_snippet(&content, terms.first().unwrap_or(&""));
|
||||
|
||||
Some(SearchResult {
|
||||
path: relative,
|
||||
title,
|
||||
snippet,
|
||||
score,
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_snippet(content: &str, term: &str) -> String {
|
||||
let lower = content.to_lowercase();
|
||||
if let Some(pos) = lower.find(&term.to_lowercase()) {
|
||||
let start = content[..pos]
|
||||
.rfind('\n')
|
||||
.map(|p| p + 1)
|
||||
.unwrap_or(pos.saturating_sub(60));
|
||||
let end = content[pos..]
|
||||
.find('\n')
|
||||
.map(|p| pos + p)
|
||||
.unwrap_or((pos + 120).min(content.len()));
|
||||
content[start..end].chars().take(150).collect()
|
||||
} else {
|
||||
content.lines().next().unwrap_or("").chars().take(150).collect()
|
||||
}
|
||||
}
|
||||
204
crates/vault-core/src/types.rs
Normal file
204
crates/vault-core/src/types.rs
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Priority {
|
||||
Urgent,
|
||||
High,
|
||||
#[default]
|
||||
Medium,
|
||||
Low,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum RunStatus {
|
||||
Success,
|
||||
Failure,
|
||||
Timeout,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum TaskStatus {
|
||||
Urgent,
|
||||
Open,
|
||||
#[serde(rename = "in-progress")]
|
||||
InProgress,
|
||||
Done,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum AgentTaskStatus {
|
||||
Queued,
|
||||
Running,
|
||||
Done,
|
||||
Failed,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Agent {
|
||||
pub name: String,
|
||||
pub executable: String,
|
||||
#[serde(default)]
|
||||
pub model: Option<String>,
|
||||
#[serde(default)]
|
||||
pub escalate_to: Option<String>,
|
||||
#[serde(default)]
|
||||
pub escalate_when: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub mcp_servers: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub skills: Vec<String>,
|
||||
#[serde(default = "default_timeout")]
|
||||
pub timeout: u64,
|
||||
#[serde(default)]
|
||||
pub max_retries: u32,
|
||||
#[serde(default)]
|
||||
pub env: HashMap<String, String>,
|
||||
}
|
||||
|
||||
fn default_timeout() -> u64 {
|
||||
600
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Skill {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
#[serde(default)]
|
||||
pub version: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub requires_mcp: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub inputs: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub outputs: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CronJob {
|
||||
pub schedule: String,
|
||||
pub agent: String,
|
||||
pub title: String,
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
#[serde(default)]
|
||||
pub last_run: Option<DateTime<Utc>>,
|
||||
#[serde(default)]
|
||||
pub last_status: Option<RunStatus>,
|
||||
#[serde(default)]
|
||||
pub next_run: Option<DateTime<Utc>>,
|
||||
#[serde(default)]
|
||||
pub run_count: u64,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HumanTask {
|
||||
pub title: String,
|
||||
#[serde(default)]
|
||||
pub priority: Priority,
|
||||
#[serde(default)]
|
||||
pub source: Option<String>,
|
||||
#[serde(default)]
|
||||
pub repo: Option<String>,
|
||||
#[serde(default)]
|
||||
pub labels: Vec<String>,
|
||||
pub created: DateTime<Utc>,
|
||||
#[serde(default)]
|
||||
pub due: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AgentTask {
|
||||
pub title: String,
|
||||
pub agent: String,
|
||||
#[serde(default)]
|
||||
pub priority: Priority,
|
||||
#[serde(default, rename = "type")]
|
||||
pub task_type: Option<String>,
|
||||
pub created: DateTime<Utc>,
|
||||
#[serde(default)]
|
||||
pub started: Option<DateTime<Utc>>,
|
||||
#[serde(default)]
|
||||
pub completed: Option<DateTime<Utc>>,
|
||||
#[serde(default)]
|
||||
pub retry: u32,
|
||||
#[serde(default)]
|
||||
pub max_retries: u32,
|
||||
#[serde(default)]
|
||||
pub input: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
pub output: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct KnowledgeNote {
|
||||
#[serde(default)]
|
||||
pub title: Option<String>,
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub source: Option<String>,
|
||||
#[serde(default)]
|
||||
pub created: Option<DateTime<Utc>>,
|
||||
#[serde(default)]
|
||||
pub related: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ViewDefinition {
|
||||
#[serde(rename = "type")]
|
||||
pub view_type: String,
|
||||
pub title: Option<String>,
|
||||
#[serde(default)]
|
||||
pub icon: Option<String>,
|
||||
#[serde(default)]
|
||||
pub route: Option<String>,
|
||||
#[serde(default)]
|
||||
pub position: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub layout: Option<String>,
|
||||
#[serde(default)]
|
||||
pub regions: HashMap<String, Vec<WidgetInstance>>,
|
||||
// Widget-specific fields
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
#[serde(default)]
|
||||
pub component: Option<String>,
|
||||
#[serde(default)]
|
||||
pub props_schema: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WidgetInstance {
|
||||
pub widget: String,
|
||||
#[serde(default)]
|
||||
pub props: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Notification {
|
||||
pub title: String,
|
||||
#[serde(default)]
|
||||
pub message: Option<String>,
|
||||
#[serde(default)]
|
||||
pub level: Option<String>,
|
||||
#[serde(default)]
|
||||
pub source: Option<String>,
|
||||
#[serde(default)]
|
||||
pub created: Option<DateTime<Utc>>,
|
||||
#[serde(default)]
|
||||
pub expires: Option<DateTime<Utc>>,
|
||||
}
|
||||
317
crates/vault-core/src/validation.rs
Normal file
317
crates/vault-core/src/validation.rs
Normal file
|
|
@ -0,0 +1,317 @@
|
|||
use crate::entity::{classify_path, EntityKind};
|
||||
use crate::frontmatter::split_frontmatter_with_path;
|
||||
use crate::types::*;
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ValidationIssue {
|
||||
pub level: IssueLevel,
|
||||
pub field: Option<String>,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum IssueLevel {
|
||||
Error,
|
||||
Warning,
|
||||
}
|
||||
|
||||
impl serde::Serialize for ValidationIssue {
|
||||
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
|
||||
use serde::ser::SerializeStruct;
|
||||
let mut st = s.serialize_struct("ValidationIssue", 3)?;
|
||||
st.serialize_field("level", &self.level)?;
|
||||
st.serialize_field("field", &self.field)?;
|
||||
st.serialize_field("message", &self.message)?;
|
||||
st.end()
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate a vault file given its relative path and raw content.
|
||||
pub fn validate(relative_path: &Path, content: &str) -> Vec<ValidationIssue> {
|
||||
let mut issues = Vec::new();
|
||||
|
||||
// Check frontmatter exists
|
||||
let (yaml, _body) = match split_frontmatter_with_path(content, relative_path) {
|
||||
Ok(pair) => pair,
|
||||
Err(_) => {
|
||||
issues.push(ValidationIssue {
|
||||
level: IssueLevel::Error,
|
||||
field: None,
|
||||
message: "Missing or malformed frontmatter".into(),
|
||||
});
|
||||
return issues;
|
||||
}
|
||||
};
|
||||
|
||||
let kind = classify_path(relative_path);
|
||||
|
||||
match kind {
|
||||
EntityKind::Agent => validate_agent(yaml, &mut issues),
|
||||
EntityKind::Skill => validate_skill(yaml, &mut issues),
|
||||
EntityKind::CronActive | EntityKind::CronPaused | EntityKind::CronTemplate => {
|
||||
validate_cron(yaml, &mut issues)
|
||||
}
|
||||
EntityKind::HumanTask(_) => validate_human_task(yaml, &mut issues),
|
||||
EntityKind::AgentTask(_) => validate_agent_task(yaml, &mut issues),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
issues
|
||||
}
|
||||
|
||||
fn validate_agent(yaml: &str, issues: &mut Vec<ValidationIssue>) {
|
||||
match serde_yaml::from_str::<Agent>(yaml) {
|
||||
Ok(agent) => {
|
||||
if agent.name.is_empty() {
|
||||
issues.push(ValidationIssue {
|
||||
level: IssueLevel::Error,
|
||||
field: Some("name".into()),
|
||||
message: "Agent name is required".into(),
|
||||
});
|
||||
}
|
||||
if agent.executable.is_empty() {
|
||||
issues.push(ValidationIssue {
|
||||
level: IssueLevel::Error,
|
||||
field: Some("executable".into()),
|
||||
message: "Agent executable is required".into(),
|
||||
});
|
||||
}
|
||||
let valid_executables = ["claude-code", "ollama", "openai-compat"];
|
||||
if !valid_executables.contains(&agent.executable.as_str())
|
||||
&& !agent.executable.starts_with('/')
|
||||
&& !agent.executable.contains('/')
|
||||
{
|
||||
issues.push(ValidationIssue {
|
||||
level: IssueLevel::Warning,
|
||||
field: Some("executable".into()),
|
||||
message: format!(
|
||||
"Executable '{}' is not a known executor. Expected one of: {:?} or an absolute path",
|
||||
agent.executable, valid_executables
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
issues.push(ValidationIssue {
|
||||
level: IssueLevel::Error,
|
||||
field: None,
|
||||
message: format!("Invalid agent frontmatter: {e}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_skill(yaml: &str, issues: &mut Vec<ValidationIssue>) {
|
||||
match serde_yaml::from_str::<Skill>(yaml) {
|
||||
Ok(skill) => {
|
||||
if skill.name.is_empty() {
|
||||
issues.push(ValidationIssue {
|
||||
level: IssueLevel::Error,
|
||||
field: Some("name".into()),
|
||||
message: "Skill name is required".into(),
|
||||
});
|
||||
}
|
||||
if skill.description.is_empty() {
|
||||
issues.push(ValidationIssue {
|
||||
level: IssueLevel::Warning,
|
||||
field: Some("description".into()),
|
||||
message: "Skill should have a description".into(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
issues.push(ValidationIssue {
|
||||
level: IssueLevel::Error,
|
||||
field: None,
|
||||
message: format!("Invalid skill frontmatter: {e}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_cron(yaml: &str, issues: &mut Vec<ValidationIssue>) {
|
||||
match serde_yaml::from_str::<CronJob>(yaml) {
|
||||
Ok(cron) => {
|
||||
if cron.title.is_empty() {
|
||||
issues.push(ValidationIssue {
|
||||
level: IssueLevel::Error,
|
||||
field: Some("title".into()),
|
||||
message: "Cron title is required".into(),
|
||||
});
|
||||
}
|
||||
if cron.agent.is_empty() {
|
||||
issues.push(ValidationIssue {
|
||||
level: IssueLevel::Error,
|
||||
field: Some("agent".into()),
|
||||
message: "Cron agent is required".into(),
|
||||
});
|
||||
}
|
||||
// Validate cron expression
|
||||
let expr = if cron.schedule.split_whitespace().count() == 5 {
|
||||
format!("0 {}", cron.schedule)
|
||||
} else {
|
||||
cron.schedule.clone()
|
||||
};
|
||||
if cron::Schedule::from_str(&expr).is_err() {
|
||||
issues.push(ValidationIssue {
|
||||
level: IssueLevel::Error,
|
||||
field: Some("schedule".into()),
|
||||
message: format!("Invalid cron expression: '{}'", cron.schedule),
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
issues.push(ValidationIssue {
|
||||
level: IssueLevel::Error,
|
||||
field: None,
|
||||
message: format!("Invalid cron frontmatter: {e}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_human_task(yaml: &str, issues: &mut Vec<ValidationIssue>) {
|
||||
match serde_yaml::from_str::<HumanTask>(yaml) {
|
||||
Ok(task) => {
|
||||
if task.title.is_empty() {
|
||||
issues.push(ValidationIssue {
|
||||
level: IssueLevel::Error,
|
||||
field: Some("title".into()),
|
||||
message: "Task title is required".into(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
issues.push(ValidationIssue {
|
||||
level: IssueLevel::Error,
|
||||
field: None,
|
||||
message: format!("Invalid task frontmatter: {e}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_agent_task(yaml: &str, issues: &mut Vec<ValidationIssue>) {
|
||||
match serde_yaml::from_str::<AgentTask>(yaml) {
|
||||
Ok(task) => {
|
||||
if task.title.is_empty() {
|
||||
issues.push(ValidationIssue {
|
||||
level: IssueLevel::Error,
|
||||
field: Some("title".into()),
|
||||
message: "Task title is required".into(),
|
||||
});
|
||||
}
|
||||
if task.agent.is_empty() {
|
||||
issues.push(ValidationIssue {
|
||||
level: IssueLevel::Error,
|
||||
field: Some("agent".into()),
|
||||
message: "Agent name is required for agent tasks".into(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
issues.push(ValidationIssue {
|
||||
level: IssueLevel::Error,
|
||||
field: None,
|
||||
message: format!("Invalid agent task frontmatter: {e}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate that references between entities are valid.
|
||||
/// Checks that agent skills and cron agents exist.
|
||||
pub fn validate_references(
|
||||
vault_root: &Path,
|
||||
agent_names: &HashSet<String>,
|
||||
skill_names: &HashSet<String>,
|
||||
) -> Vec<(String, ValidationIssue)> {
|
||||
let mut issues = Vec::new();
|
||||
|
||||
// Check agent skill references
|
||||
let agents_dir = vault_root.join("agents");
|
||||
if let Ok(files) = crate::filesystem::list_md_files(&agents_dir) {
|
||||
for path in files {
|
||||
if let Ok(entity) = crate::filesystem::read_entity::<Agent>(&path) {
|
||||
for skill in &entity.frontmatter.skills {
|
||||
if !skill_names.contains(skill) {
|
||||
issues.push((
|
||||
entity.frontmatter.name.clone(),
|
||||
ValidationIssue {
|
||||
level: IssueLevel::Warning,
|
||||
field: Some("skills".into()),
|
||||
message: format!("Referenced skill '{}' not found", skill),
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check cron agent references
|
||||
let crons_dir = vault_root.join("crons/active");
|
||||
if let Ok(files) = crate::filesystem::list_md_files(&crons_dir) {
|
||||
for path in files {
|
||||
if let Ok(entity) = crate::filesystem::read_entity::<CronJob>(&path) {
|
||||
if !agent_names.contains(&entity.frontmatter.agent) {
|
||||
issues.push((
|
||||
entity.frontmatter.title.clone(),
|
||||
ValidationIssue {
|
||||
level: IssueLevel::Warning,
|
||||
field: Some("agent".into()),
|
||||
message: format!(
|
||||
"Referenced agent '{}' not found",
|
||||
entity.frontmatter.agent
|
||||
),
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
issues
|
||||
}
|
||||
|
||||
use std::str::FromStr;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::Path;
|
||||
|
||||
#[test]
|
||||
fn test_validate_valid_agent() {
|
||||
let content = "---\nname: test-agent\nexecutable: claude-code\n---\nBody";
|
||||
let issues = validate(Path::new("agents/test-agent.md"), content);
|
||||
assert!(issues.is_empty(), "Expected no issues: {:?}", issues);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_agent_missing_name() {
|
||||
let content = "---\nname: \"\"\nexecutable: claude-code\n---\n";
|
||||
let issues = validate(Path::new("agents/bad.md"), content);
|
||||
assert!(issues.iter().any(|i| i.field.as_deref() == Some("name")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_missing_frontmatter() {
|
||||
let content = "No frontmatter here";
|
||||
let issues = validate(Path::new("agents/bad.md"), content);
|
||||
assert_eq!(issues.len(), 1);
|
||||
assert_eq!(issues[0].level, IssueLevel::Error);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_cron_bad_expression() {
|
||||
let content = "---\ntitle: bad\nagent: test\nschedule: \"not a cron\"\n---\n";
|
||||
let issues = validate(Path::new("crons/active/bad.md"), content);
|
||||
assert!(issues
|
||||
.iter()
|
||||
.any(|i| i.field.as_deref() == Some("schedule")));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue