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:
parent
fa7b8f500d
commit
053990d222
5 changed files with 3134 additions and 2 deletions
3
tasks.md
3
tasks.md
|
|
@ -379,8 +379,7 @@ modell som brukes til hva.
|
||||||
### Øvrige manglende verktøy
|
### Ø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.
|
- [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.
|
- [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.
|
||||||
> Påbegynt: 2026-03-18T20:30
|
|
||||||
- [ ] 28.6 `synops-backup`: PG-dump + CAS-filiste + metadata-snapshot. Input: `[--full | --incremental]`. Output: backup-sti. Erstatter cron-scriptet fra 12.2.
|
- [ ] 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.
|
- [ ] 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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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-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-mail` | Send epost via msmtp (vaktmester@synops.no) | Ferdig (venter SMTP-credentials) |
|
||||||
| `synops-notify` | Send varsel via epost, WebSocket-push, eller begge | Ferdig |
|
| `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
|
## Delt bibliotek
|
||||||
|
|
||||||
|
|
|
||||||
2450
tools/synops-validate/Cargo.lock
generated
Normal file
2450
tools/synops-validate/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
20
tools/synops-validate/Cargo.toml
Normal file
20
tools/synops-validate/Cargo.toml
Normal 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" }
|
||||||
662
tools/synops-validate/src/main.rs
Normal file
662
tools/synops-validate/src/main.rs
Normal 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 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<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, 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<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",
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue