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
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