Implementer synops-node CLI-verktøy (oppgave 21.14)

Nytt CLI-verktøy for å hente og vise en node med alle tilkoblede edges.
Støtter rekursiv graf-traversering (--depth) og to output-formater
(markdown og JSON). Brukes av Claude og maskinrommet for å inspisere
graf-tilstand.

Features:
- Hent node med alle edges (inn og ut)
- Berik edges med peer-tittel og node_kind for lesbarhet
- --depth 0: bare noden, --depth 1: + edges (default), --depth 2+: traverser
- --format md (default) eller json
- Kompakt metadata-visning, forkortet innhold

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
vegard 2026-03-18 10:24:43 +00:00
parent 42c3876d67
commit 23c63c2458
10 changed files with 902 additions and 3 deletions

View file

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

View file

@ -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 | 23 |
| **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 <status>] [--tag <tag>]
synops-tasks --next # neste fra "neste"-kolonnen
synops-tasks --claim <uuid> --write # atomisk: status → pågår + assigned_to
synops-tasks --complete <uuid> --write # status → review
synops-tasks --prompt <uuid> # 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 <uuid> --write` — ta en oppgave
- `synops-tasks --complete <uuid> --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 <uuid> 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.

View file

@ -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) | — |

View file

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

View file

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

View file

@ -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 <hash> --model <model> [--initial-prompt <tekst>]",
"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

View file

@ -257,8 +257,7 @@ kaller dem direkte. Samme verktøy, to brukere.
- [x] 21.11 `synops-search`: Fulltekstsøk i grafen. Input: `<query> [--kind <node_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: `<feature_key>`. Output: spec-sammendrag, oppgavestatus, nylige commits, ubesvart feedback.
- [~] 21.14 `synops-node`: Hent/vis en node med edges. Input: `<uuid> [--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: `<uuid> [--depth N] [--format json|md]`. Output: node-data med edges.
### Infrastruktur

View file

@ -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-<verb>` (f.eks. `synops-context`)

View file

@ -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"] }

View file

@ -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<String>,
content: Option<String>,
visibility: String,
metadata: serde_json::Value,
created_at: chrono::DateTime<chrono::Utc>,
created_by: Option<Uuid>,
}
#[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<chrono::Utc>,
created_by: Option<Uuid>,
}
// --- Serialiserbare output-strukturer ---
#[derive(Serialize)]
struct NodeOutput {
id: Uuid,
node_kind: String,
title: Option<String>,
content: Option<String>,
visibility: String,
metadata: serde_json::Value,
created_at: chrono::DateTime<chrono::Utc>,
created_by: Option<Uuid>,
#[serde(skip_serializing_if = "Vec::is_empty")]
edges_out: Vec<EdgeOutput>,
#[serde(skip_serializing_if = "Vec::is_empty")]
edges_in: Vec<EdgeOutput>,
}
#[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<chrono::Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
created_by: Option<Uuid>,
/// Sammendrag av noden i andre enden av edgen
#[serde(skip_serializing_if = "Option::is_none")]
peer_title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
peer_kind: Option<String>,
}
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<NodeOutput>,
}
#[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<Uuid> = 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<Uuid> = HashSet::new();
visited.insert(cli.node_id);
// Bygg frontier fra edges på hovednoden
let mut frontier_set: HashSet<Uuid> = 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<Uuid> = 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<Option<NodeRow>, 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<EdgeRow>, Vec<EdgeRow>), 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<Uuid>,
) -> Result<HashMap<Uuid, (Option<String>, String)>, String> {
if ids.is_empty() {
return Ok(HashMap::new());
}
let ids_vec: Vec<Uuid> = ids.iter().copied().collect();
// sqlx støtter ikke IN-klausul med Vec direkte, bruk ANY
let rows = sqlx::query_as::<_, (Uuid, Option<String>, 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<EdgeOutput>, edges_in: Vec<EdgeOutput>) -> 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<EdgeRow>,
peers: &HashMap<Uuid, (Option<String>, String)>,
peer_id_fn: fn(&EdgeRow) -> Uuid,
) -> Vec<EdgeOutput> {
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!();
}