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>
317 lines
11 KiB
Rust
317 lines
11 KiB
Rust
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")));
|
|
}
|
|
}
|