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
|
||||
|
||||
- [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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
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