// synops-validate — Valider at en node matcher forventet skjema for sin node_kind. // // Henter noden fra PostgreSQL og sjekker: // 1. Basisfelter (visibility, created_by) // 2. node_kind-spesifikk metadata-validering (collection traits, ai_preset, orchestration, media, communication) // // Samler alle avvik i en liste (feiler ikke på første treff). // Output: avviksliste til stdout (markdown eller JSON). // Exit-kode: 0 = ingen avvik, 1 = avvik funnet, 2 = node finnes ikke / feil. // // Brukes av valideringsfasen og som pre-commit sjekk. // // Miljøvariabler: // DATABASE_URL — PostgreSQL-tilkobling (påkrevd) // // Ref: docs/primitiver/nodes.md, docs/primitiver/traits.md use clap::{Parser, ValueEnum}; use serde::Serialize; use std::process; use uuid::Uuid; /// Valider at en node matcher forventet skjema for sin node_kind. #[derive(Parser)] #[command(name = "synops-validate", about = "Valider node-skjema")] struct Cli { /// Node-ID (UUID) #[arg(long)] node_id: Uuid, /// Output-format #[arg(long, default_value = "md")] format: OutputFormat, } #[derive(Clone, ValueEnum)] enum OutputFormat { Json, Md, } #[derive(Debug, Clone, Serialize)] struct Violation { field: String, message: String, severity: Severity, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "lowercase")] enum Severity { Error, Warning, } #[derive(sqlx::FromRow)] struct NodeRow { id: Uuid, node_kind: String, title: Option, #[allow(dead_code)] content: Option, visibility: String, metadata: serde_json::Value, #[allow(dead_code)] created_at: chrono::DateTime, created_by: Option, } // --- Kjente verdier (speiler maskinrommet/src/intentions.rs) --- const VALID_VISIBILITIES: &[&str] = &["hidden", "discoverable", "readable", "open"]; const VALID_NODE_KINDS: &[&str] = &[ "person", "team", "collection", "content", "communication", "topic", "media", "agent", "system_announcement", "ai_preset", "workspace", "work_item", "cli_tool", "orchestration", ]; const VALID_TRAITS: &[&str] = &[ // Innhold & redigering "editor", "versioning", "collaboration", "translation", "templates", // Publisering & distribusjon "publishing", "rss", "newsletter", "custom_domain", "analytics", "embed", "api", // Lyd & video "podcast", "recording", "transcription", "tts", "clips", "playlist", "mixer", "studio", // Kommunikasjon "chat", "forum", "comments", "guest_input", "announcements", "polls", "qa", // Organisering "kanban", "calendar", "timeline", "table", "gallery", "bookmarks", "tags", // Kunnskap "knowledge_graph", "mindmap", "wiki", "glossary", "faq", "bibliography", // Automatisering & AI "auto_tag", "auto_summarize", "digest", "bridge", "moderation", "ai_tool", "orchestration", // Tilgang & fellesskap "membership", "roles", "invites", "paywall", "directory", // Ekstern integrasjon "webhook", "import", "export", "ical_sync", ]; const VALID_TRIGGER_EVENTS: &[&str] = &[ "node.created", "edge.created", "communication.ended", "node.published", "scheduled.due", "manual", ]; const VALID_EXECUTORS: &[&str] = &["script", "bot", "dream"]; const VALID_MODEL_PROFILES: &[&str] = &["flash", "standard"]; const VALID_AI_PRESET_CATEGORIES: &[&str] = &["standard", "custom"]; const VALID_AI_PRESET_DIRECTIONS: &[&str] = &["tool_to_node", "node_to_tool", "both"]; #[tokio::main] async fn main() { synops_common::logging::init("synops_validate"); let cli = Cli::parse(); match run(cli).await { Ok(violations) => { process::exit(if violations.is_empty() { 0 } else { 1 }); } Err(e) => { eprintln!("Feil: {e}"); process::exit(2); } } } async fn run(cli: Cli) -> Result, String> { let db = synops_common::db::connect().await?; let node = sqlx::query_as::<_, NodeRow>( "SELECT id, node_kind::text, title, content, visibility::text, \ metadata, created_at, created_by FROM nodes WHERE id = $1", ) .bind(cli.node_id) .fetch_optional(&db) .await .map_err(|e| format!("DB-feil: {e}"))? .ok_or_else(|| format!("Node {} finnes ikke", cli.node_id))?; let mut violations = Vec::new(); // 1. Basisvalidering validate_base(&node, &mut violations); // 2. node_kind-spesifikk validering match node.node_kind.as_str() { "collection" => validate_collection(&node.metadata, &mut violations), "ai_preset" => validate_ai_preset(&node.metadata, &mut violations), "orchestration" => validate_orchestration(&node.metadata, &mut violations), "media" => validate_media(&node.metadata, &mut violations), "communication" => validate_communication(&node.metadata, &mut violations), "person" => validate_person(&node, &db, &mut violations).await, "agent" => validate_agent(&node, &db, &mut violations).await, _ => {} // content, topic, team, workspace, work_item, cli_tool, system_announcement — ingen spesifikk validering } let node_kind = &node.node_kind; let node_id = node.id; let title = node.title.as_deref().unwrap_or("Uten tittel"); // Output match cli.format { OutputFormat::Json => { #[derive(Serialize)] struct Report<'a> { node_id: Uuid, node_kind: &'a str, title: &'a str, violations: &'a [Violation], valid: bool, } let report = Report { node_id, node_kind, title, valid: violations.is_empty(), violations: &violations, }; println!( "{}", serde_json::to_string_pretty(&report) .map_err(|e| format!("JSON-feil: {e}"))? ); } OutputFormat::Md => { if violations.is_empty() { println!("OK: {} (`{}`, {}) — ingen avvik", title, node_kind, node_id); } else { println!("# Valideringsavvik: {} (`{}`)\n", title, node_kind); println!("Node: `{}`\n", node_id); println!("| # | Alvorlighet | Felt | Avvik |"); println!("|---|-------------|------|-------|"); for (i, v) in violations.iter().enumerate() { let sev = match v.severity { Severity::Error => "FEIL", Severity::Warning => "ADVARSEL", }; println!("| {} | {} | `{}` | {} |", i + 1, sev, v.field, v.message); } println!("\nTotalt {} avvik.", violations.len()); } } } tracing::info!( node_id = %node_id, node_kind = %node_kind, violations = violations.len(), "Validering fullført" ); Ok(violations) } // ============================================================================= // Basisvalidering (alle node_kinds) // ============================================================================= fn validate_base(node: &NodeRow, violations: &mut Vec) { // Sjekk at node_kind er kjent if !VALID_NODE_KINDS.contains(&node.node_kind.as_str()) { violations.push(Violation { field: "node_kind".into(), message: format!("Ukjent node_kind: '{}'. Kjente: {:?}", node.node_kind, VALID_NODE_KINDS), severity: Severity::Warning, }); } // Sjekk visibility if !VALID_VISIBILITIES.contains(&node.visibility.as_str()) { violations.push(Violation { field: "visibility".into(), message: format!("Ugyldig visibility: '{}'. Gyldige: {:?}", node.visibility, VALID_VISIBILITIES), severity: Severity::Error, }); } // created_by bør være satt (advarsel, ikke feil — system-noder kan mangle) if node.created_by.is_none() { violations.push(Violation { field: "created_by".into(), message: "created_by er NULL".into(), severity: Severity::Warning, }); } // metadata bør være et objekt if !node.metadata.is_object() { violations.push(Violation { field: "metadata".into(), message: format!("metadata er ikke et JSON-objekt (type: {})", json_type_name(&node.metadata)), severity: Severity::Error, }); } } // ============================================================================= // Collection: traits-validering // ============================================================================= fn validate_collection(metadata: &serde_json::Value, violations: &mut Vec) { let traits = match metadata.get("traits") { None => return, // Ingen traits er OK Some(t) => t, }; let traits_obj = match traits.as_object() { None => { violations.push(Violation { field: "metadata.traits".into(), message: "traits må være et objekt".into(), severity: Severity::Error, }); return; } Some(o) => o, }; // Sjekk ukjente trait-navn for key in traits_obj.keys() { if !VALID_TRAITS.contains(&key.as_str()) { violations.push(Violation { field: format!("metadata.traits.{key}"), message: format!("Ukjent trait: '{key}'"), severity: Severity::Error, }); } } // mindmap-konfigurasjon if let Some(mindmap) = traits_obj.get("mindmap") { if let Some(depth) = mindmap.get("default_depth") { match depth.as_i64() { Some(d) if !(1..=3).contains(&d) => { violations.push(Violation { field: "metadata.traits.mindmap.default_depth".into(), message: format!("Må være 1–3, fikk {d}"), severity: Severity::Error, }); } None => { violations.push(Violation { field: "metadata.traits.mindmap.default_depth".into(), message: "Må være et heltall".into(), severity: Severity::Error, }); } _ => {} } } if let Some(layout) = mindmap.get("layout") { match layout.as_str() { Some(l) if l != "radial" && l != "tree" => { violations.push(Violation { field: "metadata.traits.mindmap.layout".into(), message: format!("Må være 'radial' eller 'tree', fikk '{l}'"), severity: Severity::Error, }); } None => { violations.push(Violation { field: "metadata.traits.mindmap.layout".into(), message: "Må være en streng".into(), severity: Severity::Error, }); } _ => {} } } } } // ============================================================================= // AI Preset // ============================================================================= fn validate_ai_preset(metadata: &serde_json::Value, violations: &mut Vec) { // Påkrevde streng-felter check_required_str(metadata, "prompt", violations); check_required_enum(metadata, "model_profile", VALID_MODEL_PROFILES, violations); check_required_enum(metadata, "category", VALID_AI_PRESET_CATEGORIES, violations); check_required_enum(metadata, "default_direction", VALID_AI_PRESET_DIRECTIONS, violations); check_required_str(metadata, "icon", violations); // color: hex-farge match metadata.get("color").and_then(|v| v.as_str()) { None | Some("") => { violations.push(Violation { field: "metadata.color".into(), message: "Påkrevd: hex-farge (#RRGGBB)".into(), severity: Severity::Error, }); } Some(c) if !c.starts_with('#') || c.len() != 7 => { violations.push(Violation { field: "metadata.color".into(), message: format!("Ugyldig hex-farge: '{c}'. Forventet #RRGGBB"), severity: Severity::Error, }); } _ => {} } } // ============================================================================= // Orchestration // ============================================================================= fn validate_orchestration(metadata: &serde_json::Value, violations: &mut Vec) { // trigger (objekt) match metadata.get("trigger") { None => { violations.push(Violation { field: "metadata.trigger".into(), message: "Påkrevd: trigger-objekt".into(), severity: Severity::Error, }); } Some(trigger) => { match trigger.as_object() { None => { violations.push(Violation { field: "metadata.trigger".into(), message: "Må være et objekt".into(), severity: Severity::Error, }); } Some(trigger_obj) => { // trigger.event match trigger_obj.get("event").and_then(|v| v.as_str()) { None | Some("") => { violations.push(Violation { field: "metadata.trigger.event".into(), message: "Påkrevd: trigger-event".into(), severity: Severity::Error, }); } Some(ev) if !VALID_TRIGGER_EVENTS.contains(&ev) => { violations.push(Violation { field: "metadata.trigger.event".into(), message: format!("Ukjent event: '{ev}'. Gyldige: {VALID_TRIGGER_EVENTS:?}"), severity: Severity::Error, }); } _ => {} } // trigger.conditions (valgfri, men må være objekt) if let Some(cond) = trigger_obj.get("conditions") { if !cond.is_object() { violations.push(Violation { field: "metadata.trigger.conditions".into(), message: "Må være et objekt".into(), severity: Severity::Error, }); } } } } } } // executor check_required_enum(metadata, "executor", VALID_EXECUTORS, violations); // intelligence (valgfri, 1–3) if let Some(val) = metadata.get("intelligence") { match val.as_i64() { Some(n) if !(1..=3).contains(&n) => { violations.push(Violation { field: "metadata.intelligence".into(), message: format!("Må være 1–3, fikk {n}"), severity: Severity::Error, }); } None => { violations.push(Violation { field: "metadata.intelligence".into(), message: "Må være et heltall".into(), severity: Severity::Error, }); } _ => {} } } // effort (valgfri, 1–3) if let Some(val) = metadata.get("effort") { match val.as_i64() { Some(n) if !(1..=3).contains(&n) => { violations.push(Violation { field: "metadata.effort".into(), message: format!("Må være 1–3, fikk {n}"), severity: Severity::Error, }); } None => { violations.push(Violation { field: "metadata.effort".into(), message: "Må være et heltall".into(), severity: Severity::Error, }); } _ => {} } } // compiled (valgfri, boolean) if let Some(val) = metadata.get("compiled") { if !val.is_boolean() { violations.push(Violation { field: "metadata.compiled".into(), message: "Må være en boolean".into(), severity: Severity::Error, }); } } // pipeline (valgfri, array) if let Some(val) = metadata.get("pipeline") { if !val.is_array() { violations.push(Violation { field: "metadata.pipeline".into(), message: "Må være et array".into(), severity: Severity::Error, }); } } } // ============================================================================= // Media // ============================================================================= fn validate_media(metadata: &serde_json::Value, violations: &mut Vec) { check_required_str(metadata, "cas_hash", violations); check_required_str(metadata, "mime", violations); match metadata.get("size_bytes") { None => { violations.push(Violation { field: "metadata.size_bytes".into(), message: "Påkrevd: filstørrelse i bytes".into(), severity: Severity::Error, }); } Some(v) if !v.is_i64() && !v.is_u64() => { violations.push(Violation { field: "metadata.size_bytes".into(), message: "Må være et heltall".into(), severity: Severity::Error, }); } _ => {} } } // ============================================================================= // Communication // ============================================================================= fn validate_communication(metadata: &serde_json::Value, violations: &mut Vec) { // started_at bør finnes (settes automatisk av create_node) if metadata.get("started_at").is_none() { violations.push(Violation { field: "metadata.started_at".into(), message: "Mangler started_at (bør settes automatisk)".into(), severity: Severity::Warning, }); } // ended_at (valgfri, men bør være streng hvis satt) if let Some(ended) = metadata.get("ended_at") { if !ended.is_string() { violations.push(Violation { field: "metadata.ended_at".into(), message: "Må være en ISO8601-streng".into(), severity: Severity::Error, }); } } } // ============================================================================= // Person — bør ha auth_identity // ============================================================================= async fn validate_person(node: &NodeRow, db: &sqlx::PgPool, violations: &mut Vec) { let has_auth = sqlx::query_scalar::<_, bool>( "SELECT EXISTS(SELECT 1 FROM auth_identities WHERE node_id = $1)", ) .bind(node.id) .fetch_one(db) .await; match has_auth { Ok(false) => { violations.push(Violation { field: "auth_identities".into(), message: "Person-node mangler auth_identity-kobling".into(), severity: Severity::Warning, }); } Err(e) => { tracing::warn!("Kunne ikke sjekke auth_identities: {e}"); } _ => {} } // title bør være satt (visnavn) if node.title.is_none() { violations.push(Violation { field: "title".into(), message: "Person-node bør ha title (visnavn)".into(), severity: Severity::Warning, }); } } // ============================================================================= // Agent — bør ha agent_identity // ============================================================================= async fn validate_agent(node: &NodeRow, db: &sqlx::PgPool, violations: &mut Vec) { let has_agent = sqlx::query_scalar::<_, bool>( "SELECT EXISTS(SELECT 1 FROM agent_identities WHERE node_id = $1)", ) .bind(node.id) .fetch_one(db) .await; match has_agent { Ok(false) => { violations.push(Violation { field: "agent_identities".into(), message: "Agent-node mangler agent_identity-kobling".into(), severity: Severity::Warning, }); } Err(e) => { tracing::warn!("Kunne ikke sjekke agent_identities: {e}"); } _ => {} } } // ============================================================================= // Hjelpefunksjoner // ============================================================================= fn check_required_str(metadata: &serde_json::Value, field: &str, violations: &mut Vec) { match metadata.get(field).and_then(|v| v.as_str()) { None | Some("") => { violations.push(Violation { field: format!("metadata.{field}"), message: format!("Påkrevd: ikke-tom streng"), severity: Severity::Error, }); } _ => {} } } fn check_required_enum( metadata: &serde_json::Value, field: &str, valid: &[&str], violations: &mut Vec, ) { match metadata.get(field).and_then(|v| v.as_str()) { None => { violations.push(Violation { field: format!("metadata.{field}"), message: format!("Påkrevd. Gyldige verdier: {valid:?}"), severity: Severity::Error, }); } Some(v) if !valid.contains(&v) => { violations.push(Violation { field: format!("metadata.{field}"), message: format!("Ugyldig verdi: '{v}'. Gyldige: {valid:?}"), severity: Severity::Error, }); } _ => {} } } fn json_type_name(v: &serde_json::Value) -> &'static str { match v { serde_json::Value::Null => "null", serde_json::Value::Bool(_) => "boolean", serde_json::Value::Number(_) => "number", serde_json::Value::String(_) => "string", serde_json::Value::Array(_) => "array", serde_json::Value::Object(_) => "object", } }