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.
This commit is contained in:
vegard 2026-03-18 20:37:23 +00:00
parent fa7b8f500d
commit 053990d222
5 changed files with 3134 additions and 2 deletions

View file

@ -379,8 +379,7 @@ modell som brukes til hva.
### Øvrige manglende verktøy
- [x] 28.4 `synops-notify`: send varsel via epost (synops-mail), WebSocket-push, eller begge. Input: `--to <node_id> --message <tekst> [--channel email|ws|both]`. Brukes av orkestreringer og vaktmesteren.
- [~] 28.5 `synops-validate`: sjekk at en node matcher forventet skjema for sin node_kind. Input: `--node-id <uuid>`. Output: liste av avvik. Brukes av valideringsfasen og som pre-commit sjekk.
> Påbegynt: 2026-03-18T20:30
- [x] 28.5 `synops-validate`: sjekk at en node matcher forventet skjema for sin node_kind. Input: `--node-id <uuid>`. Output: liste av avvik. Brukes av valideringsfasen og som pre-commit sjekk.
- [ ] 28.6 `synops-backup`: PG-dump + CAS-filiste + metadata-snapshot. Input: `[--full | --incremental]`. Output: backup-sti. Erstatter cron-scriptet fra 12.2.
- [ ] 28.7 `synops-health`: sjekk status for alle tjenester (PG, Caddy, vaktmesteren, LiteLLM, Whisper, LiveKit). Output: JSON med status per tjeneste. Brukes av admin-dashboard og overvåking.

View file

@ -25,6 +25,7 @@ eller maskinrommet-API. Ligger i PATH via symlink eller direkte kall.
| `synops-clip` | Hent og parse webartikler (Readability + Playwright-fallback, paywall-deteksjon) | Ferdig |
| `synops-mail` | Send epost via msmtp (vaktmester@synops.no) | Ferdig (venter SMTP-credentials) |
| `synops-notify` | Send varsel via epost, WebSocket-push, eller begge | Ferdig |
| `synops-validate` | Valider at en node matcher forventet skjema for sin node_kind | Ferdig |
## Delt bibliotek

2450
tools/synops-validate/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,20 @@
[package]
name = "synops-validate"
version = "0.1.0"
edition = "2024"
[[bin]]
name = "synops-validate"
path = "src/main.rs"
[dependencies]
clap = { version = "4", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "postgres", "uuid", "chrono", "json"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
uuid = { version = "1", features = ["v7", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
synops-common = { path = "../synops-common" }

View file

@ -0,0 +1,662 @@
// 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",
}
}