diff --git a/CLAUDE.md b/CLAUDE.md index ce271c8..58f5f00 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,7 +44,7 @@ CLAUDE.md er eneste startdokument. Alt annet ligger under `docs/`: - `docs/concepts/` — Brukeropplevelser/produktområder: - `studioet.md`, `møterommet.md`, `redaksjonen.md`, `podcastfabrikken.md`, `kunnskapsgrafen.md`, `valgomaten.md`, `den_asynkrone_gjesten.md`, - `publisering.md`, `adminpanelet.md` + `publisering.md`, `adminpanelet.md`, `arbeidstavlen.md` - `docs/features/` — Tekniske byggeklosser: - Se individuelle filer for chat, kanban, kalender, meldingsboks, kunnskapsgraf, whiteboard, live transkripsjon, ressursforbruk, m.fl. diff --git a/docs/concepts/arbeidstavlen.md b/docs/concepts/arbeidstavlen.md new file mode 100644 index 0000000..ed8c65b --- /dev/null +++ b/docs/concepts/arbeidstavlen.md @@ -0,0 +1,355 @@ +# Konsept: Arbeidstavlen (Prosjektstyring) +**Filsti:** `docs/concepts/arbeidstavlen.md` + +## 1. Konsept +Alt prosjektarbeid i Synops — oppgaver, bugs, ideer, forslag, +feature-forespørsler — er noder i PostgreSQL. Én kanban-tavle +organiserer arbeidet. Claude og Vegard deler samme data via CLI +og web. Arbeidselementer oppstår organisk fra samtaler, manuelt +input, eller systemhendelser. + +## 2. Hvorfor + +`tasks.md` var en pragmatisk bootstrap. Den har tre fundamentale +problemer: + +1. **Race conditions.** Flere Claude-instanser redigerer samme fil + samtidig. Endringer overskrives, status forsvinner, git-konflikter + oppstår. +2. **Fragil parsing.** Bash-scripts parser markdown med regex + (`grep -P '^\- \[~\]'`). Formateringsendringer knekker parseren. +3. **Frakoblet fra grafen.** Oppgaver kan ikke lenkes til samtaler, + dokumenter, eller personer via edges. De lever utenfor + datamodellen. + +Løsningen: arbeidselementer er noder. De deltar i grafen som alt +annet. De har edges til samtaler (hvor ideen kom fra), til +personer (hvem som jobber), til dokumenter (hva de implementerer), +og til hverandre (foreldre/barn). + +## 3. Nodemodell + +Arbeidselementer bruker `node_kind: 'work_item'`: + +``` +work_item-node: + title: "Forbedre lydkvalitet ved opptak" + content: "Brukere rapporterer at ..." (ren tekst, søkbar) + metadata: { + "size": "small", // small | medium | large + "origin": "chat" // chat | manual | system + } +``` + +### Kategorisering via edges, ikke typer + +Det finnes ingen `node_kind` per kategori (task, bug, idea). +Distinksjonen gjøres med `tagged`-edges: + +``` +work_item ──tagged──→ board { "tag": "bug" } +work_item ──tagged──→ board { "tag": "idea" } +work_item ──tagged──→ board { "tag": "feature" } +work_item ──tagged──→ board { "tag": "tech-debt" } +``` + +Dette følger eksisterende filosofi: edges definerer hva en node er, +ikke noden selv. + +### Størrelse er organisk + +Ingen tvungen hierarki-klassifisering. Et element starter som +`small` og vokser: + +``` +"Fiks fade-bug" → small (én commit) +"Redesign lydpipeline" → medium (noen timer) +"Redesign lydpipeline" → large (har fått barn-items) + ├── belongs_to: "Nytt EDL-format" + ├── belongs_to: "FFmpeg wrapper" + └── belongs_to: "Oppdater studio-UI" +``` + +Samme tekst-primitiv-filosofi: en node kan vokse. + +## 4. Edge-modell + +| Edge-type | Source → Target | Metadata | Betydning | +|-----------|----------------|----------|-----------| +| `belongs_to` | work_item → board | `{ "position": 1.5 }` | Kortet tilhører tavlen | +| `status` | work_item → board | `{ "value": "neste" }` | Kolonneplassering | +| `assigned_to` | work_item → person/agent | — | Hvem jobber på dette | +| `belongs_to` | work_item → work_item | `{ "position": 1.0 }` | Deloppgave | +| `source_material` | work_item → content | `{ "context": "summarized", "excerpt": "..." }` | Hvor ideen kom fra | +| `tagged` | work_item → board | `{ "tag": "bug" }` | Kategorisering | +| `mentions` | work_item → any node | — | Relatert dokument/feature | + +`assigned_to` er en ny edge-type. Retning: work_item → person. +Semantikk: "dette elementet er tildelt denne personen/agenten." + +Barn-items har sin egen `status`-edge. Et barn kan være ferdig +mens forelderen fortsatt er i arbeid. + +## 5. Kanban-oppsettet + +Tavlen er en `collection`-node med `kanban`-trait: + +```jsonc +{ + "node_kind": "collection", + "title": "Synops Arbeidstavle", + "metadata": { + "traits": { + "kanban": { + "columns": ["innboks", "backlog", "neste", "pågår", "review", "ferdig"] + } + } + } +} +``` + +### Kolonnesemantikk + +| Kolonne | Hva | Hvem styrer | WIP-grense | +|---------|-----|-------------|------------| +| **Innboks** | Ubehandlede elementer fra chat og automatikk | System/@bot | Ingen | +| **Backlog** | Vurdert, ikke prioritert. Kan være hundrevis | Vegard | Ingen | +| **Neste** | Prioritert og klar. Claude plukker herfra | Vegard | ~10 | +| **Pågår** | Noen jobber aktivt på dette | Claude/Vegard | 2–3 | +| **Review** | Implementert, venter på Vegards sjekk | Claude | ~5 | +| **Ferdig** | Arkivert. Alltid søkbart | System | Ingen | + +### Bevegelsesregler + +``` +Innboks → Backlog/Neste Vegard triagerer +Backlog → Neste Vegard prioriterer +Neste → Pågår Claude/Vegard starter. assigned_to-edge opprettes +Pågår → Review Claude ferdig. Commits refererer work_item UUID +Review → Ferdig Vegard bekrefter +Review → Pågår Vegard ber om endringer +Enhver → Backlog Deprioritering +``` + +### Backlog-visning + +Backlog trenger ikke være kanban. Hundrevis av elementer er +bedre i en filtrert listevisning med søk, tags og sortering. +Bare **Neste → Pågår → Review → Ferdig** er aktive kanban-lanes. + +## 6. @bot-konvensjonen + +`@bot` er Synops sin generiske markør for AI-assistanse i +samtaler. Ruteren i maskinrommet bestemmer hvilken modell eller +agent som svarer — brukeren trenger ikke vite om det er Claude, +en annen modell, eller en spesialisert agent. + +### Slik fungerer det + +1. En bruker skriver i en kommunikasjonsnode der bot-agenten er + `member_of`. +2. Maskinrommet trigger `agent_respond`-jobb (eksisterende flow). +3. Boten svarer i samtalen. +4. **Nytt:** Hvis samtalen inneholder actionable innhold, oppretter + boten også `work_item`-noder. + +### Når opprettes work items? + +Deteksjonsheuristikker (i `synops-respond` eller et nytt +`synops-triage`-verktøy): + +- Imperative setninger: "Fiks ...", "Legg til ...", "Vi bør ..." +- Spørsmål som impliserer arbeid: "Kan vi endre ...?" +- Eksplisitte markører: "TODO:", "oppgave:", "ide:" + +### Hva opprettes? + +For hvert actionable element: +- `work_item`-node med tittel og beskrivelse +- `belongs_to`-edge → arbeidstavlen +- `status`-edge med `value: "innboks"` +- `source_material`-edge → chat-meldingen (proveniens) +- Eventuelt `assigned_to`-edge til agent-noden (om boten kan + gjøre det selv) + +Vegard triagerer innboksen og prioriterer. + +### Ruting + +`@bot` rutes av maskinrommet basert på kontekst: +- Standard: Claude (nåværende `agent_respond`-flyt) +- Fremtidig: spesialiserte agenter, andre modeller, eller + regelbaserte svar — uten endring i brukergrensesnittet + +## 7. Task runner og jobbhenting + +### CLI: `synops-tasks` + +```bash +synops-tasks --list [--status ] [--tag ] +synops-tasks --next # neste fra "neste"-kolonnen +synops-tasks --claim --write # atomisk: status → pågår + assigned_to +synops-tasks --complete --write # status → review +synops-tasks --prompt # generer prompt for autonom sesjon +``` + +`--claim` bruker `SELECT ... FOR UPDATE` for atomisk status- +endring. Ingen race conditions. + +### Task runner + +`run-next-task.sh` blir en tynn wrapper: + +```bash +TASK_JSON=$(synops-tasks --next --format json) +TASK_ID=$(echo "$TASK_JSON" | jq -r '.id') +synops-tasks --claim "$TASK_ID" --write +claude -p --dangerously-skip-permissions \ + "$(synops-tasks --prompt "$TASK_ID")" +synops-tasks --complete "$TASK_ID" --write +``` + +### Stale-deteksjon + +Items som har stått i "pågår" lenger enn 60 minutter uten +commit frigjøres tilbake til "neste": + +```bash +synops-tasks --unstale --write +``` + +## 8. CLAUDE.md som bootstrap + +CLAUDE.md forblir prosjektets startdokument — det lastes alltid +inn i konteksten. Strukturen beholdes i hovedsak, men +oppgavehenting endres: + +**Erstatt tasks.md-referanser med:** + +```markdown +## Arbeidshenting +Oppgaver hentes fra arbeidstavlen (PG). Bruk CLI: +- `synops-tasks --next` — vis neste oppgave +- `synops-tasks --list` — vis hele tavlen +- `synops-tasks --claim --write` — ta en oppgave +- `synops-tasks --complete --write` — marker ferdig + +Task runner: `./scripts/run-next-task.sh` +Se `docs/concepts/arbeidstavlen.md` for full spesifikasjon. +``` + +Doc-treet i CLAUDE.md beholdes — det er verdifull navigasjon. +Endringen er kirurgisk: erstatt oppgaverelaterte instruksjoner. + +## 9. Migrering fra tasks.md + +### Steg 1: Opprett arbeidstavle-noden +Én `collection`-node med kanban-trait og seks kolonner. +Fast UUID som dokumenteres i CLAUDE.md. + +### Steg 2: Migrer gjenstående oppgaver +Script leser `tasks.md`, oppretter `work_item`-noder for +gjenstående `- [ ]`-elementer. Ferdigstilte `[x]`-elementer +kan migreres til "ferdig" for historikk, eller forbli i +`tasks.md` som lesbart arkiv i git-historikken. + +Fase- og avhengighetsinfo overføres til `metadata` og +`belongs_to`-edges mellom relaterte items. + +### Steg 3: Switch task runner +`run-next-task.sh` bruker `synops-tasks` i stedet for +markdown-parsing. `tasks.md` redigeres ikke lenger. + +## 10. CLI og web: samme data + +``` +Terminal (Claude/Vegard) Web (SvelteKit) + │ │ + synops-tasks --list KanbanTrait panel + synops-tasks --claim Drag-and-drop kolonne + psql → direkte SQL Arbeidsflaten + │ │ + └──────────── PG ──────────────────┘ + │ + ↕ synk + │ + SpacetimeDB → WebSocket → sanntid i frontend +``` + +| Grensesnitt | Leser fra | Skriver til | +|-------------|-----------|-------------| +| `synops-tasks` CLI | PG direkte | PG direkte | +| Web (KanbanTrait) | SpacetimeDB | Maskinrommet → PG + STDB | +| `synops-respond` | PG | PG + STDB (via maskinrommet) | + +Ingen nytt UI trengs — arbeidstavlen er bare en `collection`-node +med kanban-trait. Vegard ser den på arbeidsflaten ved siden av +andre paneler. + +## 11. Komponenter + +| Feature | Rolle i Arbeidstavlen | +|---------|----------------------| +| Kanban (trait) | Visuell tavle med kolonner og drag-and-drop | +| Chat | @bot i samtaler skaper arbeidselementer | +| `synops-tasks` CLI | Claude og scripts henter/oppdaterer arbeid | +| `synops-respond` | Boten oppretter work_items fra chat-innsikt | +| Jobbkø | `run-next-task.sh` orkestrerer autonome sesjoner | + +## 12. Utviklingsfaser + +1. Opprett arbeidstavle-noden i PG med kanban-trait og seks kolonner. +2. Implementer `synops-tasks` CLI (list, next, claim, complete, prompt). +3. Migrer gjenstående oppgaver fra `tasks.md`. +4. Oppdater `run-next-task.sh` til å bruke `synops-tasks`. +5. Utvid `synops-respond` til å opprette work_items fra chat. +6. Oppdater CLAUDE.md med ny arbeidshenting-seksjon. + +## 13. Fallback: `synops-snapshot` + +PG nede betyr at Claude mister tilgang til verktøyoversikt, +arbeidstavle og arkitektur-noder. Løsningen er en generert +snapshot-fil som alltid ligger i repo: + +```bash +synops-snapshot --write + → Kobler til PG + → Henter: cli_tool-noder, work_items (neste/pågår/review), + arbeidstavle-status, aktive agenter + → Skriver til docs/snapshot.md + → Deterministisk output — ingen diff hvis ingenting endret seg +``` + +### Når den kjøres +- **Automatisk:** Post-hook i `run-next-task.sh` etter hver oppgave +- **Periodisk:** Cron (hvert 5. minutt) som sikkerhetsnett +- **Manuelt:** `synops-snapshot --write` ved behov + +### Hva den inneholder +- Alle `cli_tool`-noder med usage og metadata +- Arbeidstavle-status (antall per kolonne, items i neste/pågår) +- Siste endringer (nyeste work_items) +- Tidsstempel for når snapshot ble generert + +### CLAUDE.md-referanse +```markdown +## Fallback-kontekst +Hvis PG er utilgjengelig, se `docs/snapshot.md` for siste kjente +tilstand. Generert av `synops-snapshot`. Ikke rediger manuelt. +``` + +Filen committes og pushes som del av normal arbeidsflyt. +`synops-snapshot` er selv et CLI-verktøy og en `cli_tool`-node +i grafen. + +## 14. Avgrensning + +- Arbeidstavlen erstatter `tasks.md` for prosjektstyring. + Den erstatter **ikke** docs-filer — arkitektur, retninger, + feature-specs og konseptdokumenter lever fortsatt som + markdown i `docs/`. +- `@bot` er en chat-konvensjon, ikke en ny primitiv. Den bruker + eksisterende `member_of`-edge og `agent_respond`-flyt. +- Work items er noder i grafen — ikke en separat + prosjektstyringsmodul. De følger alle regler for noder, + edges, visibility og tilgangskontroll. diff --git a/docs/primitiver/edges.md b/docs/primitiver/edges.md index 67d0ff6..f524100 100644 --- a/docs/primitiver/edges.md +++ b/docs/primitiver/edges.md @@ -74,6 +74,7 @@ valideres i maskinrommet. | `show_notes` | Show notes for episode | `{ "variant": "ai" }` | | `chapter` | Kapittelmarkør for episode | `{ "at": "00:05:23" }` | | `source_material` | Kildemateriale (avledet node → kilde) | `{ "context": "quoted", "excerpt": "..." }` | +| `assigned_to` | Tildelt (work_item → person/agent) | — | | `derived_from` | Prosessert versjon av (f.eks. lydstudio-output → original) | — | | `has_studio` | Studio-sesjon (sesjon → medienode) | — | diff --git a/docs/primitiver/nodes.md b/docs/primitiver/nodes.md index 35bb8d4..7ce45ef 100644 --- a/docs/primitiver/nodes.md +++ b/docs/primitiver/nodes.md @@ -52,6 +52,8 @@ Kjente node_kinds: | `system_announcement` | Systemvarsler | | `ai_preset` | AI-verktøy-preset (prompt, modellprofil, kategori) | | `workspace` | Personlig arbeidsflate (én per bruker, auto-provisjonert) | +| `work_item` | Oppgave, bug, idé, forslag (se `docs/concepts/arbeidstavlen.md`) | +| `cli_tool` | CLI-verktøy med spec, bruk og metadata (se `docs/retninger/unix_filosofi.md`) | Listen vokser organisk etter behov. diff --git a/docs/retninger/maskinrommet.md b/docs/retninger/maskinrommet.md index 4c8bb00..25efdf5 100644 --- a/docs/retninger/maskinrommet.md +++ b/docs/retninger/maskinrommet.md @@ -182,6 +182,33 @@ samme maskin. Compute-separasjon er en konfigurasjon, ikke en arkitekturendring. +## Evolusjon: Maskinrommet → Portvokteren + +Maskinrommet ble bygget som en monolitt — auth, validering, +prosessering, jobbkø, STDB-synk i én binær. Med unix-filosofi- +retningen (se `docs/retninger/unix_filosofi.md`) flyttes all +prosessering til CLI-verktøy. Det som blir igjen er: + +1. **Auth** — JWT-validering, "hvem er denne requesten?" +2. **HTTP-ruting** — frontend → riktig CLI-verktøy +3. **STDB-synk** — push PG-endringer til SpacetimeDB +4. **Jobbkø-dispatch** — poll PG, spawn CLI-verktøy + +Dette er en **portvokter**, ikke et maskinrom. Når uttynningen er +ferdig, renames `maskinrommet/` til `portvokteren/` og systemd- +tjenesten oppdateres. Navnet skal reflektere rollen: vokter porten, +gjør ikke jobben. + +**Hva dette gir:** +- Portvokteren dør → frontend stopper, men CLI-verktøy fungerer. + Claude jobber videre, scripts kjører, terminalen funker. +- Et CLI-verktøy dør → bare den funksjonen stopper. Alt annet + er upåvirket. +- Portvokteren blir så enkel at den nesten aldri feiler. + +**Edge-validering og tilgangskontroll** flyttes til `synops-common` +(delt lib) — brukes av både portvokteren og CLI-verktøy. + ## Forhold til andre retninger Maskinrommet er infrastrukturen *under* de tre primitivene i diff --git a/docs/retninger/unix_filosofi.md b/docs/retninger/unix_filosofi.md index f0ff97b..42d4ce4 100644 --- a/docs/retninger/unix_filosofi.md +++ b/docs/retninger/unix_filosofi.md @@ -94,6 +94,32 @@ Kjernen som ikke bør brytes ut: Alt annet — prosessering, rendering, generering — er kandidater for CLI-verktøy. +## Verktøy som noder + +Hvert CLI-verktøy er en node i grafen (`node_kind: 'cli_tool'`): + +``` +cli_tool-node: + title: "synops-transcribe" + content: "Whisper-transkribering av lydfil fra CAS" + metadata: { + "binary": "synops-transcribe", + "usage": "--cas-hash --model [--initial-prompt ]", + "output": "JSON med segmenter, skriver til PG", + "replaces": "transcribe.rs" + } +``` + +Dette gir: +- **Maskinrommet** slår opp verktøyets spec før det spawner fra jobbkøen +- **Claude** spør grafen "hvilke verktøy finnes?" i stedet for å lese filer +- **Arbeidselementer** kan ha `mentions`-edge til verktøyet de berører +- **Verktøy** kan ha edges til hverandre (`depends_on` → synops-common) +- **Oppdatering** av verktøyets spec skjer i PG, ikke i en README + +`tools/README.md` forblir som lesbar oversikt i repo, men den +autoritative spesifikasjonen lever i grafen. + ## Bygger på - `docs/retninger/maskinrommet.md` — orkestratorrollen - `docs/infra/agent_api.md` — Claude sitt grensesnitt diff --git a/tasks.md b/tasks.md index 7c53b73..6b98d19 100644 --- a/tasks.md +++ b/tasks.md @@ -257,8 +257,7 @@ kaller dem direkte. Samme verktøy, to brukere. - [x] 21.11 `synops-search`: Fulltekstsøk i grafen. Input: ` [--kind ] [--limit N]`. Output: matchende noder med utdrag. - [x] 21.12 `synops-tasks`: Parse tasks.md og vis status. Input: `[--phase N] [--status todo|done|blocked]`. Output: formatert oppgaveliste. - [x] 21.13 `synops-feature-status`: Sjekk feature-status. Input: ``. Output: spec-sammendrag, oppgavestatus, nylige commits, ubesvart feedback. -- [~] 21.14 `synops-node`: Hent/vis en node med edges. Input: ` [--depth N] [--format json|md]`. Output: node-data med edges. - > Påbegynt: 2026-03-18T10:20 +- [x] 21.14 `synops-node`: Hent/vis en node med edges. Input: ` [--depth N] [--format json|md]`. Output: node-data med edges. ### Infrastruktur diff --git a/tools/README.md b/tools/README.md index b4c8f87..1d9ee03 100644 --- a/tools/README.md +++ b/tools/README.md @@ -20,6 +20,7 @@ eller maskinrommet-API. Ligger i PATH via symlink eller direkte kall. | `synops-search` | Fulltekstsøk i noder (title + content, norsk tsvector) | Ferdig | | `synops-tasks` | Parse tasks.md og vis oppgavestatus (filtrering på fase/status) | Ferdig | | `synops-feature-status` | Sjekk feature-status: spec, oppgaver, commits, feedback | Ferdig | +| `synops-node` | Hent/vis en node med edges (UUID, --depth, --format json/md) | Ferdig | ## Konvensjoner - Navnekonvensjon: `synops-` (f.eks. `synops-context`) diff --git a/tools/synops-node/Cargo.toml b/tools/synops-node/Cargo.toml new file mode 100644 index 0000000..6491df5 --- /dev/null +++ b/tools/synops-node/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "synops-node" +version = "0.1.0" +edition = "2024" + +[[bin]] +name = "synops-node" +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"] } diff --git a/tools/synops-node/src/main.rs b/tools/synops-node/src/main.rs new file mode 100644 index 0000000..e4be055 --- /dev/null +++ b/tools/synops-node/src/main.rs @@ -0,0 +1,469 @@ +// synops-node — Hent og vis en node med edges. +// +// Henter en node fra PostgreSQL og viser den med alle tilkoblede edges. +// Støtter rekursiv traversering med --depth for å vise nabolag i grafen. +// To output-formater: markdown (lesbart) og JSON (maskinlesbart). +// +// Output: node-data med edges til stdout (markdown eller JSON). +// Logging: structured tracing til stderr. +// +// Miljøvariabler: +// DATABASE_URL — PostgreSQL-tilkobling (påkrevd) +// +// Ref: docs/retninger/unix_filosofi.md, docs/primitiver/nodes.md, docs/primitiver/edges.md + +use clap::{Parser, ValueEnum}; +use serde::Serialize; +use std::collections::{HashMap, HashSet}; +use std::process; +use uuid::Uuid; + +/// Hent og vis en node med edges. +#[derive(Parser)] +#[command(name = "synops-node", about = "Hent/vis en node med edges")] +struct Cli { + /// Node-ID (UUID) + node_id: Uuid, + + /// Traverseringsdybde for edges (0 = bare noden, 1 = noden + direkte edges, osv.) + #[arg(long, default_value_t = 1)] + depth: u32, + + /// Output-format + #[arg(long, default_value = "md")] + format: OutputFormat, +} + +#[derive(Clone, ValueEnum)] +enum OutputFormat { + Json, + Md, +} + +// --- Database-rader --- + +#[derive(sqlx::FromRow)] +struct NodeRow { + id: Uuid, + node_kind: String, + title: Option, + content: Option, + visibility: String, + metadata: serde_json::Value, + created_at: chrono::DateTime, + created_by: Option, +} + +#[derive(sqlx::FromRow)] +struct EdgeRow { + id: Uuid, + source_id: Uuid, + target_id: Uuid, + edge_type: String, + metadata: serde_json::Value, + system: bool, + created_at: chrono::DateTime, + created_by: Option, +} + +// --- Serialiserbare output-strukturer --- + +#[derive(Serialize)] +struct NodeOutput { + id: Uuid, + node_kind: String, + title: Option, + content: Option, + visibility: String, + metadata: serde_json::Value, + created_at: chrono::DateTime, + created_by: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + edges_out: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + edges_in: Vec, +} + +#[derive(Serialize)] +struct EdgeOutput { + id: Uuid, + source_id: Uuid, + target_id: Uuid, + edge_type: String, + #[serde(skip_serializing_if = "is_empty_object")] + metadata: serde_json::Value, + #[serde(skip_serializing_if = "std::ops::Not::not")] + system: bool, + created_at: chrono::DateTime, + #[serde(skip_serializing_if = "Option::is_none")] + created_by: Option, + /// Sammendrag av noden i andre enden av edgen + #[serde(skip_serializing_if = "Option::is_none")] + peer_title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + peer_kind: Option, +} + +fn is_empty_object(v: &serde_json::Value) -> bool { + v.as_object().is_some_and(|o| o.is_empty()) +} + +#[derive(Serialize)] +struct FullOutput { + node: NodeOutput, + #[serde(skip_serializing_if = "Vec::is_empty")] + connected_nodes: Vec, +} + +#[tokio::main] +async fn main() { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "synops_node=info".parse().unwrap()), + ) + .with_target(false) + .with_writer(std::io::stderr) + .init(); + + let cli = Cli::parse(); + + if let Err(e) = run(cli).await { + eprintln!("Feil: {e}"); + process::exit(1); + } +} + +async fn run(cli: Cli) -> Result<(), String> { + let db_url = std::env::var("DATABASE_URL") + .map_err(|_| "DATABASE_URL er ikke satt".to_string())?; + + let db = sqlx::postgres::PgPoolOptions::new() + .max_connections(2) + .connect(&db_url) + .await + .map_err(|e| format!("Kunne ikke koble til database: {e}"))?; + + // Hent hovednoden + let root_node = fetch_node(&db, cli.node_id).await? + .ok_or_else(|| format!("Node {} finnes ikke", cli.node_id))?; + + // Depth 0: bare noden, ingen edges + let (edges_out, edges_in, peer_info) = if cli.depth == 0 { + (vec![], vec![], HashMap::new()) + } else { + // Hent edges for hovednoden + let (eo, ei) = fetch_edges(&db, cli.node_id).await?; + + // Samle peer-noder vi trenger for visning + let mut peer_ids: HashSet = HashSet::new(); + for e in &eo { + peer_ids.insert(e.target_id); + } + for e in &ei { + peer_ids.insert(e.source_id); + } + peer_ids.remove(&cli.node_id); + + let pi = fetch_node_summaries(&db, &peer_ids).await?; + (eo, ei, pi) + }; + + // Berik edges med peer-info + let edges_out = enrich_edges(edges_out, &peer_info, |e| e.target_id); + let edges_in = enrich_edges(edges_in, &peer_info, |e| e.source_id); + + let mut root_output = node_to_output(root_node, edges_out, edges_in); + + // Depth > 1: hent connected nodes med deres edges + let mut connected_nodes = Vec::new(); + if cli.depth > 1 { + let mut visited: HashSet = HashSet::new(); + visited.insert(cli.node_id); + + // Bygg frontier fra edges på hovednoden + let mut frontier_set: HashSet = HashSet::new(); + for e in &root_output.edges_out { + frontier_set.insert(e.target_id); + } + for e in &root_output.edges_in { + frontier_set.insert(e.source_id); + } + frontier_set.remove(&cli.node_id); + let mut frontier: Vec = frontier_set.into_iter().collect(); + + for _level in 1..cli.depth { + if frontier.is_empty() { + break; + } + + let mut next_frontier = Vec::new(); + + for nid in &frontier { + if visited.contains(nid) { + continue; + } + visited.insert(*nid); + + if let Some(node) = fetch_node(&db, *nid).await? { + let (eo, ei) = fetch_edges(&db, *nid).await?; + + // Samle nye peers + let mut new_peers = HashSet::new(); + for e in &eo { + if !visited.contains(&e.target_id) { + new_peers.insert(e.target_id); + next_frontier.push(e.target_id); + } + } + for e in &ei { + if !visited.contains(&e.source_id) { + new_peers.insert(e.source_id); + next_frontier.push(e.source_id); + } + } + + let extra_peer_info = fetch_node_summaries(&db, &new_peers).await?; + // Merge peer info + let mut combined = peer_info.clone(); + combined.extend(extra_peer_info); + + let eo = enrich_edges(eo, &combined, |e| e.target_id); + let ei = enrich_edges(ei, &combined, |e| e.source_id); + + connected_nodes.push(node_to_output(node, eo, ei)); + } + } + + frontier = next_frontier; + } + } + + tracing::info!( + node_id = %cli.node_id, + edges_out = root_output.edges_out.len(), + edges_in = root_output.edges_in.len(), + connected = connected_nodes.len(), + depth = cli.depth, + "Node hentet" + ); + + match cli.format { + OutputFormat::Json => { + let output = if connected_nodes.is_empty() { + // Enkel output uten wrapper for depth <= 1 + serde_json::to_string_pretty(&root_output) + } else { + let full = FullOutput { + node: root_output, + connected_nodes, + }; + serde_json::to_string_pretty(&full) + }; + println!("{}", output.map_err(|e| format!("JSON-serialisering feilet: {e}"))?); + } + OutputFormat::Md => { + print_markdown(&mut root_output, &connected_nodes); + } + } + + Ok(()) +} + +// --- Database-funksjoner --- + +async fn fetch_node( + db: &sqlx::PgPool, + id: Uuid, +) -> Result, String> { + sqlx::query_as::<_, NodeRow>( + "SELECT id, node_kind::text, title, content, visibility::text, \ + metadata, created_at, created_by \ + FROM nodes WHERE id = $1", + ) + .bind(id) + .fetch_optional(db) + .await + .map_err(|e| format!("DB-feil (node): {e}")) +} + +async fn fetch_edges( + db: &sqlx::PgPool, + node_id: Uuid, +) -> Result<(Vec, Vec), String> { + let edges_out = sqlx::query_as::<_, EdgeRow>( + "SELECT id, source_id, target_id, edge_type, metadata, system, created_at, created_by \ + FROM edges WHERE source_id = $1 ORDER BY edge_type, created_at", + ) + .bind(node_id) + .fetch_all(db) + .await + .map_err(|e| format!("DB-feil (edges ut): {e}"))?; + + let edges_in = sqlx::query_as::<_, EdgeRow>( + "SELECT id, source_id, target_id, edge_type, metadata, system, created_at, created_by \ + FROM edges WHERE target_id = $1 ORDER BY edge_type, created_at", + ) + .bind(node_id) + .fetch_all(db) + .await + .map_err(|e| format!("DB-feil (edges inn): {e}"))?; + + Ok((edges_out, edges_in)) +} + +async fn fetch_node_summaries( + db: &sqlx::PgPool, + ids: &HashSet, +) -> Result, String)>, String> { + if ids.is_empty() { + return Ok(HashMap::new()); + } + + let ids_vec: Vec = ids.iter().copied().collect(); + + // sqlx støtter ikke IN-klausul med Vec direkte, bruk ANY + let rows = sqlx::query_as::<_, (Uuid, Option, String)>( + "SELECT id, title, node_kind::text FROM nodes WHERE id = ANY($1)", + ) + .bind(&ids_vec) + .fetch_all(db) + .await + .map_err(|e| format!("DB-feil (peer-noder): {e}"))?; + + Ok(rows.into_iter().map(|(id, title, kind)| (id, (title, kind))).collect()) +} + +// --- Transformasjoner --- + +fn node_to_output(node: NodeRow, edges_out: Vec, edges_in: Vec) -> NodeOutput { + NodeOutput { + id: node.id, + node_kind: node.node_kind, + title: node.title, + content: node.content, + visibility: node.visibility, + metadata: node.metadata, + created_at: node.created_at, + created_by: node.created_by, + edges_out, + edges_in, + } +} + +fn enrich_edges( + edges: Vec, + peers: &HashMap, String)>, + peer_id_fn: fn(&EdgeRow) -> Uuid, +) -> Vec { + edges + .into_iter() + .map(|e| { + let pid = peer_id_fn(&e); + let (peer_title, peer_kind) = peers + .get(&pid) + .map(|(t, k)| (t.clone(), Some(k.clone()))) + .unwrap_or((None, None)); + EdgeOutput { + id: e.id, + source_id: e.source_id, + target_id: e.target_id, + edge_type: e.edge_type, + metadata: e.metadata, + system: e.system, + created_at: e.created_at, + created_by: e.created_by, + peer_title, + peer_kind, + } + }) + .collect() +} + +// --- Markdown-formattering --- + +fn print_markdown(node: &NodeOutput, connected: &[NodeOutput]) { + let title = node.title.as_deref().unwrap_or("Uten tittel"); + println!("# {title}\n"); + println!("| Felt | Verdi |"); + println!("|------|-------|"); + println!("| **ID** | `{}` |", node.id); + println!("| **Kind** | `{}` |", node.node_kind); + println!("| **Synlighet** | {} |", node.visibility); + println!("| **Opprettet** | {} |", node.created_at.format("%Y-%m-%d %H:%M")); + if let Some(cb) = node.created_by { + println!("| **Opprettet av** | `{cb}` |"); + } + + // Metadata (kompakt) + if let Some(obj) = node.metadata.as_object() { + if !obj.is_empty() { + println!("\n## Metadata\n"); + println!("```json\n{}\n```", serde_json::to_string_pretty(&node.metadata).unwrap_or_default()); + } + } + + // Content (forkortet) + if let Some(ref content) = node.content { + println!("\n## Innhold\n"); + if content.len() > 500 { + println!("{}...\n", &content[..500]); + } else { + println!("{content}\n"); + } + } + + // Edges ut + if !node.edges_out.is_empty() { + println!("## Edges ut ({} stk)\n", node.edges_out.len()); + print_edge_table(&node.edges_out, "target"); + } + + // Edges inn + if !node.edges_in.is_empty() { + println!("## Edges inn ({} stk)\n", node.edges_in.len()); + print_edge_table(&node.edges_in, "source"); + } + + // Connected nodes (depth > 1) + if !connected.is_empty() { + println!("---\n"); + println!("## Tilkoblede noder ({} stk)\n", connected.len()); + for cn in connected { + let ct = cn.title.as_deref().unwrap_or("Uten tittel"); + println!("### {ct} (`{}`)\n", cn.node_kind); + println!("ID: `{}` | {} | {}\n", cn.id, cn.visibility, cn.created_at.format("%Y-%m-%d %H:%M")); + + if !cn.edges_out.is_empty() { + println!("**Edges ut ({}):**\n", cn.edges_out.len()); + print_edge_table(&cn.edges_out, "target"); + } + if !cn.edges_in.is_empty() { + println!("**Edges inn ({}):**\n", cn.edges_in.len()); + print_edge_table(&cn.edges_in, "source"); + } + } + } +} + +fn print_edge_table(edges: &[EdgeOutput], peer_col: &str) { + println!("| Type | {} | Tittel | System |", if peer_col == "target" { "Mål" } else { "Kilde" }); + println!("|------|------|--------|--------|"); + for e in edges { + let peer_id = if peer_col == "target" { e.target_id } else { e.source_id }; + let pt = e.peer_title.as_deref().unwrap_or("-"); + let pk = e.peer_kind.as_deref().unwrap_or(""); + let kind_suffix = if pk.is_empty() { String::new() } else { format!(" ({})", pk) }; + let sys = if e.system { "ja" } else { "" }; + println!("| `{}` | `{}`{} | {} | {} |", e.edge_type, peer_id, kind_suffix, pt, sys); + + // Vis edge-metadata inline hvis den ikke er tom + if let Some(obj) = e.metadata.as_object() { + if !obj.is_empty() { + let meta_str = serde_json::to_string(&e.metadata).unwrap_or_default(); + println!("| | ↳ metadata: `{meta_str}` | | |"); + } + } + } + println!(); +}