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:
parent
42c3876d67
commit
23c63c2458
10 changed files with 902 additions and 3 deletions
|
|
@ -44,7 +44,7 @@ CLAUDE.md er eneste startdokument. Alt annet ligger under `docs/`:
|
||||||
- `docs/concepts/` — Brukeropplevelser/produktområder:
|
- `docs/concepts/` — Brukeropplevelser/produktområder:
|
||||||
- `studioet.md`, `møterommet.md`, `redaksjonen.md`, `podcastfabrikken.md`,
|
- `studioet.md`, `møterommet.md`, `redaksjonen.md`, `podcastfabrikken.md`,
|
||||||
`kunnskapsgrafen.md`, `valgomaten.md`, `den_asynkrone_gjesten.md`,
|
`kunnskapsgrafen.md`, `valgomaten.md`, `den_asynkrone_gjesten.md`,
|
||||||
`publisering.md`, `adminpanelet.md`
|
`publisering.md`, `adminpanelet.md`, `arbeidstavlen.md`
|
||||||
- `docs/features/` — Tekniske byggeklosser:
|
- `docs/features/` — Tekniske byggeklosser:
|
||||||
- Se individuelle filer for chat, kanban, kalender, meldingsboks,
|
- Se individuelle filer for chat, kanban, kalender, meldingsboks,
|
||||||
kunnskapsgraf, whiteboard, live transkripsjon, ressursforbruk, m.fl.
|
kunnskapsgraf, whiteboard, live transkripsjon, ressursforbruk, m.fl.
|
||||||
|
|
|
||||||
355
docs/concepts/arbeidstavlen.md
Normal file
355
docs/concepts/arbeidstavlen.md
Normal 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 | 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 <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.
|
||||||
|
|
@ -74,6 +74,7 @@ valideres i maskinrommet.
|
||||||
| `show_notes` | Show notes for episode | `{ "variant": "ai" }` |
|
| `show_notes` | Show notes for episode | `{ "variant": "ai" }` |
|
||||||
| `chapter` | Kapittelmarkør for episode | `{ "at": "00:05:23" }` |
|
| `chapter` | Kapittelmarkør for episode | `{ "at": "00:05:23" }` |
|
||||||
| `source_material` | Kildemateriale (avledet node → kilde) | `{ "context": "quoted", "excerpt": "..." }` |
|
| `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) | — |
|
| `derived_from` | Prosessert versjon av (f.eks. lydstudio-output → original) | — |
|
||||||
| `has_studio` | Studio-sesjon (sesjon → medienode) | — |
|
| `has_studio` | Studio-sesjon (sesjon → medienode) | — |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,8 @@ Kjente node_kinds:
|
||||||
| `system_announcement` | Systemvarsler |
|
| `system_announcement` | Systemvarsler |
|
||||||
| `ai_preset` | AI-verktøy-preset (prompt, modellprofil, kategori) |
|
| `ai_preset` | AI-verktøy-preset (prompt, modellprofil, kategori) |
|
||||||
| `workspace` | Personlig arbeidsflate (én per bruker, auto-provisjonert) |
|
| `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.
|
Listen vokser organisk etter behov.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -182,6 +182,33 @@ samme maskin.
|
||||||
|
|
||||||
Compute-separasjon er en konfigurasjon, ikke en arkitekturendring.
|
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
|
## Forhold til andre retninger
|
||||||
|
|
||||||
Maskinrommet er infrastrukturen *under* de tre primitivene i
|
Maskinrommet er infrastrukturen *under* de tre primitivene i
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,32 @@ Kjernen som ikke bør brytes ut:
|
||||||
Alt annet — prosessering, rendering, generering — er kandidater for
|
Alt annet — prosessering, rendering, generering — er kandidater for
|
||||||
CLI-verktøy.
|
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å
|
## Bygger på
|
||||||
- `docs/retninger/maskinrommet.md` — orkestratorrollen
|
- `docs/retninger/maskinrommet.md` — orkestratorrollen
|
||||||
- `docs/infra/agent_api.md` — Claude sitt grensesnitt
|
- `docs/infra/agent_api.md` — Claude sitt grensesnitt
|
||||||
|
|
|
||||||
3
tasks.md
3
tasks.md
|
|
@ -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.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.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.
|
- [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.
|
- [x] 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
|
|
||||||
|
|
||||||
### Infrastruktur
|
### Infrastruktur
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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-search` | Fulltekstsøk i noder (title + content, norsk tsvector) | Ferdig |
|
||||||
| `synops-tasks` | Parse tasks.md og vis oppgavestatus (filtrering på fase/status) | 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-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
|
## Konvensjoner
|
||||||
- Navnekonvensjon: `synops-<verb>` (f.eks. `synops-context`)
|
- Navnekonvensjon: `synops-<verb>` (f.eks. `synops-context`)
|
||||||
|
|
|
||||||
19
tools/synops-node/Cargo.toml
Normal file
19
tools/synops-node/Cargo.toml
Normal 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"] }
|
||||||
469
tools/synops-node/src/main.rs
Normal file
469
tools/synops-node/src/main.rs
Normal 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!();
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue