synops/tools/synops-validate/src/main.rs
vegard 053990d222 synops-validate: sjekk at en node matcher forventet skjema for sin node_kind (oppgave 28.5)
Nytt CLI-verktøy som henter en node fra PG og validerer:
- Basisfelter: visibility, created_by, metadata-type, kjent node_kind
- node_kind-spesifikk metadata: collection (traits), ai_preset,
  orchestration, media, communication
- Relasjonelle sjekker: person→auth_identities, agent→agent_identities

Samler alle avvik (feiler ikke på første treff). Output i markdown
eller JSON (--format). Exit-koder: 0=OK, 1=avvik, 2=feil.

Valideringsreglene speiler maskinrommet/src/intentions.rs slik at
skrivestien og valideringsverktøyet håndhever samme skjema.
2026-03-18 20:37:23 +00:00

662 lines
23 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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<String>,
#[allow(dead_code)]
content: Option<String>,
visibility: String,
metadata: serde_json::Value,
#[allow(dead_code)]
created_at: chrono::DateTime<chrono::Utc>,
created_by: Option<Uuid>,
}
// --- 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<Vec<Violation>, 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<Violation>) {
// 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<Violation>) {
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 13, 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<Violation>) {
// 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<Violation>) {
// 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, 13)
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 13, 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, 13)
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 13, 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<Violation>) {
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<Violation>) {
// 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<Violation>) {
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<Violation>) {
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<Violation>) {
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<Violation>,
) {
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",
}
}