2121 lines
113 KiB
Markdown
2121 lines
113 KiB
Markdown
# Ausbaustufen — Munin
|
||
|
||
Was bewusst **nicht** gebaut wurde, warum, und woran man merkt, dass es Zeit dafür ist.
|
||
|
||
Der Sinn dieses Dokuments ist, dass niemand — auch nicht wir selbst in einem
|
||
halben Jahr — dieselben Fragen noch einmal von vorne durchdenken muss. Wo Zahlen
|
||
stehen, sind sie gemessen, nicht geschätzt; die Messumgebung ist jeweils genannt.
|
||
|
||
---
|
||
|
||
## Teil 1 — Entschieden und aufgeschoben
|
||
|
||
Diese Punkte sind durchgerechnet. Die Entscheidung lautet jeweils „später",
|
||
nicht „nie".
|
||
|
||
### 1.1 FTS5 statt eigenem Suchindex
|
||
|
||
**Stand heute:** Ein handgeschriebener BM25-Index im Arbeitsspeicher
|
||
(`internal/search`, 464 Zeilen plus 195 Zeilen Tests), einer je Projekt, beim
|
||
ersten Zugriff aufgebaut.
|
||
|
||
**Gemessen** (10.000 Einträge à 150 Wörter, Wortschatz 5.000, Zipf-verteilt):
|
||
|
||
| | eigener Index | FTS5 |
|
||
|---|---|---|
|
||
| Arbeitsspeicher | 65 MB | 0 |
|
||
| Platte | 0 | 54 MB |
|
||
| Aufbau | 171 ms | 280 ms |
|
||
| Suche | 12 µs | 208 µs |
|
||
|
||
FTS5 ist im reinen Go-Treiber verfügbar (`ENABLE_FTS5`), der statische Build
|
||
bliebe also erhalten.
|
||
|
||
**Warum aufgeschoben:**
|
||
|
||
- Der Suchindex ist **abgeleitete Daten**. Er lässt sich jederzeit aus `entries`
|
||
neu aufbauen — eine Umstellung später bedeutet keine Datenmigration. Der übliche
|
||
Grund, so etwas früh zu erledigen, greift hier nicht.
|
||
- Es löst nicht den eigentlichen Engpass. Der ist die semantische Trefferqualität
|
||
(siehe 2.2), und die kann FTS5 so wenig wie BM25 von Hand.
|
||
- FTS5 gibt es nur in SQLite. Der eigene Index ist von der Datenbank unabhängig
|
||
und hält damit den Weg zu PostgreSQL offen.
|
||
|
||
**Auslöser für die Umstellung:**
|
||
|
||
- Ein Projekt jenseits von grob 50.000 Einträgen (~300 MB Arbeitsspeicher allein
|
||
für dessen Index)
|
||
- Der Start dauert spürbar, weil viele große Projekte gleichzeitig angefragt werden
|
||
- Phrasensuche, `NEAR` oder boolesche Operatoren werden gebraucht — die bringt
|
||
FTS5 mit, der eigene Index nicht
|
||
|
||
**Aufwand:** ein bis zwei Tage. Virtuelle Tabelle mit `content=`-Verweis auf
|
||
`entries`, Befüllung aus dem Bestand, `internal/search` entfällt.
|
||
|
||
### 1.2 Eine SQLite-Datei je Projekt
|
||
|
||
> **Nachtrag:** Mit 4.4 gibt es inzwischen eine Datei je *Workspace* und eine
|
||
> zentrale Kontroll-Datenbank für die Zuordnung Key → Workspace. Die hier
|
||
> besprochene Aufteilung je *Projekt* bleibt aufgeschoben. Die Messwerte unten
|
||
> stammen aus der Zeit mit nur einer Datei und gelten jetzt je Workspace-Datei.
|
||
|
||
**Stand damals:** Eine Datei für alles, zwei Verbindungspools — acht Verbindungen
|
||
zum Lesen, eine zum Schreiben.
|
||
|
||
**Gemessen:**
|
||
|
||
| | |
|
||
|---|---|
|
||
| Schreibvorgänge über die eine Schreibverbindung | 8.656/s |
|
||
| Lesen in Projekt A, ungestört | 82 µs |
|
||
| Lesen in Projekt A, während drei Projekte im Dauerlauf schreiben | 121 µs |
|
||
|
||
Hundert Agenten, die jede Sekunde einen Eintrag ablegen, sind 100
|
||
Schreibvorgänge/s — etwa **1 % der Kapazität**.
|
||
|
||
**Nachmessung über den ganzen Weg (2026-10-06):** `last_test.go` lässt Agenten
|
||
über echtes HTTP und MCP arbeiten, mit Key-Prüfung, Protokoll und Suchindex
|
||
(Ausführung und alle Ergebnisse in `LASTTEST.md`). 128 Agenten, je 500 Einträge vorab,
|
||
30 % Schreiben, 20 umkämpfte Bezeichner je Projekt, 10 Sekunden, Testrechner
|
||
mit 10 Kernen, Clients und Server auf derselben Maschine:
|
||
|
||
| Aufstellung | `remember` je s | `remember` p50 / p99 | `recall` p50 / p99 | Fehler |
|
||
|---|---|---|---|---|
|
||
| ein Projekt | 1.427 | 68 ms / 269 ms | 4,5 ms / 23 ms | 0 |
|
||
| ein Workspace, 4 Projekte | 1.355 | 73 ms / 286 ms | 4,5 ms / 24 ms | 0 |
|
||
| 4 Workspaces, je 1 Projekt | 1.328 | 60 ms / 205 ms | 15 ms / 36 ms | 0 |
|
||
|
||
- Kein Schreibvorgang ging verloren, der Suchindex kannte in jedem Lauf den
|
||
letzten Stand.
|
||
- Projekte im selben Workspace teilen sich die Schreibverbindung: Mehrere
|
||
Projekte verhalten sich wie eines. Getrennte Workspaces schreiben etwas
|
||
schneller, der Rechner insgesamt bleibt aber die Grenze (die Suche wird
|
||
langsamer, weil alles um dieselben Kerne konkurriert).
|
||
- Gegenüber 8.656/s oben kostet der volle Weg etwa das Sechsfache. 1.300
|
||
Schreibvorgänge je *Sekunde* stehen einer Vorgabe von 1.000 je *Stunde* und
|
||
Projekt gegenüber (Schreibbudget 3.8). Last ist bei echter Nutzung kein Thema.
|
||
|
||
Dabei gefunden und behoben: Schrieben zwei Agenten gleichzeitig denselben
|
||
Eintrag, konnte der Suchindex eine ältere Fassung behalten als die Datenbank.
|
||
`Registry.Refresh` liest seitdem nach jedem Schreiben aus der Datenbank nach,
|
||
statt den mitgegebenen Stand zu übernehmen. Außerdem hat seitdem jedes Projekt
|
||
seine eigene Sperre in der Registry: Der Aufbau eines großen Index nach einem
|
||
Neustart lässt die anderen Projekte desselben Workspace nicht mehr warten.
|
||
|
||
**Warum aufgeschoben:** Es bräuchte trotzdem eine zentrale Datenbank, denn drei
|
||
Dinge braucht man, *bevor* man das Projekt kennt: `ResolveKeyHash` (Key →
|
||
Projekt), `ListProjects` und `EnsureWorkspace`. Daraus folgt:
|
||
|
||
- Globales Protokoll und „Projekte mit Eintragszahl" werden aus einer Abfrage
|
||
N Abfragen plus Zusammenführen in Go
|
||
- Migrationen laufen über jede Datei, auch beim Anlegen eines Projekts
|
||
- Verbindungsverwaltung mit Verdrängung nötig (50 Projekte × 9 Verbindungen)
|
||
- Kein gemeinsamer Commit über zentrale und Projektdatenbank — „Projekt anlegen
|
||
samt erstem Key" wäre nicht mehr atomar
|
||
|
||
**Auslöser:**
|
||
|
||
- Anhaltend über ~1.000 Schreibvorgänge/s (10 % der Kapazität)
|
||
- Ein Projekt jenseits weniger GB, sodass Sicherung und `VACUUM` unangenehm werden
|
||
- Verbindliche Löschzusagen je Projekt, bei denen „Zeilen gelöscht" nicht reicht
|
||
|
||
**Billigere Schritte davor:** mehrere Einträge in einer Transaktion bündeln,
|
||
WAL-Prüfpunkte seltener setzen.
|
||
|
||
### 1.3 Zeichenketten im Suchindex teilen
|
||
|
||
`internal/search` hält in `docTerms` je Dokument eine Liste **aller seiner
|
||
verschiedenen Wörter** als eigene Zeichenketten. Bei 10.000 Einträgen sind das
|
||
über eine Million einzeln abgelegte Zeichenketten, obwohl im Projekt vielleicht
|
||
nur 20.000 verschiedene Wörter vorkommen.
|
||
|
||
Die Liste ist nötig: ohne sie müsste beim Ändern oder Löschen der komplette Index
|
||
durchsucht werden. Sie ließe sich aber verkleinern, indem gleiche Wörter dieselbe
|
||
Zeichenkette teilen. **Geschätzt ein Drittel bis die Hälfte** des Speichers.
|
||
|
||
**Warum aufgeschoben:** Löst dasselbe Problem wie 1.1, nur halb. Wenn der
|
||
Speicher wirklich drückt, ist FTS5 der gründlichere Schritt.
|
||
|
||
### 1.4 sqlc für die Datenschicht
|
||
|
||
War empfohlen, dann verworfen: sqlc ist nicht installiert, und ein zusätzlicher
|
||
Codegenerator bedeutet, dass `go build` allein nicht mehr reicht. Handgeschriebenes
|
||
`database/sql` ist außerdem besser lesbar für jemanden, der Go lernt.
|
||
|
||
**Nachrüstbar,** ohne eine Zeile SQL zu ändern — das SQL ist bereits so
|
||
geschrieben, wie sqlc es erwartet.
|
||
|
||
---
|
||
|
||
## Teil 2 — Trefferqualität
|
||
|
||
**Hier liegt der eigentliche Engpass.** Alles andere in diesem Dokument ist
|
||
Ressourcenoptimierung; das hier entscheidet darüber, ob ein Agent findet, was er
|
||
sucht.
|
||
|
||
### 2.1 Umlaute im Tokenizer falten — ✅ umgesetzt
|
||
|
||
`tokenize` bildet `ä→a`, `ö→o`, `ü→u` und `ß→ss` ab, beim Indizieren wie beim
|
||
Suchen (siehe `foldUmlauts` in `internal/search/index.go`).
|
||
|
||
```
|
||
prüfung ↔ prufung ✓
|
||
größe ↔ grosse ✓
|
||
prüfung ↔ pruefung ✗
|
||
```
|
||
|
||
**Korrektur zur früheren Fassung dieses Abschnitts:** Dort stand, die Umschreibung
|
||
mit e (`ue`, `oe`, `ae`) werde mit abgedeckt. Das stimmt nicht. Sie ließe sich nur
|
||
abdecken, indem man auch `ue` zu `u` zusammenzieht — dann würde aber aus „neue"
|
||
ein „nu" und aus „Steuer" ein „Stur". Der Schaden wäre größer als der Nutzen.
|
||
Die Punkte wegzulassen ist ohnehin die weit häufigere Schreibweise.
|
||
|
||
Gegenüber FTS5 mit `remove_diacritics` ein Vorteil: Das faltet zwar Umlaute, aber
|
||
kein `ß` — dort findet „grosse" die „Größe" nicht.
|
||
|
||
### 2.2 Semantische Suche
|
||
|
||
**Problem:** BM25 vergleicht Wörter. Fragt ein Agent „wie deployen wir", findet er
|
||
einen Eintrag namens „Release-Prozess" nicht — kein Wort passt.
|
||
|
||
**Lösung:** Einbettungen (Embeddings) plus Vektorsuche, kombiniert mit BM25
|
||
(„hybride Suche"): BM25 ist stark bei seltenen, exakten Begriffen — Fehlercodes,
|
||
Eigennamen —, die Vektorsuche bei Umschreibungen.
|
||
|
||
**Bauform:** ein eigener kleiner Dienst, angebunden über HTTP. Python hat dafür
|
||
das deutlich reichere Ökosystem; die Go-Anwendung bliebe ein Binary.
|
||
|
||
**Aufwand:** mehrere Tage, plus laufender Betrieb eines zweiten Dienstes und
|
||
eines Einbettungsmodells.
|
||
|
||
**Vorher versuchen:** die `instructions` an echten Anfragen schärfen (siehe 3.1).
|
||
Erfahrungsgemäß holt das einen guten Teil des Unterschieds, kostet aber nichts.
|
||
|
||
**Umgesetzt statt Embeddings (2026-10-14):** Die Clients sind selbst
|
||
Sprachmodelle und kennen Synonyme. Das nutzt Munin jetzt an beiden Enden:
|
||
|
||
- **Beim Suchen:** `recall` nimmt `synonyms` an (höchstens 10), die in derselben
|
||
Suche mitlaufen. instructions und Werkzeugbeschreibung bitten darum, und ein
|
||
leeres Ergebnis rät zuerst zur erneuten Suche mit anderen Begriffen.
|
||
- **Beim Schreiben:** Einträge enden mit einer Zeile `Auch gesucht als: …`.
|
||
Bewusst im Inhalt statt als Schlagworte: Die Agenten-Vorlagen verlangen
|
||
Schlagworte sparsam aus dem bestehenden Bestand, und die Zeile bleibt für
|
||
Menschen sichtbar und bearbeitbar.
|
||
|
||
Ob Agenten das befolgen, zeigt erst der Betrieb — Stichproben in neuen
|
||
Einträgen (steht die Zeile da?) sind der einfachste Test.
|
||
|
||
**Lokales Modell statt Gateway:** geprüft und verworfen. Das Modell braucht
|
||
grob 0,5–2 GB Speicher (einmal, nicht je Workspace) und in Go entweder cgo —
|
||
dann ist der statische Build weg — oder einen zweiten Dienst. Der Zusatznutzen
|
||
gegenüber Synonymen vom Client ist klein.
|
||
|
||
**pgvector: geprüft und nicht nötig (2026-10-16).** Mit PostgreSQL (4.10) liegt
|
||
pgvector nahe. Es löst aber nur den kleineren Teil: Vektoren speichern und
|
||
vergleichen. Das Einbettungsmodell, an dem diese Stufe scheitert, bleibt
|
||
nötig. Außerdem gibt es pgvector nur in PostgreSQL, die Suche verhielte sich
|
||
dann je nach Datenbank anders. Und bei dieser Größe braucht es keinen Index:
|
||
10.000 Einträge × 1.024 Werte sind rund 40 MB, ein Vergleich mit allen dauert
|
||
in Go wenige Millisekunden. **Falls semantische Suche doch kommt:** Vektoren
|
||
als `BLOB`/`bytea` in einer Tabelle (gleich in beiden Datenbanken), Suche im
|
||
Speicher wie beim BM25-Index. pgvector erst bei Millionen von Vektoren.
|
||
|
||
### 2.2a Ausschnitt an der Fundstelle — ✅ umgesetzt (2026-10-14)
|
||
|
||
`entry.Snippet` suchte die ganze Anfrage wörtlich. Bei Agenten-Fragen („wie
|
||
deployen wir …") traf das nie, der Ausschnitt zeigte immer den Textanfang.
|
||
Jetzt: erst die ganze Anfrage, sonst das längste Wort (ab 4 Zeichen), das im
|
||
Text vorkommt; in `recall` samt Synonymen. Hervorgehoben wird dasselbe Wort.
|
||
|
||
### 2.3 Lange Einträge zerlegen
|
||
|
||
`get` liefert einen Eintrag ganz oder gar nicht. Ein Eintrag mit 3.000 Wörtern
|
||
belegt beim Agenten Kontext, von dem er zwei Absätze braucht.
|
||
|
||
**Lösung:** Einträge in Abschnitte zerlegen (an Überschriften) und abschnittsweise
|
||
zurückgeben, mit Verweis auf den vollständigen Eintrag.
|
||
|
||
**Vorher prüfen:** ob überhaupt so lange Einträge entstehen. Bei einem
|
||
Agentengedächtnis sind die meisten Einträge kurz — dann wäre das eine Lösung ohne
|
||
Problem.
|
||
|
||
### 2.4 Wortvarianten, Stoppwörter, Tippfehler — ✅ umgesetzt (2026-10-14)
|
||
|
||
Umgesetzt in `internal/search/variants.go`. Zu jedem Suchwort sucht der Index
|
||
mehrere Varianten, jede schwächer gewichtet als ein exakter Treffer (1):
|
||
|
||
| Variante | Beispiel | Gewicht | ab Länge |
|
||
|---|---|---|---|
|
||
| Wortanfang | „deploy" → Deployment | 0,5 | 3 |
|
||
| Endung gekürzt, dann Wortanfang | „deployen" → Deployment | 0,5 | Stamm ≥ 4 |
|
||
| Teilwort | „prozess" → Releaseprozess | 0,3 | 5 |
|
||
| Tippfehler (nur `SearchFuzzy`) | „deploymnet" → Deployment | 0,3 | 4 |
|
||
|
||
Je Eintrag zählt nur die beste Variante eines Suchworts.
|
||
|
||
**Was sich dabei geändert hat:**
|
||
|
||
- **Fehler behoben:** Der Wortanfang griff früher nur, wenn das Suchwort
|
||
nirgends exakt vorkam. Stand „deploy" in irgendeinem Eintrag, fand „deploy"
|
||
den „Deployment-Prozess" nicht mehr.
|
||
- **Stoppwörter** („wie", „wir", „the" …) werden aus der *Anfrage* entfernt,
|
||
nicht aus dem Index. Agenten stellen laut `instructions` ganze Fragen; die
|
||
Füllwörter fanden über den Wortanfang sonst Unpassendes („wir" → „Wirkung").
|
||
- **Endungen:** bewusst kein vollständiger Wortstammbildner, nur `-en`, `-es`,
|
||
`-e`, `-n`, `-s`. Kein `-er`, sonst fände „Server" auch „Service".
|
||
- **Tippfehler** gleicht nur die Weboberfläche aus (`SearchFuzzy`), und nur
|
||
für Suchwörter, die sonst nichts finden. `recall` bleibt ohne.
|
||
|
||
**Laufzeit:** Der Wortschatz wird jetzt bei jedem Suchwort einmal durchlaufen,
|
||
nicht mehr nur beim Fehlschlag. `bench_test.go` (10.000 Einträge, 20.000
|
||
Wörter, Frage mit drei Suchwörtern): 0,37 ms vorher, 1,7 ms nachher, gut die
|
||
Hälfte davon Speicherbereinigung. Für Agenten wie für die Vorschläge beim
|
||
Tippen unerheblich.
|
||
|
||
**Offen:** Synonyme („Release" ↔ „Deployment") kann das nicht — dafür wäre 2.2
|
||
nötig. Und ob die Gewichte gut gewählt sind, zeigt erst der Betrieb.
|
||
|
||
---
|
||
|
||
## Teil 3 — Funktionen für Agenten
|
||
|
||
### 3.1 Die `instructions` an echten Anfragen schärfen
|
||
|
||
Der Text in `internal/mcpserver/server.go` steht bei jedem Client im Kontext und
|
||
entscheidet darüber, ob ein Agent vor dem Schreiben sucht oder blind Dubletten
|
||
anlegt.
|
||
|
||
**Vorgehen:** Nach ein paar Wochen Betrieb ins Protokoll schauen — wie viele
|
||
`remember`-Aufrufe legen neu an, wie viele aktualisieren? Ein hoher Anteil
|
||
Neuanlagen bei ähnlichen Themen heißt: Die Anleitung greift nicht.
|
||
|
||
**Kostet nichts und wirkt sofort.** Deshalb steht es hier ganz oben.
|
||
|
||
**Messgrundlage ✅ (2026-10-14):** Jeder `recall` landet im Suchprotokoll
|
||
(`search_log`, migrations/006, 30 Tage Aufbewahrung). Die Nutzungsseite zeigt
|
||
den Anteil der Suchen ohne Treffer, den Anteil mit Synonymen und die häufigsten
|
||
Anfragen ohne Treffer. Bewusst eine eigene Tabelle statt `audit_events`: Suchen
|
||
sind viel häufiger als Schreibvorgänge und würden das Protokoll zudecken, und
|
||
Anfragen sind Inhalt — sie gehen mit dem Projekt und haben eine kürzere Frist.
|
||
|
||
### 3.2 Importer als Gegenstück zum Export — ✅ umgesetzt
|
||
|
||
Der Markdown-Export ist heute eine Einbahnstraße. Das Dateiformat trägt alle
|
||
Felder im Kopf, ein Einlesen wäre also möglich.
|
||
|
||
**Wozu:** Umzug auf einen anderen Server, Wiederherstellung aus der Sicherung,
|
||
Erstbefüllung aus vorhandener Dokumentation.
|
||
|
||
**Aufwand:** ein halber Tag. Zu klären wäre, was bei einem Zusammenstoß passiert
|
||
— überschreiben, überspringen oder abbrechen.
|
||
|
||
**✅ Umgesetzt 2026-09-25:**
|
||
|
||
- **Weg:** ZIP-Upload unter `/p/{projekt}/import`, Rolle `admin`. Bewusst kein
|
||
Kommandozeilenbefehl: Der Suchindex liegt im Speicher des laufenden
|
||
Servers, ein zweiter Prozess könnte ihn nicht nachziehen. Nebenbei können
|
||
Projekt-Admins so selbst importieren.
|
||
- **Zusammenstoß:** *überspringen* (Standard) oder *überschreiben*. Beim
|
||
Überschreiben wandert der alte Stand in den Verlauf, der Autor ist dann der
|
||
importierende Zugang. Ein *Abbrechen* gibt es nicht, dafür eine Vorschau,
|
||
die beim ersten Hochladen angehakt ist. Dateien, die nichts ändern würden,
|
||
werden nie geschrieben. Ein wiederholter Import ist damit harmlos.
|
||
- **Papierkorb:** Im Modus *überspringen* holt ein Import keinen gelöschten
|
||
Eintrag zurück. Das war die Falle, weil `AllEntries` den Papierkorb nicht
|
||
sieht.
|
||
- **Felder:** Beim Anlegen bleiben Autor, Herkunft, Prüfvermerk und beide
|
||
Zeitstempel erhalten (neu: `EntryWrite.CreatedAt/UpdatedAt`). Sonst sähe
|
||
nach einer Wiederherstellung alles frisch aus, und 3.5 liefe ins Leere. Der
|
||
Verlauf wird nicht übernommen, der Export enthält ihn nicht.
|
||
- **Fremde Dateien:** Ohne Kopf gilt der Dateiname als Bezeichner und die
|
||
erste Überschrift als Titel. Fremde Köpfe (Hugo, Obsidian) werden gelesen,
|
||
soweit sie `title` und `tags` betreffen. Kein YAML-Paket, ein kleiner
|
||
eigener Leser in `internal/importer/parse.go`.
|
||
- **Grenzen:** 20 MB Upload, 5.000 Dateien, 1 MB je Datei, 100 MB ausgepackt.
|
||
Gezählt wird, was tatsächlich ausgepackt wird, nicht was das Archiv
|
||
behauptet.
|
||
- **Protokoll:** ein Eintrag `entry_import` je Import mit Zusammenfassung,
|
||
nicht einer je Datei. So verzerrt ein einmaliger Import die
|
||
Nutzungsübersicht (4.6) nicht.
|
||
- **Nebenbei behoben:** Das 1-MB-Limit für Formulare lag über dem ganzen Mux,
|
||
auch über `/mcp`. Zwei geschachtelte `MaxBytesReader` begrenzen auf den
|
||
kleineren Wert, MCP war also nie bei den dokumentierten 4 MB, und
|
||
`MAX_API_BODY_BYTES` wirkte nur nach unten. Jetzt wählt `LimitBodies` die
|
||
Grenze einmal außen je Route.
|
||
|
||
### 3.3 Dubletten über Ähnlichkeit erkennen — ✅ umgesetzt (2026-10-14)
|
||
|
||
**Umsetzung:** `internal/duplicates` (bis 2026-10-16 `internal/mcpserver/dubletten.go`;
|
||
seitdem eigenes Paket, weil auch die Weboberfläche es nutzt, siehe 5.7). Bei `remember` mit neuem
|
||
Bezeichner sucht der Index nach dem Titel; die zehn besten Treffer werden
|
||
genauer verglichen. Maß ist nicht der BM25-Wert (kein fester Wertebereich),
|
||
sondern der Anteil gemeinsamer Titelwörter (`search.Coverage`, in beide
|
||
Richtungen, samt Wortanfang, Endung und Teilwort). Die Zeile
|
||
„Auch gesucht als: …" zählt zum Titel — so findet „Release-Ablauf" auch den
|
||
„Deployment-Prozess", wenn dort „release" steht.
|
||
|
||
Ab 50 % Überdeckung: nichts schreiben, `judgment_required` mit bis zu fünf
|
||
`candidates` zurückgeben, kein Fehler. `force: true` legt trotzdem an.
|
||
Aktualisieren (Bezeichner vorhanden) wird nie geprüft.
|
||
|
||
**Bekannter Fehlalarm:** „Server-Zugang" gegen „Datenbank-Zugang" (50 %).
|
||
Kostet den Agenten einen zweiten Aufruf mit `force`. Ob die Schwelle passt,
|
||
zeigt der Betrieb.
|
||
|
||
**Ursprüngliche Beschreibung:**
|
||
|
||
Heute verhindert nur der gleiche Bezeichner eine Dublette. Wählt ein Agent einen
|
||
neuen Slug für dasselbe Thema, steht die Erkenntnis zweimal da.
|
||
|
||
**Lösung:** Bei `remember` mit neuem Bezeichner den vorhandenen Suchindex über
|
||
Titel und Inhalt laufen lassen. Liegt ein Treffer über einer Schwelle, den
|
||
Eintrag **nicht** anlegen, sondern die Kandidaten zurückgeben und den Agenten
|
||
entscheiden lassen:
|
||
|
||
```json
|
||
{
|
||
"created": false,
|
||
"judgment_required": true,
|
||
"candidates": [
|
||
{ "slug": "deployment-prozess", "title": "Deployment-Prozess", "score": 0.81 }
|
||
],
|
||
"hint": "Passt einer davon? Dann mit dessen slug erneut remember aufrufen.
|
||
Sonst mit force: true anlegen."
|
||
}
|
||
```
|
||
|
||
Der entscheidende Punkt ist, die Entscheidung **zurückzugeben** statt sie zu
|
||
treffen. Ein Agent, der einen Hinweis bekommt, korrigiert sich; einer, dem der
|
||
Aufruf stillschweigend abgelehnt wird, versucht es mit einem noch schlechteren
|
||
Bezeichner.
|
||
|
||
Braucht keine Einbettungen — der BM25-Index liegt bereits vor.
|
||
|
||
**Vorbild:** [Engram](https://github.com/Gentleman-Programming/engram) macht genau
|
||
das über FTS5 (`candidates[]` plus `judgment_required` bei `mem_save`). Das Muster
|
||
ist gut und ließe sich mit unserem Index eins zu eins übernehmen.
|
||
|
||
**Nachgebessert 2026-10-16: gemeinsames Präfix im Titel.** Im Betrieb mit
|
||
Titeln nach dem Muster „Projekt – Thema“ wurde fast jeder neue Eintrag
|
||
gemeldet: „Munin – Architektur“ gegen „Munin – Überblick“ ergibt ein
|
||
gemeinsames Wort von zweien, also 50 %, genau die Schwelle. Jetzt fällt ein
|
||
Präfix vor einem Trenner (` – `, ` - `, `: `, ` | `) aus dem Vergleich, wenn
|
||
**beide** Titel dasselbe haben (`withoutCommonPrefix` in
|
||
`internal/duplicates`). Verschiedene Präfixe bleiben im
|
||
Vergleich, denn dann können sie etwas unterscheiden. Ein Bindestrich ohne
|
||
Leerzeichen („Deployment-Ablauf“) ist kein Trenner. Eine echte Dublette unter
|
||
gleichem Präfix fällt weiter auf. Tests: `TestWithoutCommonPrefix`,
|
||
`TestRememberCommonPrefixNoDuplicate`.
|
||
|
||
Verworfen: Wörter nach ihrer Häufigkeit im Projekt gewichten. In einem jungen
|
||
Projekt mit zwei Einträgen drückt das auch „Deployment-Prozess“ gegen
|
||
„Deployment-Ablauf“ unter die Schwelle, also genau den Fall, um den es geht.
|
||
Titel ohne Trenner („Munin Architektur“) erfasst die Regel nicht; das bleibt
|
||
ein Fehlalarm, den `force` auflöst.
|
||
|
||
**Zweite Nachbesserung 2026-10-16: Suchbegriffe nur wörtlich, gemeinsame
|
||
Wörter in der Antwort.** Beim Dokumentieren in Munin selbst kamen zwei weitere
|
||
Fehlalarme mit je 50 %:
|
||
|
||
1. „Munin – Betrieb mit PostgreSQL“ gegen einen Statuseintrag mit dem
|
||
Suchbegriff `postgres-migration`. „postgres“ ist der Wortanfang von
|
||
„postgresql“. **Behoben:** Ein Suchbegriff aus „Auch gesucht als“ zählt
|
||
jetzt nur wörtlich, Titelwörter weiter mit allen Varianten
|
||
(`search.CoverageWithSearchTerms`). Der Kernfall „Release-Ablauf“
|
||
gegen den Suchbegriff `release` bleibt erkannt.
|
||
2. „Munin – Entscheidungen zur PostgreSQL-Anbindung“ gegen „Munin – Betrieb mit
|
||
PostgreSQL“: Beide Titel enthalten „PostgreSQL“. **Bleibt** gemeldet —
|
||
strukturell ist das derselbe Fall wie „Deployment-Ablauf“ gegen
|
||
„Deployment-Prozess“, den die Prüfung finden soll. Wortvergleiche können
|
||
die beiden nicht trennen, nur Bedeutung könnte es. Dafür nennt jeder
|
||
Kandidat jetzt die gemeinsamen Wörter (`matched_words`). Steht dort nur ein
|
||
allgemeines Wort, sieht der Agent sofort, dass `force` richtig ist, ohne den
|
||
Kandidaten erst zu laden.
|
||
|
||
Tests: `TestCoverageWithSearchTerms`, `TestRememberSearchTermOnlyLiteral`,
|
||
`matched_words` in `TestRememberReportsDuplicate`.
|
||
|
||
### 3.3a Kuratierte Einträge schützen — ✅ umgesetzt (2026-10-14)
|
||
|
||
**Problem:** `remember` überschrieb kuratierte Einträge ohne Rückfrage; der
|
||
Haken fiel still weg. Weder der Agent noch ein Mensch bekam es mit, und eine
|
||
Wiederherstellung holte den Haken nicht zurück (sie lässt ihn bewusst, wie er
|
||
aktuell ist).
|
||
|
||
**Umsetzung** (`internal/mcpserver/curated.go`), Muster wie bei 3.3:
|
||
|
||
- `remember` auf einen kuratierten Eintrag schreibt nicht, sondern meldet
|
||
`judgment_required` mit Handlungsanweisung.
|
||
- Mit `override_curated: true` und `reason` wird geschrieben. Der Haken fällt
|
||
weg (der neue Text ist ungeprüft), das Protokoll vermerkt
|
||
`kuratiert aufgehoben: <Begründung>` — zählt in der Nutzung als
|
||
Aktualisierung.
|
||
- Die Nutzungsseite listet solche Einträge unter *Zur Prüfung*, bis ein Mensch
|
||
den Haken neu setzt (`store.CuratedOverrides`, keine eigene Tabelle).
|
||
- instructions: Kuratiertes hat Vorrang vor eigenen Annahmen.
|
||
- Suche: kuratierte Einträge mit Faktor 1,2 (`curatedBoost` in
|
||
`internal/search/registry.go`) — genug für den Gleichstand, zu wenig, um
|
||
einen klar besser passenden Eintrag zu verdrängen.
|
||
|
||
**Bewusst nicht:** Sperren mit Änderungsvorschlägen. Erst sinnvoll, wenn die
|
||
Liste *Zur Prüfung* zeigt, dass kuratierte Einträge oft falsch überschrieben
|
||
werden.
|
||
|
||
**Offen:** `forget` auf kuratierte Einträge hat noch keine Hürde. Weniger
|
||
dringend — der Papierkorb hält 30 Tage, und nur Menschen holen zurück —, aber
|
||
dasselbe Muster würde passen.
|
||
|
||
### 3.4 Bezeichner vorschlagen lassen
|
||
|
||
Der slug ist der Schlüssel gegen Dubletten (siehe 3.3), aber der Agent muss ihn
|
||
raten. Ein Werkzeug `suggest_slug(thema)` würde ihm die Arbeit abnehmen:
|
||
|
||
- gibt es zum Thema bereits einen Eintrag → dessen slug zurückgeben
|
||
- sonst einen neuen vorschlagen, der zu den vorhandenen passt
|
||
|
||
Damit wird aus „hoffentlich wählt das Modell einen guten Bezeichner" ein
|
||
Nachschlagevorgang. **Aufwand gering**, die Bausteine (Suche, `Slugify`) liegen vor.
|
||
|
||
**Vorbild:** Engrams `mem_suggest_topic_key`.
|
||
|
||
### 3.5 Aktualität von Wissen — kleiner Schritt ✅ umgesetzt
|
||
|
||
Ein Agentengedächtnis veraltet still. Ein Eintrag über den Deployment-Prozess von
|
||
vor zwei Jahren sieht genauso aus wie einer von gestern — beide kommen mit
|
||
demselben Gewicht aus `recall` zurück.
|
||
|
||
**Kleiner Schritt zuerst:** Das Änderungsdatum steht bereits in jeder Antwort,
|
||
wird aber nicht betont. Einträge jenseits einer Frist zusätzlich mit einem
|
||
sichtbaren Vermerk versehen:
|
||
|
||
```json
|
||
{ "slug": "deployment-prozess", "updated_at": "2024-03-11",
|
||
"stale": true, "hint": "Seit 18 Monaten unverändert — vor Verwendung prüfen." }
|
||
```
|
||
|
||
**Größerer Schritt:** eine Prüffrist je Eintrag, die nach Art unterschiedlich
|
||
schnell abläuft — ein Zugangsverfahren altert schneller als eine
|
||
Grundsatzentscheidung. Dazu eine Ansicht „fällige Einträge" für Menschen und ein
|
||
Bestätigen, das die Frist zurücksetzt.
|
||
|
||
**✅ Kleiner Schritt umgesetzt 2026-09-25:**
|
||
|
||
- **Regel:** `Entry.Stale` in `internal/store/models.go`. Ein Eintrag, der seit
|
||
über einem Jahr (`StaleAfter`) nicht geändert wurde, gilt als veraltet. Das
|
||
gilt auch für geprüfte Einträge: Das Häkchen sagt nicht, *wann* geprüft
|
||
wurde. Ein Jahr ist bewusst eher lang. Ein Vermerk an jedem zweiten Eintrag
|
||
liest bald niemand mehr.
|
||
- **MCP:** `recall`, `list` und `related` tragen `"stale": true`, `get`
|
||
zusätzlich einen `hint` mit Handlungsanweisung. `recall` nennt im Hinweis,
|
||
wie viele Treffer alt sind. Alte Treffer werden weder ausgeblendet noch
|
||
nach hinten sortiert, denn ein alter Eintrag kann der einzige zum Thema
|
||
sein. Die `instructions` erklären das Feld.
|
||
- **Oberfläche:** Auf der Eintragsseite steht die Marke *über ein Jahr alt*.
|
||
- **Offen:** der größere Schritt (Frist je Art, „fällige Einträge“,
|
||
Bestätigen).
|
||
|
||
**Vorbild:** Engram führt dafür `review_after` und `expires_at` je Beobachtung,
|
||
mit einer Verfallslogik je Typ, sowie ein `mem_review` zum Abarbeiten. Deutlich
|
||
ausgebauter als das, was wir bräuchten — aber die Richtung stimmt.
|
||
|
||
### 3.6 Änderungshistorie — ✅ umgesetzt
|
||
|
||
`entries.version` zählt hoch, aber alte Fassungen wurden nicht aufgehoben.
|
||
Überschreibt ein Agent etwas Richtiges mit etwas Falschem, war das Richtige weg.
|
||
|
||
**Umgesetzt:** Tabelle `entry_versions`
|
||
(`migrations/004_change_history.sql`). `entries` bleibt die aktuelle
|
||
Fassung; bei jedem Schreibvorgang, der eine bestehende Zeile ändert (nicht
|
||
beim erstmaligen Anlegen), sichert `snapshotEntryVersion` den bisherigen
|
||
Stand — Titel, Inhalt, Schlagworte, Autor —, bevor er überschrieben wird.
|
||
Läuft in derselben Transaktion wie die Änderung selbst, sowohl beim normalen
|
||
Schreiben (`PutEntry`) als auch bei der Sammelbearbeitung
|
||
(`BulkUpdateTags`, siehe 5). Löschen eines Eintrags nimmt seine Historie über
|
||
`ON DELETE CASCADE` mit — konsistent mit Tags und Verweisen.
|
||
|
||
Die Alternative "Markdown-Export in einem Git-Repository" bleibt weiterhin
|
||
möglich, ersetzt das hier aber nicht: Der Export läuft nur, wenn `EXPORT_DIR`
|
||
gesetzt ist, während die Historie in der Datenbank immer mitläuft.
|
||
|
||
### 3.7 Verwandte Einträge — ✅ umgesetzt
|
||
|
||
Der Verweisgraph (`entry_links`) wird bisher nur für Rückverweise genutzt. Ein
|
||
Werkzeug `related(slug)`, das Nachbarn im Graphen liefert, wäre für einen Agenten
|
||
ein zweiter Weg zum Kontext — neben der Suche.
|
||
|
||
**Aufwand:** gering, die Daten liegen vor.
|
||
|
||
**✅ Umgesetzt 2026-09-25:** MCP-Werkzeug `related(slug, distance?, limit?)`.
|
||
Liefert Nachbarn in beide Richtungen (wer verweist auf mich, worauf verweise
|
||
ich), standardmäßig bis zur zweiten Reihe, höchstens 50. Jeder Treffer nennt
|
||
Abstand, den Eintrag, über den er gefunden wurde (`via`), die Richtung und
|
||
`stale`. Verweise ins Leere und Einträge im Papierkorb fallen heraus, auch als
|
||
Brücke in die zweite Reihe. Mehr als zwei Schritte gibt es bewusst nicht: In
|
||
einem gut verknüpften Projekt hängt in der dritten Reihe fast alles mit allem
|
||
zusammen. `get` liefert weiterhin `links_to`/`linked_by` als nackte
|
||
Bezeichner. `related` ergänzt Titel, Datum und die zweite Reihe.
|
||
|
||
### 3.8 Obergrenze gegen durchdrehende Agenten — ✅ umgesetzt
|
||
|
||
Ein gültiger Key umgeht das Rate-Limit bewusst (siehe `APIAuth` in
|
||
`internal/web/middleware.go`) — richtig für Menschen hinter einer gemeinsamen
|
||
Adresse, aber eine Schleife im Agenten hatte damit kein Limit.
|
||
|
||
**Umgesetzt als `auth.WriteBudget`:** ein Fixed-Window-Zähler über schreibende
|
||
MCP-Aufrufe, Vorgabe **1.000 je Stunde** (`WRITE_LIMIT_HOURLY`), durchgesetzt in
|
||
`Handler.requireWrite` in `internal/mcpserver/tools.go`. Lesen bleibt
|
||
unbegrenzt.
|
||
|
||
**Korrektur zum ursprünglichen Vorschlag:** Dort stand „je Key und Stunde". Das
|
||
Budget hängt jetzt am **Projekt**. Der Grund: Ein Limit je Key lässt sich
|
||
umgehen, indem man einen zweiten Key anlegt — und das darf jeder Projekt-Admin
|
||
selbst. Es hätte also nur vor dem Versehen geschützt, nicht vor dem Verbrauch.
|
||
Einzelne Projekte bekommen unter `/admin/projects` eine eigene Obergrenze; setzen
|
||
darf sie nur der Plattform-Admin, aus demselben Grund.
|
||
|
||
**Drei Dinge, die beim Bauen auffielen:**
|
||
|
||
1. **Die Obergrenze darf nicht zwischengespeichert werden.** Die MCP-Server je
|
||
Projekt sind Closures über eine Projektzeile, die beim ersten Zugriff gelesen
|
||
wurde — eine Änderung in der Verwaltung wäre dort nie angekommen. Genommen
|
||
wird deshalb `sess.Project.WriteLimitHourly` aus der Sitzung: Die hat
|
||
`APIAuth` für genau diesen Request frisch aus der Datenbank geholt. Aktuell,
|
||
und ohne zusätzliche Abfrage im Schreibpfad.
|
||
|
||
2. **Jede Ablehnung zu protokollieren hätte das Problem nur verschoben.** Ein
|
||
Agent in einer Schleife hätte statt der Einträge das Protokoll vollgeschrieben.
|
||
Deshalb gibt `Consume` als dritten Wert zurück, der wievielte Aufruf über der
|
||
Grenze das war — vermerkt wird nur der erste je Fenster.
|
||
|
||
3. **Die Fehlermeldung geht an ein Sprachmodell, nicht an einen Menschen.** Ein
|
||
nackter Fehler führt dazu, dass es sofort wieder versucht oder auf ein anderes
|
||
Werkzeug ausweicht. Die Meldung sagt darum ausdrücklich, was zu tun ist:
|
||
abbrechen und das Limit melden.
|
||
|
||
**Was das Budget nicht ist:** Der Zählerstand liegt im Arbeitsspeicher und ist
|
||
nach einem Neustart weg. Als Netz gegen eine Schleife reicht das. Sobald eine
|
||
verbindliche Zusage gebraucht wird — oder mehr als ein Prozess läuft —, gehört
|
||
der Zähler in die Datenbank.
|
||
|
||
### 3.9 Papierkorb statt endgültigem Löschen — ✅ umgesetzt
|
||
|
||
`forget` löscht heute sofort und endgültig, samt aller Fassungen (3.6,
|
||
`ON DELETE CASCADE`). Das gilt auch, wenn ein Agent es aufruft. Überschreiben
|
||
lässt sich über den Verlauf reparieren, Löschen nicht. Damit ist Löschen der
|
||
schlimmste Fall, den ein fehlgeleiteter Agent heute anrichten kann.
|
||
|
||
**Lösung:** Löschen setzt nur eine Markierung (`deleted_at`, `deleted_by`).
|
||
Markierte Einträge verschwinden aus Suche, Liste, Graph und MCP-Antworten,
|
||
bleiben aber samt Verlauf in der Datenbank. Eine Seite *Papierkorb* je Projekt
|
||
zeigt sie. Menschen mit Schreibrecht können sie dort wiederherstellen oder
|
||
endgültig löschen. Nach einer Frist (Vorschlag 30 Tage) räumt die tägliche
|
||
Aufgabe sie endgültig weg, im selben Lauf wie das Protokoll (siehe 4.4,
|
||
WP12).
|
||
|
||
**Zu bedenken:**
|
||
- Der Bezeichner bleibt belegt, solange der Eintrag im Papierkorb liegt.
|
||
Schreibt jemand mit demselben Bezeichner neu, muss klar sein, was passiert.
|
||
Vorschlag: Das stellt den Eintrag wieder her und überschreibt ihn, wobei der
|
||
alte Stand wie gewohnt in den Verlauf wandert.
|
||
- Jede Abfrage auf `entries` braucht die Bedingung `deleted_at IS NULL`. Das ist
|
||
dieselbe Art Risiko, die 4.4 für Workspaces bewusst vermeiden wollte. Hier ist
|
||
es vertretbar, weil ein vergessenes `WHERE` nur einen gelöschten Eintrag des
|
||
*eigenen* Projekts zeigt. Trotzdem gehört ein Test dazu, der jeden lesenden
|
||
Weg (Suche, Liste, `get`, `recall`, Graph, Rückverweise, Export) prüft.
|
||
|
||
**Aufwand:** mittel.
|
||
|
||
**✅ Umgesetzt 2026-09-24:**
|
||
|
||
- **Schema:** `migrations/005_trash.sql`: `deleted_at` (NULL = normal),
|
||
`deleted_by` und ein Teilindex nur über den Papierkorb.
|
||
- **Store:** `internal/store/trash.go`. `DeleteEntry` gibt es nicht mehr,
|
||
an seine Stelle tritt `MoveEntryToTrash`. Der neue Name sorgt dafür, dass
|
||
jeder alte Aufrufer beim Kompilieren auffällt. Endgültig löschen können nur
|
||
`PurgeFromTrash` und `PurgeTrash`, und beide fassen nur an, was schon im
|
||
Papierkorb liegt.
|
||
- **Lesewege:** Alle tragen `notInTrash`. `ListTags` schließt die
|
||
Schlagworte gelöschter Einträge über eine Unterabfrage auf den Teilindex
|
||
aus; die Schlagworte selbst bleiben für das Wiederherstellen stehen.
|
||
`TestTrashInvisible` prüft jeden Weg einzeln, `TestForgetMovesToTrash`
|
||
dasselbe aus Sicht eines Agenten (`get`, `recall`, `list`).
|
||
- **Gleicher Bezeichner:** Wie vorgeschlagen holt `PutEntry` den Eintrag
|
||
zurück und überschreibt ihn, der gelöschte Stand wandert in den Verlauf
|
||
(`Entry.RestoredFromTrash`). Web und `remember` sagen das dem Schreiber, das
|
||
Protokoll vermerkt `entry_restore`. Ausnahme: Wer eine gelesene Fassung
|
||
mitschickt (`ExpectedVersion`), bekommt einen Konflikt. Das ist der Fall
|
||
„Formular offen, währenddessen hat ein Agent gelöscht“, und eine Löschung
|
||
soll nicht unbemerkt rückgängig werden.
|
||
- **MCP:** `forget` legt in den Papierkorb und sagt das in Beschreibung und
|
||
Antwort. Wiederherstellen oder endgültig löschen gibt es über MCP bewusst
|
||
nicht. `remember` erkennt „angelegt“ jetzt an der Fassung nach dem Schreiben
|
||
statt über eine Nachfrage vorher, die den Papierkorb nicht gesehen hätte.
|
||
- **Oberfläche:** `/p/{project}/trash`, nur mit Schreibrecht, auch das Ansehen:
|
||
Gelöscht wird oft, was nicht jeder sehen soll. Es gibt Wiederherstellen und
|
||
endgültiges Löschen je Zeile oder für eine Auswahl, dazu einen Filter nach
|
||
löschendem Zugang mit *Alle … wiederherstellen* für den Fall „Agent hat zu
|
||
gründlich aufgeräumt“. `confirm.js` liest die Rückfrage jetzt auch am
|
||
gedrückten Knopf (`event.submitter`).
|
||
- **Frist:** `store.TrashRetention` = 30 Tage. `purgeTrashAll` in
|
||
`main.go` läuft im selben täglichen Takt wie das Protokoll-Aufräumen und
|
||
schreibt je Eintrag eine Protokollzeile mit Herkunft `system`.
|
||
Die Hilfeseiten nennen „30 Tage“ als Text. Wer die Frist ändert, muss sie
|
||
dort mitziehen.
|
||
- **Protokoll:** Neue Aktionen `entry_restore` und `entry_purge`.
|
||
`entry_delete` bedeutet jetzt „in den Papierkorb“ (Detail sagt es).
|
||
|
||
---
|
||
|
||
## Teil 4 — Betrieb
|
||
|
||
### 4.1 Zugriffsprotokoll — ✅ umgesetzt
|
||
|
||
`LogRequests` in `internal/web/middleware.go` schreibt je Anfrage eine Zeile:
|
||
|
||
```
|
||
200 POST /mcp 1.475ms claude-desktop
|
||
202 POST /mcp 149µs claude-desktop
|
||
401 POST /mcp 193µs —
|
||
```
|
||
|
||
Statische Dateien und der Healthcheck bleiben draußen (Rauschen), die
|
||
Query-Zeichenkette ebenfalls — dort stehen Suchbegriffe, und die sind Inhalt,
|
||
kein Betriebsdatum.
|
||
|
||
**Zwei Fallstricke, die beim Bauen auffielen:**
|
||
|
||
1. Der Mantel um den `ResponseWriter` verdeckte `http.Flusher`. Der MCP-Transport
|
||
kommt über `http.ResponseController` und damit über `Unwrap()` heran, die
|
||
verbreitetere direkte Typzusicherung `w.(http.Flusher)` hätte aber lautlos
|
||
fehlgeschlagen — der Aufrufer nimmt dann an, Leeren werde nicht unterstützt.
|
||
Deshalb ist `Flush()` ausdrücklich implementiert.
|
||
|
||
2. Der Zugang stand nicht in der Zeile. Das Protokoll liegt ganz außen, damit es
|
||
auch abgewiesene Anfragen erfasst — zu diesem Zeitpunkt ist aber noch niemand
|
||
angemeldet, und die Sitzung landet später in einer abgeleiteten Kopie des
|
||
Requests. Gelöst über einen veränderbaren Halter im Context (`actorHolder`),
|
||
den die Anmelde-Middleware befüllt.
|
||
|
||
### 4.2 Tests bei jeder Änderung — ✅ umgesetzt (manuell)
|
||
|
||
Es gibt keine automatische Pipeline (kein GitHub, keine CI). Die Prüfungen
|
||
werden vor jedem Commit von Hand aufgerufen:
|
||
|
||
```
|
||
gofmt -l . # muss leer bleiben
|
||
go vet ./...
|
||
go test -race ./...
|
||
CGO_ENABLED=0 go build -o /dev/null .
|
||
```
|
||
|
||
*Korrektur 2026-10-16:* Hier stand früher, `.github/workflows/test.yml` führe
|
||
diese Prüfungen automatisch aus. Diese Datei hat es im Repository nie gegeben.
|
||
|
||
`-race` ist hier kein Beiwerk: Suchindex, Rate-Limiter und die Datenbankpools
|
||
werden von vielen Anfragen gleichzeitig benutzt. Der Bau ohne CGO schlägt fehl,
|
||
sobald jemand eine Abhängigkeit hinzufügt, die C braucht — das statische Binary
|
||
ist die Grundlage des ganzen Deployments.
|
||
|
||
### 4.3 Keycloak statt Key-Anmeldung
|
||
|
||
Vorgesehen und im Schema bereits berücksichtigt: Das Protokoll verweist auf
|
||
`api_key_id`, nicht auf Klarnamen. Kommt eine Nutzerverwaltung dazu, lässt sich
|
||
`user_id` danebenstellen, ohne die Historie zu verlieren.
|
||
|
||
**Zu ergänzen:** Tabellen `users` und `memberships`, OIDC-Anmeldung, Rollen an
|
||
Personen statt an Keys.
|
||
|
||
### 4.4 Mehrere Workspaces — ✅ umgesetzt
|
||
|
||
**Ausgangslage:** Die Spalte `workspace_id` stand von Anfang an in allen Tabellen,
|
||
es gab aber nur einen Workspace und keine Oberfläche dafür. Nötig, sobald
|
||
Abteilungen sich gegenseitig nicht sehen sollen.
|
||
|
||
**Entscheidung:** Abteilungen dürfen sich strukturell nie sehen — nicht nur "in
|
||
der Praxis meistens nicht". Eine gemeinsame Tabelle mit Filterung über
|
||
`WHERE workspace_id = ?` hängt davon ab, dass diese Bedingung in jeder
|
||
gegenwärtigen und jeder künftigen Abfrage korrekt steht — bei einem einzelnen
|
||
Entwickler ohne Review durch Dritte ein zu hohes Risiko für ein einziges
|
||
vergessenes `WHERE`.
|
||
|
||
Gleichzeitig muss der Betrieb zentral bleiben: ein Deployment für
|
||
anfangs 10, später bis zu 40 Teams (1–5 Projekte je Team) statt eines eigenen
|
||
Deployments pro Abteilung, das niemand von Hand pflegen kann. Eigene Backups je
|
||
Team sind Pflicht.
|
||
|
||
**Konzept:** eine dünne Kontroll-Datenbank (Workspaces, Key-Hash →
|
||
Workspace-Routing) plus eine eigene SQLite-Datei je Workspace mit dem heutigen,
|
||
unveränderten Schema. Trennung ist damit strukturell (eigene Datei), nicht durch
|
||
Disziplin in jeder Abfrage erzwungen. Backups werden dadurch nebenbei
|
||
trivial — eine Datei je Team lässt sich einzeln sichern.
|
||
|
||
Bei 10–40 Workspaces ist das unproblematisch — deutlich unterhalb der in 1.2
|
||
genannten Schwelle, ab der Verbindungsverwaltung mit Verdrängung nötig würde
|
||
(die galt für *Projekte*, nicht für Workspaces, und für eine viel höhere Zahl).
|
||
|
||
**Arbeitspakete:**
|
||
|
||
1. ✅ Kontroll-Schema: `workspaces` + `key_index` (hash → workspace_id) —
|
||
`internal/store/migrations_control/001_init.sql`
|
||
2. ✅ `ControlStore`: kleines Store-Objekt für die Kontroll-DB —
|
||
`internal/store/control.go`
|
||
3. ✅ Key-Routing zweistufig: `ControlStore.ResolveKeyWorkspace` (hash →
|
||
workspace_id) davor, danach die heutige `ResolveKeyHash`-Logik unverändert
|
||
auf der passenden Workspace-Datei — sowohl beim Web-Login
|
||
(`handlers_auth.go`) als auch bei MCP-Aufrufen (`APIAuth` in
|
||
`middleware.go`). `CreateAPIKey`/`RevokeAPIKey` pflegen `key_index` im
|
||
selben Zug.
|
||
4. ✅ Verbindungscache je Workspace: `internal/workspace.Manager` öffnet
|
||
Workspace-Dateien bei Bedarf und hält sie offen (`map[workspaceID]*Workspace`,
|
||
ein `sync.Mutex`). Bei 10–40 Workspaces keine Verdrängung nötig.
|
||
5. ✅ Workspace-Auflösung pro Request ersetzt das feste `WorkspaceID` in
|
||
`web.Config`: Das Session-Subject trägt die Workspace-ID direkt
|
||
(`key:<workspace-id>:<key-id>` bzw. `platform:<workspace-id>`, siehe
|
||
`sessionFromSubject` in `middleware.go`) — signiert, also fälschungssicher,
|
||
und ohne Umweg über die Kontroll-DB bei jedem Request. Für den
|
||
Plattform-Admin (ein globales `ADMIN_TOKEN`, siehe Entscheidung oben) kommt
|
||
die Workspace-Wahl über `/admin/workspaces` dazu
|
||
(`handlers_workspaces.go`) — eine frische Admin-Sitzung ohne gewählten
|
||
Workspace kommt an keine Projektseite, `requireWorkspace` in
|
||
`middleware.go` erzwingt das. MCP-Werkzeuge lesen ihren Workspace aus dem
|
||
Context (`web.WorkspaceFromContext`, siehe `mcpserver/tools.go`) statt aus
|
||
einer festen Handler-Instanz — ein `mcpserver.Handler` bedient dadurch
|
||
beliebig viele Workspaces gleichzeitig.
|
||
6. ✅ Migrationen laufen für die Kontroll-DB und beim Öffnen jeder
|
||
Workspace-Datei (`store.Open`/`store.OpenControl`, `main.go` legt
|
||
`DATA_DIR/control.db` und `DATA_DIR/workspaces/<id>.db` an)
|
||
7. Datenmigration für eine bereits befüllte Bestandsinstallation (einmaliges
|
||
Skript) — bislang nicht nötig, es läuft noch keine produktive Instanz
|
||
8. ✅ Workspace-Verwaltung, vollständig: `/admin/workspaces` zeigt alle
|
||
Workspaces (neueste zuerst, mit Anlegedatum und einer
|
||
"Aktuell"-Markierung), legt neue an (springt danach direkt zur
|
||
Projektverwaltung, kein Umweg über die Auswahl) und verlinkt je Zeile auf
|
||
`/admin/workspaces/{id}/edit` zum Umbenennen. Dort auch die Gefahrenzone:
|
||
Löschen entfernt erst die Kontroll-Zeile (samt `key_index` über
|
||
`ON DELETE CASCADE`), dann die Workspace-Datei von der Platte
|
||
(`workspace.Manager.Delete`) — mit Bestätigung durch Eintippen des Namens,
|
||
wie beim Löschen eines Projekts. Betrifft das den Workspace der eigenen
|
||
Sitzung, wird die Sitzung auf "kein Workspace gewählt" zurückgesetzt statt
|
||
den Admin auszusperren. Dazu ein Workspace-Umschalter in der Kopfleiste
|
||
(nicht nur in der Seitenleiste): zeigt den Namen des aktuellen Workspace
|
||
auf jeder Seite, Klick führt zur Auswahl/Verwaltung — Vorbild sind die
|
||
Workspace-Umschalter von Slack/Linear/Notion (`templates/partials.html`,
|
||
`.workspace*` in `static/style.css`).
|
||
9. ✅ `scripts/backup.sh`: sichert Kontroll-DB und jede Workspace-Datei
|
||
einzeln über `sqlite3 .backup` in einen zeitgestempelten Ordner,
|
||
optionale Aufräumfrist über `RETENTION_DAYS`. README verlinkt es
|
||
samt Cron-Beispiel.
|
||
10. ✅ Tests: `internal/store/control_test.go` (`ControlStore`/Routing),
|
||
`internal/workspace/workspace_test.go` (Manager cached, trennt Dateien,
|
||
schließt sauber), `internal/web/web_test.go`
|
||
(`TestWorkspaceIsolation` — zwei gleichzeitig offene Workspace-Dateien im
|
||
selben Prozess kommen sich nicht in die Quere)
|
||
11. ✅ README, `.env.example`, `Dockerfile`: `DB_PATH` → `DATA_DIR`
|
||
|
||
**Stand:** Alle Arbeitspakete umgesetzt bis auf eines: eine Datenmigration für
|
||
eine bereits befüllte Bestandsinstallation, falls die je nötig wird (7,
|
||
bislang läuft keine produktive Instanz). Mehrere Workspaces gleichzeitig in
|
||
einem Prozess, strukturell getrennt, mit Oberfläche zum Anlegen und Wechseln
|
||
und eigenem Backup-Skript — fertig und end-to-end geprüft (Tests plus
|
||
manueller Durchlauf gegen den laufenden Server bzw. das Skript).
|
||
|
||
**Nachgezogen** (beim Abgleich der Doku mit dem Code am 2026-09-24 gefunden
|
||
und am selben Tag behoben):
|
||
|
||
12. ✅ Wiederkehrende Aufgaben über alle Workspaces. Vorher bekamen
|
||
`pruneAuditRegularly` und `exportRegularly` nur den ältesten Workspace.
|
||
Das Protokoll aller anderen wurde nie aufgeräumt, die 90-Tage-Frist (4.1)
|
||
griff dort nicht. Jetzt gehen beide in jedem Durchgang über
|
||
`forEachWorkspace` in `main.go`: Die Liste kommt jedes Mal frisch aus
|
||
der Kontroll-Datenbank, also sind auch nach dem Start angelegte Workspaces
|
||
dabei. Ein Fehler in einem Workspace hält die übrigen nicht auf, wird aber
|
||
gesammelt gemeldet (`errors.Join`). Tests: `main_test.go`.
|
||
13. ✅ Export je Workspace. Vorher war der Exporter beim Start fest an den Store
|
||
des ältesten Workspace gebunden (*Jetzt exportieren* in einem anderen
|
||
Workspace schrieb nichts), und die Zielordner hießen nur
|
||
`EXPORT_DIR/<projekt>`, sodass sich Workspaces mit gleichem
|
||
Projekt-Bezeichner gegenseitig überschrieben und Dateien wegräumten. Jetzt
|
||
kennt `export.Exporter` keine Datenbank mehr, der Aufrufer gibt den Store
|
||
je Lauf mit (`ExportWorkspace`). Die Ordner heißen
|
||
`EXPORT_DIR/<workspace-id>/<projekt>/`, dazu `_workspace.md` mit dem Namen.
|
||
Die ID statt des Namens, damit ein Umbenennen keinen zweiten Ordner
|
||
erzeugt. `./munin -export` exportiert alle Workspaces und endet mit
|
||
Fehlercode, wenn einer scheitert. Tests: `TestExportSeparatesWorkspaces`,
|
||
`TestExportButtonInSecondWorkspace`, `main_test.go`.
|
||
|
||
Alte Exportordner direkt unter `EXPORT_DIR/` fasst der neue Export nicht mehr
|
||
an. Sie können nach dem ersten erfolgreichen Lauf von Hand gelöscht werden.
|
||
|
||
### 4.5 Kennzahlen — geplant, sobald es ein zentrales Monitoring gibt
|
||
|
||
Ein `/metrics`-Endpunkt mit Anfragen, Dauern, Eintragszahlen. Sinnvoll, sobald es
|
||
ein Überwachungssystem gibt, in das es hineinfließt — vorher nicht.
|
||
|
||
*Stand 2026-10-16:* Ein zentrales Monitoring ist angekündigt, aber noch nicht
|
||
da. Bis dahin beantwortet die Systemseite (5.12, 5.16) die Fragen „Ist etwas
|
||
zu tun?“ und „Wird Munin genutzt?“. Was erst mit dem Monitoring kommt:
|
||
|
||
- **Laufzeitwerte, die heute niemand zählt:** MCP-Aufrufe je Werkzeug,
|
||
Fehlerquote, Dauer (Median, langsamste 5 %), Treffer auf das Schreibbudget je
|
||
Projekt, gesperrte IPs, Go-Laufzeit (Speicher, Goroutinen, GC),
|
||
Wartezeiten der Verbindungspools als Verlauf.
|
||
- **Weg:** entweder `/metrics` im Prometheus-Textformat (von Hand
|
||
geschrieben, ohne Bibliothek machbar) oder OpenTelemetry, je nachdem, was das
|
||
Monitoring erwartet. OpenTelemetry brächte eine Bibliothek mit sich und ist
|
||
erst zu wählen, wenn klar ist, wohin die Daten gehen.
|
||
- **Bewusst nicht in Munin:** eine eigene Ablage für Messwerte über Monate mit
|
||
eigenen Diagrammen. Verläufe zeichnet das Monitoring; die Systemseite bleibt
|
||
der schnelle Blick.
|
||
- **Schutz:** `/metrics` nicht öffentlich — nur aus dem internen Netz oder
|
||
mit eigenem Token, wie `/live` und `/ready` ohne Inhalte der Projekte.
|
||
|
||
### 4.6 Nutzungsübersicht je Projekt — ✅ umgesetzt
|
||
|
||
Anders als 4.5 nicht für ein Überwachungssystem, sondern für Menschen in der
|
||
Oberfläche. Die Hilfe (*Mit Agenten arbeiten*) rät, zu prüfen, ob
|
||
Aktualisierungen gegenüber Neuanlagen zunehmen. Das lässt sich heute nur
|
||
mühsam aus dem Protokoll ablesen.
|
||
|
||
**Inhalt einer kleinen Übersichtsseite je Projekt:**
|
||
- Schreibvorgänge der letzten 30 Tage, aufgeteilt in Neuanlagen und
|
||
Aktualisierungen, getrennt nach Mensch und Agent
|
||
- Anteil kuratierter Einträge
|
||
- Einträge ohne Schlagwort, Verweise auf nicht vorhandene Einträge
|
||
- zuletzt aktive Zugänge
|
||
|
||
Für den Plattform-Admin dieselben Zahlen als Spalten in `/admin/projects`. So
|
||
fällt auf, welches Team Munin gar nicht benutzt.
|
||
|
||
**Voraussetzung — ✅ erledigt (2026-09-24):** Das Protokoll vermerkt bei
|
||
`entry_write` im Feld `detail`, ob der Eintrag `angelegt` oder `aktualisiert`
|
||
wurde (`store.EntryWriteDetail`, Konstanten `DetailEntryCreated` und
|
||
`DetailEntryUpdated` in `internal/store/audit.go`), sowohl bei `remember` als
|
||
auch in der Weboberfläche. Maßgeblich ist die Fassung nach dem Schreiben
|
||
(1 = angelegt), das stimmt auch bei gleichzeitigem Schreiben. Die
|
||
Sammelbearbeitung schreibt weiterhin ihren eigenen Text und zählt als
|
||
Aktualisierung. Schreibvorgänge vor diesem Datum lassen sich nicht
|
||
unterscheiden.
|
||
|
||
**Aufwand:** gering bis mittel, die Daten liegen im Protokoll und in `entries`.
|
||
|
||
**✅ Umgesetzt 2026-09-24:**
|
||
|
||
- **Seite:** `/p/{project}/usage` (`handlers_usage.go`), für jede Rolle.
|
||
Die Zahlen und Zugangsnamen stehen ohnehin schon im Protokoll, das jede Rolle
|
||
sieht.
|
||
- **Zahlen:** `store.UsageSummaries` (`internal/store/usage.go`) liefert
|
||
Schreibvorgänge der letzten 30 Tage (`UsageWindow`) aus dem Protokoll und den
|
||
Bestand aus `entries`, für ein Projekt oder alle, in zwei Abfragen. Es gibt
|
||
keine eigene Zähltabelle, die auseinanderlaufen könnte.
|
||
- **Mensch oder Agent:** Unterschieden wird nach Weg: Web = Mensch, MCP =
|
||
Agent. Ein Mensch, der selbst über MCP schreibt, zählt als Agent.
|
||
- **Neu oder aktualisiert:** Maßgeblich ist `detail`. `angelegt` ist neu;
|
||
alles andere außer leer ist eine Aktualisierung („aktualisiert“,
|
||
„wiederhergestellt: …“, „Sammelbearbeitung: …“). Leer heißt „ohne Angabe“:
|
||
Das sind Zeilen aus der Zeit vor der Unterscheidung, die nach 30 Tagen von
|
||
selbst herausfallen.
|
||
- **Der entscheidende Wert:** der Anteil der Aktualisierungen, mit einem
|
||
Hinweis zur Deutung. Eine feste Schwelle „gut/schlecht“ gibt es bewusst
|
||
nicht, denn ein junges Projekt legt naturgemäß vor allem an.
|
||
- **Weitere Abschnitte:** Verweise ins Leere (meistgenannte zuerst, mit
|
||
Schreibrecht „anlegen“ über `/new?slug=…`), Einträge ohne Schlagwort, die
|
||
fünf zuletzt aktiven Zugänge.
|
||
- **Admin:** `/admin/projects` hat die Spalten *Schreibvorgänge* (30 Tage, mit
|
||
„keine“ als deutlichem Signal) und *Zuletzt geändert*. Die Eintragszahl
|
||
kommt jetzt aus derselben Abfrage statt aus einem `CountEntries` je Projekt.
|
||
|
||
### 4.7 Ungenutzte Zugänge sichtbar machen — ✅ umgesetzt (Stufe 2: 2026-10-16)
|
||
|
||
`last_used_at` wird schon erfasst. Bei 10–40 Teams sammeln sich trotzdem
|
||
Keys an, die niemand mehr benutzt, etwa von Personen, die gewechselt haben,
|
||
oder von abgeschalteten Pipelines. Jeder davon ist ein offener Zugang.
|
||
|
||
**Lösung, in zwei Stufen:**
|
||
1. Unter *Zugänge* Keys markieren, die seit mehr als 90 Tagen (oder nie)
|
||
benutzt wurden, mit einem Knopf zum Zurückziehen. Gering.
|
||
2. Optional ein Ablaufdatum beim Anlegen. Abgelaufene Keys gelten wie
|
||
zurückgezogene. Braucht eine Migration (`expires_at`) und eine Prüfung in
|
||
`ResolveKeyHash`. Mittel.
|
||
|
||
**✅ Stufe 1 umgesetzt 2026-09-25:**
|
||
|
||
- **Regel:** `APIKey.Unused` in `internal/store/models.go`. Ein aktiver Key
|
||
gilt als ungenutzt, wenn er seit mehr als 90 Tagen (`UnusedAfter`) nicht
|
||
benutzt wurde. Wurde er noch nie benutzt, zählt das Anlegedatum. Sonst stünde
|
||
jeder frische Key sofort als ungenutzt da. Zurückgezogene Keys sind nie
|
||
ungenutzt.
|
||
- **Oberfläche:** Unter *Zugänge* trägt so ein Key die Marke *ungenutzt*, über
|
||
der Tabelle steht ein Hinweis mit der Anzahl. Den Knopf zum Zurückziehen gab
|
||
es schon. Keine Migration, `last_used_at` reicht.
|
||
- **Unschärfe:** `TouchAPIKey` schreibt höchstens einmal pro Stunde. Für eine
|
||
Grenze von 90 Tagen spielt das keine Rolle.
|
||
- **Tests:** `TestAPIKeyUnused` (Grenzfälle), `TestKeyListFreshKeyNotUnused`.
|
||
|
||
**✅ Stufe 2 umgesetzt 2026-10-16: Ablaufdatum.**
|
||
|
||
- **Migration 007** (`007_key_expiry.sql`, in beiden Ordnern gleich):
|
||
`api_keys.expires_at TEXT NOT NULL DEFAULT ''`. Leer = läuft nie ab, wie bei
|
||
`revoked_at`. Vorhandene Keys behalten ihr Verhalten, die Migration fasst
|
||
keine Daten an.
|
||
- **Gilt nicht mehr ab dem Ablauf**, an beiden Stellen, an denen geprüft wird,
|
||
ob ein Key gilt: `ResolveKeyHash` (jede MCP-Anfrage, jede Anmeldung;
|
||
Bedingung `expires_at = '' OR expires_at > jetzt`) und `sessionFromSubject`
|
||
(jede Seite: `APIKey.Valid`). Eine laufende Browser-Sitzung endet also bei
|
||
der nächsten Anfrage nach dem Ablauf.
|
||
- **Oberfläche:** Feld *Gültig bis* beim Anlegen (optional, gilt bis
|
||
einschließlich des Tages). Spalte *Gültig bis*, Status *abgelaufen*, Marke
|
||
*läuft bald ab* zwei Wochen vorher (`ExpiresSoonWithin`) und ein Hinweis über
|
||
der Tabelle. Ein Datum in der Vergangenheit wird abgelehnt. Das Protokoll
|
||
vermerkt das Ablaufdatum bei `key_created`.
|
||
- **Abgelaufene Keys sind nie „ungenutzt“** — sie öffnen ohnehin nichts mehr.
|
||
- **Nicht dabei:** Ablaufdatum für Workspace-Admin-Tokens (`wa_…`). Bei Bedarf
|
||
derselbe Weg in der Kontroll-Datenbank.
|
||
- **Tests:** `TestKeyExpiry` (Store, auch gegen PostgreSQL),
|
||
`TestKeyWithExpiryDate` (Anlegen, Anzeige, Ablehnung, Ende der Sitzung).
|
||
|
||
### 4.8 Admin je Workspace — ✅ umgesetzt (2026-09-30)
|
||
|
||
Bis dahin legte nur der Plattform-Admin (`ADMIN_TOKEN`) Projekte an. Bei 10–40
|
||
Teams wird er damit zum Nadelöhr, und ein gemeinsames Token für alle
|
||
Workspaces widerspricht dem Gedanken, dass Abteilungen sich nie sehen.
|
||
|
||
**Lösung:** Eine Rolle „Workspace-Admin“, die im eigenen Workspace Projekte
|
||
anlegen, umbenennen und löschen darf, aber weder andere Workspaces sieht noch
|
||
das Schreibbudget ändert (siehe 3.8, warum das beim Plattform-Admin bleibt).
|
||
|
||
**Entscheidung (2026-09-30): ohne Keycloak umsetzen.** Ursprünglich stand hier,
|
||
4.8 nur zusammen mit 4.3 zu entscheiden. Keycloak wird aktuell aber nicht
|
||
gebraucht, das Nadelöhr beim Plattform-Admin dagegen schon. Kommt 4.3 doch,
|
||
fallen nur die Token-Tabelle und der Anmeldezweig weg. Die Rechteprüfungen
|
||
(`Session.CanManageProjects`, `CanEditWriteBudget`, `CanImport`, die Routen)
|
||
bleiben gleich, nur die Herkunft von `IsWorkspaceAdmin` ändert sich.
|
||
|
||
**Umsetzung:**
|
||
|
||
- *Eigene Token-Art `wa_…`* in der Kontroll-Datenbank (`workspace_admins`,
|
||
`migrations_control/002`), Hash statt Klartext, zurückziehbar, mit letzter
|
||
Nutzung. Mehrere je Workspace, jeder mit Namen der Person, damit das
|
||
Protokoll sagt, wer etwas getan hat.
|
||
- *Nur Weboberfläche:* `APIAuth` akzeptiert nur `wk_…` (`LooksLikeKey`). Ein
|
||
`wa_`-Token scheitert dort schon an der Form und liegt außerdem in einer
|
||
anderen Tabelle.
|
||
- *Eigenes Feld `Session.IsWorkspaceAdmin`* statt `IsPlatformAdmin` mitzunutzen.
|
||
Jede Prüfung auf `IsPlatformAdmin` weist ihn damit von selbst ab. Freigegeben
|
||
ist nur, was die `Can…`-Methoden ausdrücklich nennen.
|
||
- *Darf:* Projekte des eigenen Workspace anlegen, bearbeiten, löschen. Zugänge
|
||
aller Projekte dort verwalten. Alle Projekte lesen, dazu Protokoll, Nutzung
|
||
und `/admin/audit`.
|
||
- *Darf nicht:* Einträge schreiben, importieren, Papierkorb. Außerdem
|
||
Schreibbudget (der Server ignoriert das Feld, es wird also nicht nur im
|
||
Template ausgeblendet), Export, Workspaces, andere Workspace-Admins anlegen.
|
||
- *Anlegen und Zurückziehen* nur durch den Plattform-Admin, auf der
|
||
Bearbeitungsseite des Workspace. Beides und jede Anmeldung stehen im
|
||
Protokoll des Workspace.
|
||
- *Ungenutzte Workspace-Admins* markiert wie bei den Keys (4.7, dieselbe Regel
|
||
über `store.ungenutzt`). Die Workspace-Übersicht zeigt je Workspace die Zahl
|
||
der aktiven und der ungenutzten Workspace-Admins.
|
||
|
||
**Bewusst hingenommen:** Wer Zugänge verwalten darf, kann sich einen
|
||
`admin`-Key für jedes Projekt seines Workspace anlegen und damit auch
|
||
schreiben. Das Verbot, Einträge zu schreiben, ist also keine harte Grenze. Es
|
||
sorgt dafür, dass Änderungen über Keys laufen und im Protokoll eindeutig
|
||
zuzuordnen sind. Die Rolle gehört deshalb nur an Personen, denen die Abteilung
|
||
ohnehin alle ihre Projekte anvertraut (so steht es auch in der Hilfe).
|
||
|
||
**Tests:** `internal/web/workspace_admin_test.go` (darf / darf nicht,
|
||
Workspace-Trennung, MCP abgewiesen, Zurückziehen beendet die Sitzung),
|
||
`TestWorkspaceAdmins`, `TestWorkspaceAdminsVanishWithWorkspace`,
|
||
`TestWorkspaceAdminUnused`, `TestWorkspaceListCountsActiveAdmins`,
|
||
`TestWorkspaceAdminTokenIsNoKey`.
|
||
|
||
### 4.9 Zugriff über ein Projekt hinaus — aufgeschoben
|
||
|
||
Zwei verwandte Wünsche, besprochen am 2026-09-25 und bewusst aufgeschoben:
|
||
|
||
- **Öffentliches Projekt:** Ein Projekt, das jeder gültige Key lesen darf,
|
||
auch aus anderen Workspaces. Etwa für firmenweite Richtlinien.
|
||
- **Workspace-Zugang:** Ein Key für alle Projekte eines Workspace, mit einem
|
||
MCP-Server statt einem je Projekt. Für Teams, die projektübergreifend
|
||
arbeiten und Wissen aus mehreren Projekten brauchen.
|
||
|
||
**Stand heute:** Ein Key gehört zu genau einem Projekt, und jedes Projekt hat
|
||
einen eigenen MCP-Server, dessen Werkzeuge fest an dieses Projekt gebunden
|
||
sind. Wer mehrere Projekte braucht, trägt mehrere Server ein, je einen Key pro
|
||
Projekt. Das Routing Key → Workspace in der Kontroll-Datenbank (4.4) kennt
|
||
dagegen schon keine Projektbindung.
|
||
|
||
**Geht schon heute, ohne Code:** Für ein „öffentliches“ Projekt bekommt jedes
|
||
Team einen `read`-Key und trägt das Projekt als zweiten Server ein. Das kostet
|
||
Verwaltung, aber keine Entwicklung, und das Protokoll zeigt je Team, wer liest.
|
||
|
||
#### Öffentliches Projekt
|
||
|
||
| | Idee | Aufwand |
|
||
|---|---|---|
|
||
| **A** (empfohlen) | Eigener Endpunkt `/mcp/public/<projekt>`, jeder gültige Key darf lesen (`recall`, `get`, `list`, `related`), schreiben nur mit Keys des Projekts. Schalter nur für den Plattform-Admin. | 2½–3½ Tage |
|
||
| **B** | `recall` sucht automatisch zusätzlich im öffentlichen Projekt | 4–6 Tage, ändert den MCP-Vertrag |
|
||
| Lesen in der Oberfläche | Menschen aus anderen Projekten sehen es im Browser | +2–3 Tage |
|
||
|
||
Von Schreibrecht für alle wird abgeraten: Das Schreibbudget (3.8) gälte für
|
||
die ganze Firma zusammen, und niemand fühlte sich für die Qualität zuständig.
|
||
|
||
#### Workspace-Zugang
|
||
|
||
**Nötige Änderungen:** Workspace-Keys ohne Projektbindung (`api_keys.project_id`
|
||
ist heute `NOT NULL`, bei SQLite heißt das Tabelle neu aufbauen),
|
||
`Session.CanAccessProject` erweitern, Verwaltung unter `/admin/workspaces`, und
|
||
auf der MCP-Seite eine von zwei Stufen:
|
||
|
||
| | Idee | Aufwand gesamt |
|
||
|---|---|---|
|
||
| **Stufe 1** | Projekt wählbar über den Pfad (`/mcp/<projekt>`), ein Key für alle. Löst den Zugriff, der Agent muss aber weiter wissen, wo er sucht. | 4½–6 Tage |
|
||
| **Stufe 2** | Ein Server je Workspace, `recall` über alle Projekte, jeder Treffer nennt sein Projekt, `get`/`related`/`remember` bekommen einen Parameter `project`. | 7½–10 Tage |
|
||
|
||
**Risiken:**
|
||
|
||
- *Sicherheit:* Ein verlorener Workspace-Key öffnet alle Projekte statt einem.
|
||
Die Grenze zwischen Workspaces bleibt strukturell dicht (eigene Datei je
|
||
Workspace). Innerhalb eines Workspace wird die Projektgrenze aber zu einer
|
||
Bedingung in der Abfrage statt einer Closure. Das größte Risiko ist, dass
|
||
Projekt-Keys versehentlich über denselben Codepfad laufen und mehr sehen als
|
||
heute. Deshalb Projekt-Keys unverändert auf dem heutigen Weg lassen und den
|
||
Workspace-Server getrennt aufbauen.
|
||
- *Last:* Ein workspace-weites `recall` lädt beim ersten Aufruf die
|
||
Suchindizes *aller* Projekte in den Speicher (~65 MB und ~170 ms je 10.000
|
||
Einträge, siehe 1.1). Heute nur die, die jemand benutzt. SQLite wird nicht
|
||
stärker belastet, alle Projekte eines Workspace liegen schon in einer Datei.
|
||
- *Schreibbudget:* Es zählt je Projekt. Ein durchdrehender Agent mit
|
||
Workspace-Key könnte in *jedem* Projekt bis zur Grenze schreiben. Dagegen
|
||
hilft nur ein zusätzliches Budget je Key oder Workspace, oder kein
|
||
Schreibrecht für Workspace-Keys.
|
||
- *Qualität:* Agenten schreiben ins falsche Projekt (deshalb keine
|
||
Standardangabe für `project` bei `remember`), vermischen Wissen verschiedener
|
||
Projekte, legen Dubletten über Projektgrenzen an. Außerdem sind BM25-Werte
|
||
getrennter Indizes nicht direkt vergleichbar, das Mischen der Treffer
|
||
braucht eigene Logik.
|
||
- *Wartung:* zwei Anschlussarten statt einer. Mit Keycloak (4.3) wird aus dem
|
||
Workspace-*Key* ein Workspace-*Recht* einer Person, der Rechte-Teil wird dann
|
||
umgebaut.
|
||
|
||
**Warum aufgeschoben:** Keycloak ist absehbar, aber nicht sofort. Mit wenigen
|
||
Projekten je Team (etwa zwei) ist ein Server je Projekt stabil und die
|
||
Schwäche nur Komfort. Die Risiken oben wiegen das noch nicht auf.
|
||
|
||
**Auslöser:**
|
||
|
||
- Teams arbeiten regelmäßig mit vier bis fünf oder mehr Projekten zugleich
|
||
(Werkzeuge vervielfachen sich im Kontext des Agenten)
|
||
- Die Verteilung von `read`-Keys für gemeinsam genutzte Projekte wird zur
|
||
spürbaren Last
|
||
- Die Entscheidung zu 4.3 und 4.8 steht an. Dann zusammen entscheiden, nicht
|
||
vorher Workspace-Keys bauen und später umbauen.
|
||
|
||
**Wenn es so weit ist, vorsichtig beginnen:** Workspace-Keys nur `read`,
|
||
projektübergreifend `recall`/`get`/`list`/`related`, schreiben weiter über
|
||
die Projekt-Server, vorher Speicher und Kaltstart mit echten Projektgrößen
|
||
messen. Innerhalb eines Workspace erledigt ein solcher `read`-Key nebenbei
|
||
auch das öffentliche Projekt. Übrig bliebe nur „öffentlich über
|
||
Workspaces hinweg“ (Variante A oben).
|
||
|
||
#### Idee: Mehrere Keys in einem MCP-Block (2026-10-06)
|
||
|
||
Eine Alternative zu Workspace-Keys, nur als Idee festgehalten, nicht
|
||
entschieden. Der Client trägt *einen* Server ein und übergibt alle seine Keys:
|
||
|
||
```json
|
||
"munin": {
|
||
"url": "https://munin.firma.de/mcp",
|
||
"headers": { "X-Munin-Keys": "wk_aaa,wk_bbb,wk_ccc" }
|
||
}
|
||
```
|
||
|
||
Der Server löst jeden Key einzeln auf dem heutigen Weg auf (Key → Workspace →
|
||
Projekt + Rolle). Ist einer ungültig, scheitert die ganze Anfrage
|
||
(fail-closed). Die Werkzeuge bekommen einen Parameter `project`, `recall`
|
||
sucht ohne Angabe in allen freigeschalteten Projekten. Ein einzelner Key in
|
||
`Authorization: Bearer` verhält sich wie heute.
|
||
|
||
**Vorteile gegenüber Workspace-Keys:**
|
||
|
||
- Kein Umbau der Datenbank: `api_keys.project_id` bleibt `NOT NULL`, kein
|
||
neuer Key-Typ, keine neue Verwaltung.
|
||
- Rechte bleiben je Key: ein `read`-Key bleibt `read`. Sperren, Protokoll
|
||
(4.1), Nutzungsübersicht (4.6) und ungenutzte Zugänge (4.7) funktionieren
|
||
unverändert.
|
||
- Kein größerer Schaden bei einem verlorenen Key: der Client hält dieselben
|
||
Keys wie heute, nur in einem statt in N Blöcken.
|
||
- Schreibbudget (3.8) bleibt je Projekt.
|
||
- Löst das öffentliche Projekt mit: ein `read`-Key aus einem fremden
|
||
Workspace ist in der Liste kein Sonderfall, jeder Key wird für sich
|
||
geroutet.
|
||
- 6 Werkzeuge im Kontext des Agenten statt 6 × N.
|
||
|
||
**Risiken für die Richtigkeit der Antworten:**
|
||
|
||
| Risiko | Was passiert | Gegenmittel |
|
||
|---|---|---|
|
||
| Wissen vermischt | „Wir deployen mit Helm“ aus A wird als allgemeingültig wiedergegeben, obwohl B Ansible nutzt | Jeder Treffer nennt sein Projekt deutlich, Instructions: Aussagen gelten nur für ihr Projekt, bei Widerspruch nachfragen |
|
||
| Gleicher Slug in mehreren Projekten | `get deployment` liest das falsche Projekt; schlimmer: `remember` **überschreibt** einen bestehenden Eintrag in B mit Inhalt aus A | `project` ist Pflicht für `get`/`remember`/`forget`/`related`, sobald mehr als ein Key übergeben wird. Fehlt es, Fehlermeldung statt Raten |
|
||
| Rangfolge unterschlägt Projekte | BM25-Werte getrennter Indizes sind nicht vergleichbar, ein großes Projekt füllt die Top 10, der Agent schließt „nichts bekannt“ | Treffer je Projekt gruppiert zurückgeben (beste N je Projekt) statt einer gemischten Liste |
|
||
| Dubletten | Wissen aus A wird in B neu angelegt und veraltet dort still | Nur über Instructions zu mindern |
|
||
|
||
Nur der gleiche Slug verändert gespeicherte Daten. Die Pflichtangabe von
|
||
`project` schließt das technisch aus. Die anderen drei betreffen die Qualität
|
||
der Antworten und lassen sich nur verringern.
|
||
|
||
**Aufwand (Schätzung, nicht erprobt):**
|
||
|
||
| | Umfang | Aufwand |
|
||
|---|---|---|
|
||
| **Stufe 1** | Nur lesend: `recall`/`get`/`list`/`related` über mehrere Keys, schreiben weiter über die Projekt-Server | 3–4 Tage |
|
||
| **Stufe 2** | Zusätzlich `remember`/`forget` mit Pflichtangabe `project` | +1–2 Tage |
|
||
|
||
**Wenn es so weit ist:** mit Stufe 1 beginnen. Dann ist das Überschreiben
|
||
ausgeschlossen, und an echten Antworten lässt sich sehen, wie stark sich
|
||
Wissen vermischt, bevor Schreiben dazukommt. Speicher und Kaltstart betrifft
|
||
nur die Indizes der übergebenen Keys, nicht den ganzen Workspace. Die
|
||
Hauptarbeit bei den Tests: nachweisen, dass kein Key mehr sieht als heute.
|
||
|
||
### 4.10 PostgreSQL als zweiter Speicherort — in Arbeit (begonnen 2026-10-16)
|
||
|
||
PostgreSQL soll **neben** SQLite nutzbar sein, umschaltbar über die
|
||
Konfiguration. SQLite bleibt der Standard.
|
||
|
||
**Stand 2026-10-16:** Schritte 1, 2, 3 und 5 sind umgesetzt (siehe
|
||
*Reihenfolge und Aufwand* am Ende). Alle Tests laufen gegen beide Datenbanken,
|
||
Sicherung und Wiederherstellung sind durchgespielt. PostgreSQL ist damit
|
||
produktiv nutzbar, mit einer Schreibverbindung je Workspace. Offen ist
|
||
Schritt 4 (mehrere Schreiber).
|
||
|
||
**Grenze: Auch mit PostgreSQL läuft genau ein Munin-Prozess.** PostgreSQL
|
||
ändert den Speicherort, nicht die Architektur. Einiges lebt nur im
|
||
Arbeitsspeicher des Prozesses:
|
||
|
||
- **Suchindex** (`internal/search`): Ein zweiter Prozess sähe nicht, was der
|
||
erste geschrieben hat, und fände veraltete Stände, ohne Fehlermeldung.
|
||
- **Schreibbudget und Rate-Limiter** (`internal/auth/ratelimit.go`): Jeder
|
||
Prozess zählte für sich, die Grenzen gälten doppelt.
|
||
|
||
Die Anmeldung wäre kein Problem (signierte Cookies, kein Sitzungsspeicher).
|
||
Mehrere Prozesse wären eine eigene, deutlich größere Ausbaustufe.
|
||
|
||
**Ausgangslage, was schon passt:**
|
||
|
||
- **SQL gibt es nur in `internal/store`.** Handler, MCP-Tools und Templates
|
||
arbeiten mit Go-Typen. Genau für diesen Fall ist das Package gebaut (siehe
|
||
Kopfkommentar in `store.go`).
|
||
- **Die Migrationen folgen portablen Regeln** (Kopf von
|
||
`migrations/001_init.sql`): IDs als TEXT aus Go, Zeitstempel als
|
||
RFC3339-Text, Wahrheitswerte als 0/1. Kein `AUTOINCREMENT`, kein
|
||
`INSERT OR REPLACE`, kein `datetime()`.
|
||
- **Die Volltextsuche ist eigener BM25-Code in Go** (`internal/search`), nicht
|
||
FTS5 (siehe 1.1). Sie hängt also nicht an der Datenbank.
|
||
- `RETURNING` und `ALTER TABLE … ADD COLUMN` können beide Datenbanken.
|
||
`isUniqueViolation` erkennt die Fehlermeldungen beider (`models.go`).
|
||
|
||
**Was sich ändern muss:**
|
||
|
||
| # | Stelle | Änderung | Aufwand |
|
||
|---|---|---|---|
|
||
| 1 | Konfiguration (`main.go`) | `DB_DRIVER=sqlite\|postgres` (Standard `sqlite`) und `DATABASE_URL` | gering |
|
||
| 2 | Treiber | `github.com/jackc/pgx/v5/stdlib`. Ist reines Go, das statische Alpine-Image bleibt möglich | gering |
|
||
| 3 | Platzhalter | PostgreSQL erwartet `$1, $2 …` statt `?`, betroffen sind rund 100 Abfragen. Lösung: `Rebind` im Dialekt (siehe unten), kein Umschreiben von Hand | gering bis mittel |
|
||
| 4 | `openPool` (`store.go`) | `_pragma`-Parameter, `_txlock=immediate` und die einzelne Schreibverbindung gelten nur für SQLite. PostgreSQL braucht einen eigenen Öffnungsweg | gering |
|
||
| 5 | Workspace-Modell (`internal/workspace`) | Ein Schema je Workspace statt einer Datei, siehe unten | mittel |
|
||
| 6 | Migrationen | Eigenes Verzeichnis je Datenbank, siehe unten. Konkreter Unterschied heute: `search_log.id INTEGER PRIMARY KEY` (Migration 006) zählt nur in SQLite von selbst hoch. PostgreSQL braucht `BIGINT GENERATED ALWAYS AS IDENTITY` | gering |
|
||
| 7 | Gleichzeitiges Schreiben | Stellen absichern, die heute nur durch die eine Schreibverbindung sicher sind, siehe unten | mittel |
|
||
| 8 | Tests | Die Store-Tests zusätzlich gegen PostgreSQL laufen lassen | mittel |
|
||
| 9 | Betrieb | `scripts/backup.sh` und `BETRIEB.md` setzen auf `sqlite3`. Für PostgreSQL braucht es `pg_dump` und angepasste Abschnitte | gering |
|
||
|
||
#### Entscheidung: ein Store mit Dialekt, keine zwei Stores
|
||
|
||
Naheliegend wären zwei vollständige Umsetzungen hinter einem Interface
|
||
(`SQLiteStore`, `PostgresStore`). Das hieße, rund 100 Abfragen doppelt zu
|
||
pflegen und jede Methode zweimal zu testen.
|
||
|
||
Stattdessen bleibt es bei **einem** `Store`. Umgesetzt in drei Teilen:
|
||
|
||
| Teil | Datei | Aufgabe |
|
||
|---|---|---|
|
||
| `Backend` | `backend.go`, `backend_sqlite.go`, `backend_postgres.go` | Wo liegen die Daten? Öffnet Kontroll-Datenbank und Workspaces, löscht Workspaces, prüft das Verbindungsbudget. `main.go` wählt ihn anhand von `DB_DRIVER`, danach weiß nur noch er davon. |
|
||
| `dialect` | `dialect.go` | Was unterscheidet sich in den Abfragen? Heute nur `rebind` (`?` → `$1`) und der Migrationsordner. |
|
||
| `pool`, `transaction` | `pool.go` | Jede Abfrage läuft hier durch `rebind`. An `*sql.DB` kommt außerhalb dieser Datei niemand heran, eine Abfrage am Dialekt vorbei lässt sich also gar nicht schreiben. |
|
||
|
||
```go
|
||
type dialekt interface {
|
||
rebind(abfrage string) string // ? → $1, $2 … (SQLite: unverändert)
|
||
migrationsSubdir() string // "sqlite" bzw. "postgres"
|
||
}
|
||
```
|
||
|
||
Braucht eine einzelne Abfrage später wirklich zwei Fassungen, bekommt der
|
||
Dialekt dafür gezielt eine eigene Methode. Der Rest bleibt gemeinsam.
|
||
`isUniqueViolation` erkennt die Meldungen beider Datenbanken schon und bleibt
|
||
deshalb, wo sie ist.
|
||
|
||
**Zwei Abfragen mussten umgeschrieben werden:** `CuratedOverrides`
|
||
(`usage.go`) und `EmptySearches` (`searchlog.go`) nutzten `MAX()` mit
|
||
Spalten ohne Aggregat. Das kann nur SQLite, PostgreSQL lehnt es ab. Beide
|
||
nutzen jetzt `ROW_NUMBER() OVER (…)`, das können beide Datenbanken (SQLite ab
|
||
3.25). Neuer Test: `TestCuratedOverridesLatestEvent`.
|
||
|
||
#### Entscheidung: bewusst schlichtes SQL
|
||
|
||
In PostgreSQL entstehen dieselben Tabellen wie in SQLite, keine JSONB-Spalten,
|
||
keine Arrays, keine PostgreSQL-Volltextsuche, keine Erweiterungen (pgvector:
|
||
siehe 2.2). Gründe:
|
||
|
||
- Die Daten haben eine feste Form. Schlagworte und Verweise liegen schon in
|
||
eigenen Tabellen, es gibt nichts Frei-Geformtes.
|
||
- Jedes PostgreSQL-Feature in einer Abfrage braucht eine zweite Fassung für
|
||
SQLite. Damit finge das Auseinanderlaufen an.
|
||
- Der Schema-Vergleichstest setzt gleiche Strukturen voraus.
|
||
- Die Suche liegt in Go (1.1). Ein zweiter Suchweg in der Datenbank wäre
|
||
dieselbe Abwägung wie bei FTS5.
|
||
- Ein Umzug bleibt reines Kopieren, in beide Richtungen.
|
||
|
||
Später denkbar, erst bei konkretem Bedarf: `TIMESTAMPTZ` statt Text,
|
||
`BOOLEAN` statt 0/1, `TEXT[]` für die kommagetrennten Felder
|
||
`entry_versions.tags` und `search_log.synonyms`.
|
||
|
||
#### Entscheidung: Migrationen je Datenbank getrennt
|
||
|
||
```
|
||
internal/store/migrations/sqlite/001_init.sql … 006_search_log.sql
|
||
internal/store/migrations/postgres/001_init.sql … 006_search_log.sql
|
||
internal/store/migrations_control/sqlite/…
|
||
internal/store/migrations_control/postgres/…
|
||
```
|
||
|
||
- **Warum:** Jede Datenbank bekommt ihr passendes SQL, ohne die andere
|
||
einzuschränken. Laufen die beiden später auseinander, ist das sichtbar und
|
||
gewollt statt in Sonderfällen versteckt.
|
||
- **Preis:** Jede Schemaänderung wird zweimal geschrieben.
|
||
- **Absicherung, zwei Tests:**
|
||
- Beide Verzeichnisse enthalten dieselben Dateinamen. So bleibt keine
|
||
Migration auf einer Seite liegen. Läuft ohne PostgreSQL.
|
||
- Nach allen Migrationen haben beide Datenbanken dasselbe Schema: Tabellen,
|
||
Spalten, NOT NULL, Standardwerte, Indizes (SQLite über `pragma_table_info`,
|
||
PostgreSQL über `information_schema`). Erst dieser Test merkt, wenn eine
|
||
Spalte auf einer Seite anders angelegt wurde. Braucht PostgreSQL, siehe
|
||
*Tests* unten.
|
||
- **Für den Anfang:** Die PostgreSQL-Fassungen bleiben so nah wie möglich an
|
||
SQLite (weiter TEXT-Zeitstempel, 0/1). Dann bleiben die Go-Typen gleich, und
|
||
der Umzug von Bestandsdaten ist ein reines Kopieren. Echte PostgreSQL-Typen
|
||
(`TIMESTAMPTZ`, `BOOLEAN`) erst in einem späteren Schritt.
|
||
|
||
#### Entscheidung: ein Schema je Workspace
|
||
|
||
Heute hat jeder Workspace eine eigene Datei, dazu kommt `control.db` (4.4). In
|
||
PostgreSQL entspricht dem am ehesten **ein Schema je Workspace** plus ein
|
||
Schema für die Kontroll-Datenbank. Jeder Workspace bekommt einen eigenen Pool
|
||
mit `search_path=<schema>` in der Verbindungs-URL. Abfragen und Migrationen
|
||
bleiben damit unverändert, die Abschottung ist dieselbe wie heute.
|
||
`Manager.Delete` wird zu `DROP SCHEMA … CASCADE`.
|
||
|
||
**Verworfen: eine gemeinsame Datenbank, gefiltert über `workspace_id`.** Die
|
||
Spalte steht zwar an jeder Zeile, aber die Abfragen filtern nicht durchgängig
|
||
danach, weil eine Datei bisher immer nur einen Workspace enthält. Jede
|
||
vergessene Bedingung wäre ein Datenleck zwischen Abteilungen.
|
||
|
||
#### Entscheidung: Schemanamen aus der Workspace-ID
|
||
|
||
| Schema | Inhalt |
|
||
|---|---|
|
||
| `munin_control` | Kontroll-Datenbank (Workspaces, Key-Routing, Workspace-Admins) |
|
||
| `munin_ws_<id>` | ein Workspace, z.B. `munin_ws_mue9bh3x_1c0e1120` |
|
||
|
||
- **Präfix `munin_`:** Alles von Munin ist in der Datenbank auf einen Blick
|
||
erkennbar (`\dn munin_*`), auch wenn dort andere Anwendungen liegen.
|
||
- **ID statt Name:** Workspaces lassen sich umbenennen
|
||
(`ControlStore.UpdateWorkspace`). Ein Schema mit dem Namen müsste dann
|
||
mitumbenannt werden, während offene Pools noch auf den alten `search_path`
|
||
zeigen. Dazu kommt: Namen sind freier Text mit Umlauten und Leerzeichen,
|
||
sind nicht zwingend eindeutig, und PostgreSQL begrenzt Namen auf 63 Byte.
|
||
Derselbe Grund, aus dem der Export die ID als Ordnernamen nimmt (4.4).
|
||
- **Umwandlung:** Die ID enthält `-`, das in PostgreSQL überall Anführungszeichen
|
||
erzwingen würde. Deshalb `-` → `_`, an genau einer Stelle (eine Funktion im
|
||
Dialekt), mit Test.
|
||
- **Zuordnung in der Datenbank:** Den lesbaren Namen trägt ein Kommentar am
|
||
Schema, `COMMENT ON SCHEMA munin_ws_… IS '<Workspace-Name>'`. Er wird beim
|
||
Anlegen gesetzt und bei `UpdateWorkspace` nachgezogen. `\dn+` zeigt ihn
|
||
neben dem Schemanamen an.
|
||
|
||
#### Entscheidung: ein Datenbankbenutzer (2026-10-16)
|
||
|
||
Munin nutzt **einen** Benutzer für alles: Schemas anlegen und löschen,
|
||
Migrationen, Lesen und Schreiben. Er braucht `CONNECT, CREATE` auf der
|
||
Datenbank (oder ist ihr Eigentümer); alles Weitere ergibt sich daraus, dass
|
||
ihm gehört, was er anlegt. Übersicht je Vorgang: `BETRIEB.md`, „Rechte des
|
||
Datenbankbenutzers“.
|
||
|
||
**Geprüft und verworfen: getrennte Benutzer** für Verwaltung (`munin_admin`:
|
||
Schemas, Migrationen, Eigentümer) und Betrieb (`munin_app`: nur `SELECT`,
|
||
`INSERT`, `UPDATE`, `DELETE`). Technisch ginge es mit einer zweiten
|
||
`DATABASE_ADMIN_URL`, Migrationen über die Verwaltungsverbindung, `GRANT …`
|
||
und `ALTER DEFAULT PRIVILEGES` je Schema (etwa 1 Tag). Der Gewinn ist aber
|
||
klein:
|
||
|
||
- Workspaces werden zur Laufzeit über die Oberfläche angelegt, Migrationen
|
||
laufen beim ersten Öffnen. Der laufende Prozess bräuchte also **beide**
|
||
Zugänge; wer ihn übernimmt, hat beide.
|
||
- Geschützt wäre nur der normale Abfrageweg gegen versehentliche DDL. Dort
|
||
nutzt Munin durchgehend Platzhalter.
|
||
- Den Verwaltungsbenutzer ganz aus dem Prozess zu halten, ginge nur mit
|
||
Workspaces per Befehl beim Deployment oder mit `SECURITY DEFINER`-Funktionen
|
||
in der Datenbank (PostgreSQL-eigen, kein Gegenstück in SQLite).
|
||
|
||
**Wenn es doch gebraucht wird** (etwa weil der Datenbankbetrieb getrennte
|
||
Benutzer verlangt): obiger Weg. Löschen kann dann nur `munin_admin`, denn
|
||
`DROP SCHEMA` verlangt das Eigentum (erprobt). Beim Umstieg überträgt
|
||
`REASSIGN OWNED BY munin_app TO munin_admin;` (als Superuser) das Eigentum an
|
||
allen bestehenden Schemas und Tabellen auf einmal (erprobt).
|
||
|
||
#### Entscheidung: Verbindungen fest begrenzt
|
||
|
||
PostgreSQL erlaubt standardmäßig nur 100 Verbindungen (`max_connections`).
|
||
Heute hat jeder Pool 8 Lese- und 1 Schreibverbindung. Bei 40 Workspaces wären
|
||
das über 360 Verbindungen. PostgreSQL würde neue Verbindungen ablehnen, und
|
||
Anfragen schlügen mit Fehlern fehl.
|
||
|
||
**Warum kleine Pools reichen:** Eine Verbindung ist nur für die Dauer einer
|
||
Abfrage oder Transaktion belegt, also Millisekunden (`get` ≈ 1 ms, `recall`
|
||
≈ 5 ms, siehe `LASTTEST.md`). Während ein Agent bzw. das LLM nachdenkt, hält
|
||
Munin keine Verbindung. Länger belegt ist eine Verbindung nur beim Aufbau
|
||
eines Suchindex oder bei Export und Import, und das ist eine einzelne
|
||
Leseverbindung.
|
||
|
||
**Regeln:**
|
||
|
||
1. **Feste, kleine Pools unter PostgreSQL:** je Workspace 2 Lese- und
|
||
2 Schreibverbindungen, `munin_control` 2. Bis die schreibenden Methoden
|
||
abgesichert sind (Schritt 4), bleibt es bei 1 Schreibverbindung. Unter
|
||
SQLite bleibt alles wie heute.
|
||
2. **Leerlauf gibt Verbindungen zurück:** `SetConnMaxIdleTime` (z.B. 2
|
||
Minuten). Ein ruhiger Workspace hält dann gar keine Verbindung.
|
||
3. **In Go warten statt in PostgreSQL scheitern:** Ist ein Pool voll, wartet
|
||
`database/sql` auf eine freie Verbindung, statt einen Fehler zu liefern.
|
||
Begrenzt wird das Warten durch den Kontext der Anfrage. Solange die Summe
|
||
aller Pools unter `max_connections` bleibt, kann PostgreSQL also nie
|
||
„too many connections“ melden. Schlimmstenfalls wird es langsamer.
|
||
4. **Rechnung, die immer aufgehen muss:**
|
||
`Workspaces × 4 + 2 ≤ max_connections − Reserve (10)`.
|
||
Bei 40 Workspaces sind das 162, also `max_connections = 200`.
|
||
5. **Prüfung durch Munin, statt sich darauf zu verlassen:** Beim Start liest
|
||
Munin `SHOW max_connections` und rechnet nach. Geht die Rechnung nicht
|
||
auf, startet Munin nicht und nennt die nötige Zahl. Das Anlegen eines
|
||
Workspaces, der die Rechnung sprengen würde, wird mit derselben Meldung
|
||
abgelehnt. So fällt die Grenze beim Einrichten auf und nicht unter Last.
|
||
|
||
#### Unterschiede im Verhalten
|
||
|
||
- **Sortierung:** `ORDER BY slug` bzw. `ORDER BY tag` (z.B. `entries.go`)
|
||
sortiert SQLite Byte für Byte. PostgreSQL sortiert nach Spracheinstellung,
|
||
und bei `en_US`/`de_DE` werden Bindestriche zunächst übergangen (`a-c` vor
|
||
`ab`). Deshalb die Datenbank mit `--locale=C` anlegen, dann verhält sie sich
|
||
wie SQLite. Die Startprüfung (siehe oben) kann das gleich mit prüfen.
|
||
- **`LIKE`** unterscheidet in PostgreSQL Groß- und Kleinschreibung, in SQLite
|
||
(bei ASCII) nicht. Die heutigen Stellen in `audit.go` und `usage.go`
|
||
vergleichen feste, kleingeschriebene Texte. Bei neuen Abfragen beachten.
|
||
- **`NULL` beim Sortieren:** PostgreSQL sortiert `NULL` bei `DESC` nach vorn,
|
||
SQLite nach hinten. Betrifft heute nichts (nur `deleted_at` ist nullbar).
|
||
- **Ausführungspläne:** Manche Abfragen sind auf SQLite zugeschnitten, z.B.
|
||
verzichtet `entries.go` (verwandte Einträge) bewusst auf `ORDER BY`, weil
|
||
SQLite sonst schlecht plant. In Schritt 3 die häufigen Wege (`recall`,
|
||
`get`, `remember`, verwandte Einträge) mit `EXPLAIN` prüfen. Ob das Tempo
|
||
stimmt, zeigt der Lasttest.
|
||
- **Start ohne Datenbank:** Eine SQLite-Datei ist immer da, PostgreSQL ist es
|
||
vielleicht noch nicht (Container fährt hoch). Beim Start einige Male mit
|
||
Pause erneut versuchen, dann mit klarer Meldung abbrechen.
|
||
|
||
#### Mehrere Schreibverbindungen
|
||
|
||
**Was die eine Schreibverbindung heute kostet** (`LASTTEST.md`, ein Projekt,
|
||
30 % Schreibanteil):
|
||
|
||
| Agenten | Schreibvorgänge/s | Wartezeit Schreiben p50 / p95 | Fehler |
|
||
|---|---|---|---|
|
||
| 32 | ≈ 1.370 | 13 / 42 ms | 0 |
|
||
| 128 | ≈ 1.430 | 68 / 190 ms | 0 |
|
||
|
||
Der Durchsatz bleibt gleich, die Schreiber stellen sich an, und die
|
||
Wartezeit wächst mit der Zahl der Agenten. Gemessen am Schreibbudget (1.000
|
||
je *Stunde*) ist das reichlich. Zu beachten: Seit dem Suchprotokoll (2.4)
|
||
schreibt auch jeder `recall` und steht damit in derselben Schlange.
|
||
|
||
- **Unter SQLite bleibt es bei einer Schreibverbindung.** SQLite lässt
|
||
ohnehin nur einen Schreiber gleichzeitig zu. Mehr Verbindungen führen nur zu
|
||
„database is locked“ (296 von 320 Schreibvorgängen in einer früheren
|
||
Messung, siehe `store.go`).
|
||
- **Unter PostgreSQL sind mehrere Schreibverbindungen nötig.** Dort geht jede
|
||
Transaktion über das Netzwerk und dauert länger. Eine einzige Verbindung wäre
|
||
langsamer als heute mit SQLite.
|
||
|
||
**Voraussetzung:** Manche Abläufe sind heute nur sicher, weil alle
|
||
Schreibvorgänge eines Workspaces nacheinander laufen. Bekanntes Beispiel:
|
||
`PutEntry` (`entries.go`) liest `version`, prüft `ExpectedVersion`, sichert die
|
||
alte Fassung und schreibt `version + 1`. Laufen zwei Aufrufe gleichzeitig,
|
||
lesen unter PostgreSQL (READ COMMITTED) beide denselben Stand:
|
||
|
||
- **Bestehender Eintrag:** Beide schreiben dieselbe neue Version, und eine
|
||
Änderung geht verloren.
|
||
- **Neuer Eintrag:** Beide legen an, und der zweite scheitert an
|
||
`UNIQUE (project_id, slug)`, statt zu aktualisieren.
|
||
|
||
**Abhilfe, in dieser Rangfolge:**
|
||
|
||
1. **`INSERT … ON CONFLICT … DO UPDATE`**, wo es passt. Das können beide
|
||
Datenbanken (SQLite ab 3.24), der Code bleibt also gemeinsam.
|
||
2. **Gelesene Zeile sperren**, wo erst geprüft werden muss (etwa
|
||
`ExpectedVersion`). Achtung: `SELECT … FOR UPDATE` gibt es **nur in
|
||
PostgreSQL**, SQLite meldet einen Syntaxfehler. Deshalb über eine
|
||
Dialekt-Methode, die bei PostgreSQL `" FOR UPDATE"` liefert und bei SQLite
|
||
`""`. SQLite braucht die Sperre nicht, dort schreibt ohnehin nur eine
|
||
Verbindung.
|
||
|
||
Vor dem Freischalten mehrerer Schreiber alle schreibenden Methoden in
|
||
`internal/store` auf dieses Muster durchsehen. Danach gehört die Regel in den
|
||
Kopfkommentar von `store.go`: *Schreibende Methoden sperren, was sie lesen und
|
||
danach ändern, oder nutzen `ON CONFLICT`.* Neuer Code kann sonst unter SQLite
|
||
korrekt sein und unter PostgreSQL still Änderungen verlieren. Normale Tests
|
||
finden das kaum, weil der Fehler nur bei echter Gleichzeitigkeit auftritt. Den Nachweis liefert der
|
||
Lasttest gegen PostgreSQL mit vielen Schreibern: Er prüft bereits, dass kein
|
||
Schreibvorgang verloren geht. Bis dahin kann auch PostgreSQL mit einer
|
||
Schreibverbindung je Workspace laufen. Das Verhalten ist dann dasselbe wie
|
||
heute.
|
||
|
||
#### Entwicklung mit Docker
|
||
|
||
`docker-compose.dev.yml` startet PostgreSQL 18 für die Entwicklung: Sortierung
|
||
`C`, `max_connections=200`, Port nur auf localhost. Beim allerersten Start
|
||
legt `scripts/postgres-dev-init.sql` zusätzlich die Datenbank `munin_test` für
|
||
die Tests an.
|
||
|
||
```
|
||
docker compose -f docker-compose.dev.yml up -d
|
||
DB_DRIVER=postgres DATABASE_URL='postgres://munin:munin@localhost:5432/munin?sslmode=disable' go run .
|
||
```
|
||
|
||
**Tests:** Die Store-Tests laufen zusätzlich gegen PostgreSQL, wenn
|
||
`MUNIN_TEST_PG` gesetzt ist (Datenbank `munin_test`, siehe
|
||
`internal/store/postgres_test.go`). Ohne die Variable werden sie übersprungen, und
|
||
`go test ./...` braucht weiterhin kein Docker. Jeder Test bekommt ein eigenes,
|
||
zufällig benanntes Schema und räumt es am Ende ab, damit parallele Tests sich
|
||
nicht stören.
|
||
|
||
**Manueller Ablauf.** Tests werden von Hand aufgerufen, eine automatische
|
||
Prüfung gibt es nicht. Der Gleichstand beider Datenbanken hängt also an
|
||
diesem Ablauf:
|
||
|
||
| Wann | Was |
|
||
|---|---|
|
||
| Bei jeder Änderung | `go test ./...` (nur SQLite) |
|
||
| Nach einer neuen Migration oder geänderten Abfrage | zusätzlich `MUNIN_TEST_PG=… go test ./...` mit laufendem Container. Dabei laufen Store-Suite, Schema-Vergleich und Export/Import (`TestRoundTripPostgres`) gegen PostgreSQL |
|
||
| Nach Änderungen am Schreibweg | Lasttest (Auslöser wie in `LASTTEST.md`). Läuft vorerst nur gegen SQLite; die PostgreSQL-Fassung kommt mit Schritt 4 |
|
||
| Vor einer Auslieferung | einmal alles gegen beide Datenbanken |
|
||
|
||
#### Reihenfolge und Aufwand
|
||
|
||
Schätzung, nicht erprobt:
|
||
|
||
| Schritt | Inhalt | Aufwand | Stand |
|
||
|---|---|---|---|
|
||
| 1 | Docker-Compose, Konfiguration, Treiber, Dialekt mit `Rebind`, Migrationen aufteilen, Start mit Wiederholung | 1–2 Tage | ✅ 2026-10-16 |
|
||
| 2 | Workspace-Manager auf Schema je Workspace (`munin_ws_<id>`, Kommentar mit Namen), `munin_control`, feste Poolgrößen mit Startprüfung | 1½ Tage | ✅ 2026-10-16 |
|
||
| 3 | Store-Tests gegen PostgreSQL, Dialektfehler beheben, `EXPLAIN` der häufigen Wege | 1½–2½ Tage | ✅ 2026-10-16 |
|
||
| 4 | Schreibende Methoden absichern, mehrere Schreiber, Lasttest gegen PostgreSQL | 1–2 Tage | offen |
|
||
| 5 | Backup-Skript, `BETRIEB.md` | ½ Tag | ✅ 2026-10-16 |
|
||
| optional | Umzugswerkzeug SQLite → PostgreSQL für Bestandsdaten | 1 Tag | offen |
|
||
|
||
Bis Schritt 3 ist PostgreSQL mit einem Schreiber voll nutzbar. Schritt 4 ist
|
||
der eigentliche Gewinn, aber auch die heikelste Stelle.
|
||
|
||
**Ergebnisse Schritt 3 (2026-10-16):**
|
||
|
||
- Die ganze Store-Testsuite lief ohne einen Fehler gegen PostgreSQL. Gefunden
|
||
hat sie nur einen Fehler in einer Test-Hilfsfunktion (aufräumen nach
|
||
gelöschtem Workspace).
|
||
- Export und Import laufen unverändert, `TestRoundTripPostgres` belegt es.
|
||
- Abfragepläne mit 5.000 Einträgen, Schlagworten, Verweisen und Protokoll
|
||
(PostgreSQL 18 im Container, MacBook):
|
||
|
||
| Abfrage | Plan | Zeit |
|
||
|---|---|---|
|
||
| `get` (Eintrag nach Bezeichner) | Index `entries_project_id_slug_key` | 0,03 ms |
|
||
| Liste, neueste 50 | Index `idx_entries_project_updated` rückwärts | 0,03 ms |
|
||
| Rückverweise, verwandte Einträge | `idx_entry_links_project`, Primärschlüssel | 0,01 ms |
|
||
| Protokoll je Projekt | Index `idx_audit_ts` | 0,04 ms |
|
||
| Liste nach Schlagwort | `idx_entry_tags_project`, dann Hash Join | 1,0 ms |
|
||
| Schlagwortmenü | ganze Tabelle `entry_tags` | 1,3 ms |
|
||
| Suchen ohne Treffer | ganze Tabelle `search_log`, Fensterfunktion | 1,4 ms |
|
||
| Suchindex aufbauen | ganze Tabelle `entries` (braucht alle Zeilen) | 0,4 ms |
|
||
|
||
Alles, was je Anfrage läuft, geht über Indizes. Wo die ganze Tabelle
|
||
gelesen wird, braucht die Abfrage ohnehin (fast) alle Zeilen. Kein Index
|
||
fehlt, keine Abfrage musste geändert werden. Schreiben: 2,1 ms je
|
||
`PutEntry` samt Protokoll und Suchprotokoll.
|
||
|
||
**Ergebnisse Schritt 5 (2026-10-16):**
|
||
|
||
- `scripts/backup.sh` sichert mit `DB_DRIVER=postgres` jedes Schema einzeln
|
||
per `pg_dump` (`control.dump`, `workspaces/munin_ws_<id>.dump`) und prüft
|
||
jede Datei gleich mit `pg_restore --list`. `PSQL`, `PG_DUMP`, `PG_RESTORE`
|
||
lassen sich auf einen Container umstellen.
|
||
- **Bewusst kein einzelner Dump aller Schemas:** Daraus lässt sich ein
|
||
einzelner Workspace nicht zurückholen. `pg_restore --schema=…` legt das
|
||
Schema selbst nicht an, alle Tabellen scheitern dann (ausprobiert).
|
||
- Durchgespielt: Sicherung, einzelnen Workspace über einen bestehenden
|
||
zurückspielen (altes Schema als `munin_vorher_ws_…` zur Seite), komplette
|
||
Sicherung in eine neue Datenbank und Munin darauf starten. Die
|
||
Identitätsspalte von `search_log` zählt nach dem Zurückspielen richtig
|
||
weiter.
|
||
- Munin läuft mit einem Benutzer, der nur `CONNECT, CREATE` auf der Datenbank
|
||
hat (sie muss ihm nicht gehören), ebenso als Eigentümer der Datenbank. In
|
||
beiden Fällen ohne
|
||
Superuser-Rechte (ausprobiert).
|
||
- `BETRIEB.md` hat für jeden Ablauf die PostgreSQL-Fassung, dazu einen neuen
|
||
Abschnitt *PostgreSQL ist weg*. README *Mit PostgreSQL* nennt die
|
||
Anforderungen an die Produktiv-Datenbank.
|
||
- **Nicht erprobt:** wie sich Munin verhält, wenn PostgreSQL im laufenden
|
||
Betrieb neu startet. Erwartet: Anfragen scheitern während des Ausfalls,
|
||
danach baut `database/sql` neue Verbindungen auf. Beim nächsten Neustart des
|
||
Entwicklungs-Containers mit laufendem Munin nachsehen.
|
||
|
||
### 4.11 Bereitschaft prüfen und Probe der Sicherung — ✅ umgesetzt (2026-10-16)
|
||
|
||
Zwei Lücken, die mit PostgreSQL sichtbar wurden: Die Datenbank ist dort ein
|
||
eigener Dienst, der wegfallen kann, und die Probe der Sicherung bestand aus
|
||
vielen Handgriffen.
|
||
|
||
**`/live` und `/ready`** (`internal/web/handlers_ready.go`):
|
||
|
||
| Pfad | Prüft | Daran hängt |
|
||
|---|---|---|
|
||
| `/live` | nur, ob der Prozess antwortet | Neustart des Containers (`HEALTHCHECK` im Dockerfile). Fehlt nur die Datenbank, hülfe ein Neustart nicht |
|
||
| `/ready` | `SELECT 1` auf der Kontroll-Datenbank, Zeitlimit 2 s; sonst `503` | Load-Balancer, Monitoring |
|
||
|
||
- `/healthz` (früherer Name) bleibt als Alias für `/live`, damit vorhandene
|
||
Healthchecks und Monitore nicht ins Leere laufen.
|
||
- Die Antwort verrät nichts (`ok` bzw. `nicht bereit`), der Grund steht im
|
||
Log. Dort steht nur der **Wechsel** (`Bereitschaft: Datenbank antwortet
|
||
nicht …` / `… wieder`), sonst flutete eine Abfrage alle paar Sekunden das Log.
|
||
- Alle drei Pfade erscheinen nicht im Zugriffsprotokoll.
|
||
- Test: `TestLiveAndReady` (Kontroll-Datenbank geschlossen → `/ready` 503,
|
||
`/live` weiter 200, keine Einzelheiten in der Antwort).
|
||
|
||
**`scripts/sicherung-pruefen.sh`**: spielt eine Sicherung zurück (SQLite mit
|
||
`PRAGMA integrity_check`, PostgreSQL in eine leere Probe-Datenbank), startet
|
||
Munin darauf mit eigenem Port und eigenen Geheimnissen, wartet auf `/ready`,
|
||
vergleicht die Zahl der Workspaces und räumt auf. Ist die Probe-Datenbank
|
||
nicht leer, bricht es ab, statt etwas zu überschreiben.
|
||
|
||
Ausprobiert: SQLite und PostgreSQL bestanden; eine absichtlich beschädigte
|
||
SQLite-Datei fällt mit klarer Meldung durch; die echte Datenbank als
|
||
`PROBE_DATABASE_URL` wird abgelehnt und bleibt unberührt. Unterwegs gefunden:
|
||
`tr … </dev/urandom | head` beendet mit `pipefail` das Skript (SIGPIPE), und
|
||
`sqlite3` bricht bei schwerem Schaden selbst ab, bevor die eigene Meldung
|
||
kommt. Beides ist abgefangen.
|
||
|
||
---
|
||
|
||
## Teil 5 — Oberfläche
|
||
|
||
| | Nutzen | Aufwand |
|
||
|---|---|---|
|
||
| ✅ Vorschau beim Bearbeiten | Markdown sofort sehen | mittel (braucht JavaScript) |
|
||
| ✅ Autovervollständigung für `[[…]]` | Verweise ohne Abtippen | mittel |
|
||
| ✅ Graph-Ansicht der Verweise | Zusammenhänge sehen | hoch |
|
||
| ✅ Vergleich zweier Fassungen | setzt 3.6 voraus | mittel |
|
||
| ✅ Einträge in großer Zahl bearbeiten | Schlagworte nachziehen | gering |
|
||
| ✅ Eingebaute Hilfe unter `/hilfe` | Onboarding ohne Umweg über das Repository | mittel |
|
||
| ✅ Fassung wiederherstellen (5.1) | Fehler eines Agenten mit einem Klick zurücknehmen | gering |
|
||
| ✅ Echte Server-Adresse auf der Zugangsseite (5.2) | Konfiguration direkt kopierbar | gering |
|
||
| ✅ Mehr Markdown in Einträgen (5.3) | Tabellen, Checklisten, Durchstreichen | gering |
|
||
| ✅ Hilfe an Ort und Stelle verlinken (5.4) | Hilfe dort, wo man hängt | gering |
|
||
| ✅ Ausgewählte Einträge löschen (5.5) | Aufräumen ohne jeden Eintrag einzeln zu öffnen | gering |
|
||
| ✅ Verweise in Code ignorieren (5.6) | Beispiele in Code werden nicht zu falschen Verweisen | gering |
|
||
| ✅ Ähnliche Einträge beim Anlegen (5.7) | „Gibt es schon?“ auch für Menschen | gering |
|
||
| ✅ Rückfrage bei ungespeicherten Änderungen (5.8) | Kein verlorener Text | gering |
|
||
| ✅ Verwandte Einträge in der Ansicht (5.9) | Zweite Reihe im Verweisgraphen | gering |
|
||
| ✅ Veraltete Einträge sichtbar (5.10) | Gezielt aufräumen | gering |
|
||
| ✅ Protokoll filtern (5.11) | „Was hat dieser Zugang gestern gemacht?“ | gering |
|
||
| ✅ Systemseite (5.12) | Verbindungen gegen das Budget, Fassung | mittel |
|
||
| ✅ Suche über alle Projekte (5.13) | „In welchem Projekt stand das?“ | mittel |
|
||
| ✅ Tastenkürzel (5.14) | Schneller pflegen | gering |
|
||
| ✅ Hilfe für Workspace-Admins, Hilfe in der Kopfleiste, Formulare überarbeitet (5.15) | Orientierung und Lesbarkeit | gering |
|
||
| ✅ Systemseite: Zustand, Aktivität, Bestand (5.16) | Auf einen Blick: Ist etwas zu tun, wird Munin genutzt? | mittel |
|
||
| ✅ Überblick für Workspace-Admins (5.17) | Was im eigenen Workspace zu tun ist, je Projekt mit Link | gering |
|
||
|
||
Nichts davon ist dringend: Die Oberfläche ist bewusst der zweite Zugang, MCP ist
|
||
der erste.
|
||
|
||
### 5.7 Ähnliche Einträge beim Anlegen — ✅ umgesetzt (2026-10-16)
|
||
|
||
Agenten bekamen bei `remember` eine Rückfrage mit Kandidaten (3.3), Menschen
|
||
im Formular nicht. Das war die letzte Lücke im Schutz gegen doppeltes Wissen.
|
||
|
||
- Die Prüfung zog dafür aus `internal/mcpserver` in ein eigenes Paket
|
||
`internal/duplicates` (`Check`). MCP und Weboberfläche urteilen so mit
|
||
derselben Logik; zwei Fassungen liefen mit der Zeit auseinander.
|
||
- `GET /p/{projekt}/entries/similar?title=…&slug=…` (Schreibrecht) liefert die
|
||
Kandidaten als JSON, samt gemeinsamer Wörter. `static/similar.js` fragt
|
||
nach einer Tipp-Pause (400 ms) und zeigt unter dem Titel „Gibt es schon?“.
|
||
- **Nur ein Hinweis, keine Sperre:** Ein Mensch sieht die Liste und
|
||
entscheidet; speichern geht immer. Die Links öffnen einen neuen Tab, damit
|
||
der angefangene Text bleibt.
|
||
- Nur beim Anlegen, nicht beim Bearbeiten. Ist der Bezeichner schon vergeben,
|
||
ist es eine Aktualisierung — dann gibt es keinen Hinweis.
|
||
- Tests: `TestSimilarEntries`, `TestFormAndShortcuts`.
|
||
|
||
### 5.8 Rückfrage bei ungespeicherten Änderungen — ✅ umgesetzt (2026-10-16)
|
||
|
||
`static/unsaved.js` an Formularen mit `data-unsaved` (das
|
||
Bearbeitungsformular): Verlässt jemand die Seite mit Änderungen, fragt der
|
||
Browser nach (`beforeunload`). „Geändert“ heißt: anders als beim Laden — wer
|
||
tippt und wieder löscht, wird nicht gefragt. Beim Speichern keine Rückfrage.
|
||
Den Wortlaut legt der Browser fest; eigene Texte erlauben moderne Browser
|
||
nicht mehr. Bewusst kein Entwurf im Browser-Speicher: Das wären Inhalte, die
|
||
nach dem Abmelden auf dem Rechner liegen bleiben.
|
||
|
||
### 5.9 Verwandte Einträge in der Ansicht — ✅ umgesetzt (2026-10-16)
|
||
|
||
Unter einem Eintrag steht jetzt neben *Verweist hierher* der Kasten *Auch
|
||
verwandt*: die **zweite Reihe** aus `store.RelatedEntries` (dieselbe Funktion
|
||
wie MCP `related`), jeweils mit dem Zwischeneintrag. Die erste Reihe fehlt
|
||
dort absichtlich: Eigene Verweise stehen im Text, eingehende unter *Verweist
|
||
hierher*. Test: `TestAlsoRelated`.
|
||
|
||
### 5.10 Veraltete Einträge sichtbar machen — ✅ umgesetzt (2026-10-16)
|
||
|
||
„Veraltet“ (seit über einem Jahr unverändert, 3.5) stand nur in der
|
||
Einzelansicht. Jetzt:
|
||
|
||
- Marke *veraltet* in der Eintragsliste.
|
||
- Filterlink *N veraltet* neben der Suche (nur, wenn es welche gibt),
|
||
`?veraltet=1` zeigt genau diese.
|
||
- Kennzahl auf der Nutzungsseite, verlinkt auf die gefilterte Liste.
|
||
- Store: `EntryFilter.ChangedBefore`, `CountStaleEntries`, `StaleCutoff`.
|
||
Tests: `TestStaleEntriesFilterAndCount` (auch PostgreSQL),
|
||
`TestStaleEntries`.
|
||
|
||
### 5.11 Protokoll filtern — ✅ umgesetzt (2026-10-16)
|
||
|
||
Über der Tabelle: Vorgang (mit verständlichem Namen, `store.AuditActions`),
|
||
Zugang (Liste aus `store.AuditActors`), Zeitraum (ganze Tage), im Protokoll
|
||
über alle Projekte zusätzlich das Projekt. Ein GET-Formular, damit sich eine
|
||
gefilterte Ansicht als Adresse weitergeben lässt. Kaputte Werte in der Adresse
|
||
ergeben das ungefilterte Protokoll statt einer Fehlerseite. Höchstens 200
|
||
Vorgänge wie bisher. Tests: `TestAuditFilter` (auch PostgreSQL),
|
||
`TestAuditFilterForm`.
|
||
|
||
### 5.12 Systemseite — ✅ umgesetzt (2026-10-16)
|
||
|
||
`/admin/system`, nur Plattform-Admin:
|
||
|
||
- Datenbank (`Backend.Description`, ohne Passwort) und Bereitschaft wie
|
||
`/ready`.
|
||
- Offene Verbindungen gegen das Budget: mit PostgreSQL `max_connections` und
|
||
die daraus folgende Höchstzahl an Workspaces (`Backend.Capacity`,
|
||
`Capacity.MaxWorkspaces`), dazu je geöffnetem Workspace offen / in
|
||
Benutzung / lesen-schreiben / gewartet (`sql.DBStats` über
|
||
`Store.PoolStats`).
|
||
- Fassung aus den Build-Informationen von Go (`vcs.revision`, `vcs.time`,
|
||
`vcs.modified`), Go-Fassung, Start und Laufzeit.
|
||
|
||
Test: `TestSystemPage`.
|
||
|
||
### 5.13 Suche über alle Projekte — ✅ umgesetzt (2026-10-16)
|
||
|
||
`/admin/search`, für Plattform- und Workspace-Admins (wer alle Projekte
|
||
verwalten darf, darf auch in allen suchen). Durchsucht jeden Projektindex und
|
||
zeigt die Treffer **nach Projekt gruppiert**, je Projekt die besten fünf, die
|
||
Projekte nach ihrem besten Treffer sortiert. Eine gemeinsame Rangfolge gäbe es
|
||
nicht ehrlich: Jedes Projekt hat seinen eigenen BM25-Index, die Werte sind
|
||
nicht vergleichbar.
|
||
|
||
Bewusst getrennt von 4.9: Am Zugriff der Keys ändert sich nichts, ein Key und
|
||
ein Agent sehen weiter genau ein Projekt. Test: `TestSearchAllProjects`
|
||
(auch: ein Projektzugang kommt nicht dran).
|
||
|
||
### 5.14 Tastenkürzel — ✅ umgesetzt (2026-10-16)
|
||
|
||
`static/shortcuts.js`: `/` Suchfeld, `n` neuer Eintrag, `e` bearbeiten,
|
||
`Strg+Enter`/`Cmd+Enter` speichern. Das Ziel einer Taste steht im Template
|
||
(`data-shortcut="n"` an genau dem Link, den die Rolle sehen darf) — die Rechte
|
||
bleiben so an einer Stelle. Buchstaben gelten nur außerhalb von
|
||
Eingabefeldern, mit Strg/Alt/Cmd bleiben sie dem Browser.
|
||
|
||
### 5.15 Hilfe für Workspace-Admins, Hilfe in der Kopfleiste, Formulare — ✅ umgesetzt (2026-10-16)
|
||
|
||
- **Neue Hilfeseite „Für Workspace-Admins“** (`internal/help/pages/workspace-admin.md`):
|
||
Anmelden, was sie dürfen und was nicht, ein Projekt einrichten, Überblick
|
||
behalten (Nutzung, Protokoll, Suche über alle Projekte), regelmäßige Pflege,
|
||
Projekt löschen. Sichtbar für Workspace- und Plattform-Admin. Dafür ersetzt
|
||
eine **Zielgruppe** (`ForEveryone`, `ForAdmins`, `ForPlatformAdmin`) das
|
||
bisherige `NurAdmin`. Tests: Sichtbarkeit je Lesergruppe, keine Links auf
|
||
Seiten, die der Leser nicht sieht, und neu: **jeder `#Abschnitt`-Link
|
||
innerhalb der Hilfe trifft eine vorhandene Überschrift**
|
||
(`TestLinkAnchorsExist`).
|
||
- **„Hilfe & Einstieg“ steht in der Kopfleiste** links neben „Abmelden“ statt
|
||
unten in der Seitenleiste, wo sie bei vielen Projekten und Schlagworten aus
|
||
dem Blick rutschte. Auf schmalen Bildschirmen nur „Hilfe“.
|
||
- **Formulare:** Die Breitenklassen (`short`, `medium`, `long`) begrenzen jetzt
|
||
nur das Eingabefeld, nicht mehr Beschriftung und Hinweis — lange Hinweise
|
||
brachen vorher in eine Spalte von knapp 290 Pixeln um. Mehr Abstand zwischen
|
||
Feldern, Hinweise mit Zeilenabstand 1,5 und bis zur Lesebreite. Zahlen-,
|
||
Datums- und Dateifelder sind gestaltet wie die übrigen (vorher
|
||
Browser-Standard). Alle einzeiligen Bedienelemente und Knöpfe haben eine
|
||
gemeinsame Höhe (`--hoehe-bedienelement`, 2,5 rem); vorher 36 bis 42 Pixel,
|
||
in der Filterleiste standen Beschriftungen dadurch versetzt. Auswahllisten
|
||
mit langen Optionen (Rolle, Import-Modus) sind breiter. Hinweise an
|
||
Ankreuzfeldern stehen unter der Beschriftung.
|
||
- **Schmale Bildschirme:** Die Navigationszeilen scrollen in sich, statt die
|
||
ganze Seite zu verbreitern (`min-width: 0` fehlte); ein langer
|
||
Workspace-Name wird gekürzt.
|
||
- Geprüft mit Screenshots aus Chrome im Hintergrundmodus (1280 und 500 Pixel
|
||
breit; schmaler rendert Chrome dort nicht).
|
||
|
||
### 5.16 Systemseite: Zustand, Aktivität, Bestand — ✅ umgesetzt (2026-10-16)
|
||
|
||
Die Systemseite (5.12) beantwortete nur „Spielt die Datenbank mit?“. Dazu
|
||
kommen drei Abschnitte:
|
||
|
||
1. **Zustand** — ein Kasten mit allem, was zu tun ist, leer heißt „Alles in
|
||
Ordnung“. *Warnungen:* Kapazität ab 80 % der höchstens möglichen
|
||
Workspaces; Hinweise des Speichers (`Backend.Hints`, heute: PostgreSQL
|
||
sortiert nicht nach `C`); ein Workspace ließ sich nicht auswerten;
|
||
WAL-Datei über 64 MB (der Checkpoint kommt nicht durch); ein Workspace auf
|
||
einem anderen Schema-Stand als das Programm; der letzte Export
|
||
unvollständig, überfällig (Intervall + 2 Std.) oder seit dem Start nach
|
||
einer Stunde noch keiner fertig. *Hinweise:* Anfragen mussten seit der
|
||
vorigen Zählung auf eine Verbindung warten; Keys laufen in 14 Tagen ab;
|
||
Keys seit 90 Tagen ungenutzt (je Workspace genannt); ungenutzte
|
||
Workspace-Admin-Zugänge.
|
||
2. **Aktivität der letzten 30 Tage** über alle Workspaces: Schreibvorgänge je
|
||
Tag (Agenten und Menschen gestapelt) und Suchen je Tag als Balken, dazu
|
||
„Als Tabelle“, die zehn aktivsten Projekte und die Projekte ganz ohne
|
||
Aktivität. Quelle sind Protokoll (`entry_write`, `entry_delete`) und
|
||
Suchprotokoll (recall) — keine neue Zählung. 30 Tage, weil das
|
||
Suchprotokoll danach aufgeräumt wird. Tage in UTC.
|
||
3. **Bestand je Workspace:** Projekte, Einträge, Fassungen, Papierkorb,
|
||
gültige Keys, Platz (SQLite: Datei samt WAL; PostgreSQL: Tabellen des
|
||
Schemas samt Indizes über `pg_total_relation_size`), Summenzeile. Unter
|
||
„Programm“ zusätzlich Schema-Stand und letzter Export.
|
||
|
||
**Umsetzung:**
|
||
|
||
- `internal/store/sysinfo.go`: `Store.Inventory`, `Store.DiskUsage` (über
|
||
eine neue Dialekt-Methode `diskUsage`), `Store.Activity`,
|
||
`Store.SchemaVersion`. Keys werden in Go gezählt, mit denselben Regeln wie
|
||
die Seite „Zugänge“ (`APIKey.Unused`, `ExpiresSoon`).
|
||
- `internal/stats`: Der `Collector` fragt alle Workspaces ab und merkt sich
|
||
das Ergebnis 5 Minuten (`MinInterval`). Gerechnet wird beim Aufruf der
|
||
Seite, ohne Hintergrundlauf. Er öffnet dafür alle Workspaces — das tun das
|
||
Aufräumen und der Export beim Start ohnehin.
|
||
- `internal/web/chart.go`: Balkendiagramme als SVG, vom Server
|
||
ausgerechnet. Kein JavaScript, keine Bibliothek, keine `style`-Attribute
|
||
(CSP); Farben über Klassen aus `style.css`, damit das dunkle Schema passt.
|
||
Tooltip je Tag über `<title>`.
|
||
- `export.Exporter` merkt sich den letzten Lauf über alle Workspaces
|
||
(`RecordRun`, `LastRun`); `export.Interval` ersetzt die Konstante in
|
||
`main.go`.
|
||
|
||
**Nicht umgesetzt** (siehe 4.5): Laufzeitwerte wie Aufrufe je Werkzeug und
|
||
Dauern — sie brauchen eine neue Zählung und gehören in das künftige
|
||
Monitoring. Ebenso nicht: die Sicherung. Sie läuft per Cron außerhalb von
|
||
Munin; dafür gibt es das Log des Cronjobs und die Probe (4.11). Freier
|
||
Platz auf der Platte fehlt ebenfalls: Er braucht Systemaufrufe je
|
||
Betriebssystem und ist ein klassischer Wert für das Monitoring.
|
||
|
||
Tests: `TestInventory`, `TestDiskUsage`, `TestActivity`, `TestSchemaVersion`
|
||
(auch gegen PostgreSQL), `TestCollect`, `TestMinInterval`,
|
||
`TestChart`, `TestRound125`, `TestByteSize`, `TestSystemPageState`.
|
||
Geprüft mit Screenshots (1280 und 500 Pixel) mit Testdaten über 30 Tage.
|
||
|
||
### 5.17 Überblick für Workspace-Admins — ✅ umgesetzt (2026-10-16)
|
||
|
||
`/admin/overview`, für Workspace-Admins und den Plattform-Admin (für den
|
||
gewählten Workspace; in der Seitenleiste „Überblick (Workspace)“). Das
|
||
Gegenstück zur Systemseite, beschränkt auf das, was ein Workspace-Admin selbst
|
||
erledigen kann. Technik (Verbindungen, Platz, Schema, Export) bleibt auf der
|
||
Systemseite.
|
||
|
||
- **Kennzahlen:** Projekte, Einträge, gültige Keys, Schreibvorgänge und
|
||
Suchen der letzten 30 Tage.
|
||
- **Was zu tun ist**, je Projekt eine Karte, jeder Punkt mit Link genau
|
||
dorthin, wo er zu erledigen ist. Projekte mit Dringendem zuerst.
|
||
*Dringend:* ein Key läuft in 14 Tagen ab (mit Datum); kein gültiger Key im
|
||
Projekt. *Bei Gelegenheit:* kein Key mit Rolle `admin` (das Team muss für
|
||
jeden Zugang den Workspace-Admin fragen); Keys 90 Tage ungenutzt; 30 Tage
|
||
weder geschrieben noch gesucht (nicht bei Projekten ohne Key — dort steht
|
||
schon die Warnung); ab 30 % Suchen ohne Treffer bei mindestens 10 Suchen;
|
||
kuratierte Einträge zur Prüfung; Einträge über ein Jahr unverändert.
|
||
Jede Prüfung nimmt dieselbe Regel wie die verlinkte Seite.
|
||
- **Projekte:** Tabelle mit Einträgen, Schreibvorgängen (Agenten/Menschen),
|
||
Suchen, Anteil ohne Treffer, letzter Änderung; der Name führt zur Nutzung.
|
||
- **Aktivität:** dieselben Diagramme wie auf der Systemseite, nur für diesen
|
||
Workspace.
|
||
|
||
Umsetzung: `internal/web/handlers_overview.go`;
|
||
`stats.Collector.ForWorkspace` (ohne Zwischenspeicher — nach einem neuen
|
||
Key soll der Hinweis sofort verschwinden); das Diagramm-Template liegt jetzt
|
||
in `partials.html`. Die Startseite des Workspace-Admins bleibt die
|
||
Projektliste. Test: `TestWorkspaceAdminOverview` (auch: Warnungen vor
|
||
Hinweisen, keine Technik auf der Seite, ein Projektzugang kommt nicht dran).
|
||
|
||
### 5.6 Verweise in Code ignorieren — ✅ umgesetzt (2026-10-16)
|
||
|
||
Ein Eintrag, der erklärt, wie man verweist, schreibt dafür ein Beispiel in
|
||
Code. Früher wurde auch das als Verweis gezählt: Es erschien unter „Verweise
|
||
ins Leere“, und beim Anzeigen stand mitten im Code ein Markdown-Link statt des
|
||
Beispiels. Aufgefallen beim Dokumentieren in Munin selbst (`munin-ueberblick`
|
||
verwies auf einen Eintrag „bezeichner“).
|
||
|
||
- `ExtractLinks` und `ReplaceLinks` (`internal/store/links.go`) übergehen
|
||
Codeblöcke (```` ``` ```` bzw. `~~~`, mit Info-Zeichenkette, auch bis zum
|
||
Textende offen) und Code im Fließtext zwischen gleich langen
|
||
Backtick-Folgen.
|
||
- Eingerückte Codeblöcke (vier Leerzeichen) bewusst nicht: von eingerückten
|
||
Listenzeilen nicht sicher zu unterscheiden, und ein übersehener echter
|
||
Verweis wäre schlimmer als ein überflüssiger.
|
||
- Bestehende Einträge behalten ihre alten Verweise in `entry_links`, bis sie
|
||
das nächste Mal geschrieben werden.
|
||
- Tests: Fälle in `TestExtractLinks`, `TestReplaceLinksLeavesCodeAlone`.
|
||
|
||
### 5.5 Ausgewählte Einträge löschen — ✅ umgesetzt (2026-10-14)
|
||
|
||
In der Sammelleiste der Eintragsliste steht neben *Anwenden* der Knopf
|
||
*In den Papierkorb* (`POST /p/{projekt}/entries/bulk-delete`, Schreibrecht).
|
||
|
||
- Selbes Formular wie die Schlagwort-Sammelbearbeitung, anderer Knopf
|
||
(`formaction`). Er steht hinter *Anwenden*, weil Enter in einem Textfeld
|
||
den ersten Knopf auslöst. Rückfrage über `data-confirm` (static/confirm.js).
|
||
- `store.MoveEntriesToTrash`: eine Transaktion, Auswahl über die interne ID;
|
||
fremde und schon gelöschte IDs werden übersprungen wie bei
|
||
`BulkUpdateTags`.
|
||
- Je Eintrag eine Protokollzeile `entry_delete`, damit Nutzungsübersicht und
|
||
Protokoll jede Löschung einzeln sehen.
|
||
- Die Weiterleitung behält Schlagwortfilter und Suche (gilt jetzt auch für die
|
||
Schlagwort-Sammelbearbeitung).
|
||
|
||
### 5.1 Fassung wiederherstellen — ✅ umgesetzt
|
||
|
||
Der Verlauf (3.6) zeigt alte Fassungen, aber zurückholen heißt heute: Text aus
|
||
dem Vergleich kopieren und von Hand einfügen. **Lösung:** Knopf *Diese Fassung
|
||
wiederherstellen* in der Versionsansicht (nur mit Schreibrecht). Technisch ist
|
||
das ein gewöhnliches `PutEntry` mit Titel, Inhalt und Schlagworten der alten
|
||
Fassung und `ExpectedVersion` der aktuellen. Der aktuelle Stand wandert dabei
|
||
selbst in den Verlauf, es geht nichts verloren. Das Protokoll sollte vermerken,
|
||
dass es eine Wiederherstellung war und von welcher Fassung.
|
||
|
||
**✅ Umgesetzt 2026-09-24:** `POST /p/{project}/e/{slug}/versions/{version}/restore`
|
||
(`handleEntryRestore`, `internal/web/handlers_entries.go`), Knopf auf der
|
||
Vergleichsseite und je Zeile im Verlauf, nur mit Schreibrecht. Das Formular
|
||
schickt die beim Anzeigen aktuelle Fassung als `current_version` mit; fehlt sie,
|
||
wird abgelehnt (400) statt ohne Konfliktprüfung zu schreiben, bei einem Konflikt
|
||
kommt die Verlaufsseite mit Hinweis (409). Die Markierung *kuratiert* bleibt,
|
||
wie sie ist — sie beschreibt den Eintrag, nicht einen Textstand. Im Protokoll
|
||
steht `wiederhergestellt: Fassung n` (`store.EntryRestoreDetail`); für die
|
||
Zählung in 4.6 zählt das als Aktualisierung (`detail LIKE 'wiederhergestellt%'`).
|
||
|
||
### 5.2 Echte Server-Adresse auf der Zugangsseite — ✅ umgesetzt
|
||
|
||
`keys.html` zeigt in den Konfigurationsblöcken noch `https://DEIN-SERVER/mcp`.
|
||
Die Hilfe setzt die Adresse schon ein (`serverURL` in
|
||
`internal/web/handlers_help.go`). Dieselbe Funktion für `keyListData`
|
||
verwenden. Gerade hier zählt es, denn der Key ist nur dieses eine Mal zu sehen.
|
||
|
||
**✅ Umgesetzt 2026-09-24:** `keyListData.ServerURL`, gesetzt in
|
||
`renderKeyList`; `keys.html` nutzt es in allen drei Konfigurationsblöcken.
|
||
|
||
### 5.3 Mehr Markdown in Einträgen — ✅ umgesetzt
|
||
|
||
`entry.RenderHTML` nutzt goldmark ohne Erweiterungen. Tabellen erscheinen in
|
||
Einträgen deshalb als Text, in der Hilfe (`internal/help`) dagegen als
|
||
Tabelle. **Lösung:** `extension.GFM` einschalten (Tabellen, Durchstreichen,
|
||
Checklisten, automatische Links). Roh-HTML bleibt gesperrt, die
|
||
Sicherheitsannahme in `RenderHTML` ändert sich nicht. Danach den Hinweis auf
|
||
der Hilfeseite *Weboberfläche* entfernen und Hilfe und Einträge über denselben
|
||
Renderer laufen lassen.
|
||
|
||
**✅ Umgesetzt 2026-09-24:** Neues Paket `internal/markdown` — ein
|
||
goldmark-Renderer mit `extension.GFM` für beide. `entry.RenderHTML` und
|
||
`help.Render` rufen nur noch dort hinein. Einziger Unterschied:
|
||
`RenderWithAnchors` (Hilfe) vergibt Überschrift-ids, `Render` (Einträge) nicht —
|
||
Eintragstext stammt von Menschen und Agenten, und eine Überschrift „## suche"
|
||
soll nicht mit einer id der Seite selbst zusammenstoßen können.
|
||
`markdown_test.go` prüft, dass auch mit GFM weder Roh-HTML noch
|
||
`javascript:`-Links durchkommen. Die Hilfe beschreibt jetzt Tabellen,
|
||
Checklisten, Durchstreichen und automatische Links.
|
||
|
||
### 5.4 Hilfe an Ort und Stelle verlinken — ✅ umgesetzt
|
||
|
||
Kleine „?“-Links von einer Seite zur passenden Hilfe, zum Beispiel *Zugänge* →
|
||
`/hilfe/zugaenge`, Editor → Markdown-Abschnitt, leere Eintragsliste →
|
||
*Agent anbinden*. Dazu Überschrift-Anker in den Hilfeseiten
|
||
(`parser.WithAutoHeadingID`), damit Links auf einen Abschnitt zeigen können.
|
||
|
||
**✅ Umgesetzt 2026-09-24:** Partial `hilfelink` in `partials.html`, ein
|
||
kleines „?" neben Überschriften und dem Inhaltsfeld des Editors: Einträge,
|
||
Editor (Markdown-Abschnitt), Verlauf, Vergleich, Zugänge, Protokoll (je nach
|
||
Ansicht Projekt- oder Admin-Hilfe), Workspaces, Projekte. Die leere
|
||
Eintragsliste verlinkt auf *Agent anbinden* und *Mit Agenten arbeiten*. Die
|
||
Hilfe öffnet in einem neuen Tab, damit beim Nachschlagen im Editor kein
|
||
ungespeicherter Text verloren geht.
|
||
|
||
Die Anker erzeugt nicht goldmarks eingebauter Generator — der lässt Umlaute
|
||
einfach fallen („zugnge-verwalten") —, sondern `anchorIDs` in
|
||
`internal/markdown` über `store.Slugify` („zugaenge-verwalten"), dieselbe
|
||
Regel wie für Eintragsbezeichner. `TestHelpLinksPointToExistingSections`
|
||
liest alle Templates, sucht jedes Hilfe-Ziel und prüft, dass Seite und
|
||
Abschnitt existieren. Wer eine Überschrift in der Hilfe umbenennt, erfährt so
|
||
im Test, welcher Link dadurch ins Leere zeigen würde.
|
||
|
||
**✅ Sammelbearbeitung umgesetzt:** `entries.html` zeigt bei Schreibrecht ein
|
||
Auswahlkästchen je Eintrag plus eine Sammelleiste (Schlagworte hinzufügen/
|
||
entfernen, kommagetrennt). `Store.BulkUpdateTags` (`internal/store/entries.go`)
|
||
prüft je ID, dass sie wirklich zum aufrufenden Projekt gehört — eine
|
||
untergeschobene ID aus einem fremden Projekt wird stillschweigend übersprungen,
|
||
nicht verändert. Nur tatsächlich geänderte Einträge zählen als Treffer und
|
||
zählen ihre `version` hoch; der Suchindex wird für sie nachgezogen. Kein
|
||
JavaScript nötig — ein einziges Formular um die ganze Liste.
|
||
|
||
**✅ Fassungsvergleich umgesetzt:** Baut auf 3.6 auf. `/p/{project}/e/{slug}/versions`
|
||
zeigt den Verlauf, `/versions/{version}` vergleicht eine abgelöste Fassung mit
|
||
ihrer Nachfolgerin — entweder der nächsten abgelösten oder, wenn es keine mehr
|
||
gibt, der aktuell gültigen. Der Zeilenvergleich selbst
|
||
(`internal/entry/diff.go`, `DiffLines`) ist eine klassische
|
||
LCS-Implementierung ohne externe Abhängigkeit, mit einer Notbremse für
|
||
außergewöhnlich lange Einträge (`coarseDiff` oberhalb von vier Millionen
|
||
Vergleichszellen). Auch das kein JavaScript — reines serverseitiges Rendern.
|
||
|
||
**✅ Vorschau beim Bearbeiten umgesetzt:** Das erste nennenswerte
|
||
JavaScript im Projekt (`static/preview.js`, ~90 Zeilen) neben `confirm.js`.
|
||
Reiter "Schreiben"/"Vorschau" über dem Inhaltsfeld in `edit.html`; ein Klick
|
||
auf "Vorschau" schickt den aktuellen Textarea-Inhalt per `fetch` an
|
||
`POST /p/{project}/preview` (`handleEntryPreview`,
|
||
`internal/web/handlers_entries.go`) und zeigt das Ergebnis an. Bewusst kein
|
||
Markdown-Parsing im Browser: Der Endpunkt ruft denselben `entry.RenderHTML`
|
||
auf wie die Detailansicht, Vorschau und späteres Ergebnis können also nicht
|
||
auseinanderlaufen — und es ist keine externe JS-Bibliothek nötig, die an der
|
||
Content-Security-Policy (`script-src 'self'`) vorbeigemusst hätte. Ohne
|
||
JavaScript bleibt einfach der Reiter "Schreiben" aktiv, das Formular
|
||
funktioniert unverändert. Kein Live-Rendern beim Tippen, nur auf Klick —
|
||
hält die Zahl der Aufrufe klein.
|
||
|
||
**✅ Autovervollständigung umgesetzt:** `static/autocomplete.js` erkennt
|
||
beim Tippen ein noch nicht geschlossenes `[[`, fragt
|
||
`GET /p/{project}/entries/suggest?q=…` (`handleEntrySuggest`,
|
||
`internal/web/handlers_entries.go`) und zeigt die Treffer als Liste unter dem
|
||
Feld — bewusst nicht an der Schreibmarke ausgerichtet, das bräuchte einen
|
||
Spiegel-Trick zur Pixel-Positionierung im Textfeld, den die Liste unter dem
|
||
Feld erspart. Tastatur (Pfeiltasten, Enter/Tab, Escape) und Maus bedienbar.
|
||
Die Suche selbst dupliziert keine Logik: Mit Suchbegriff läuft dieselbe
|
||
BM25-Volltextsuche wie bei `recall` (MCP) und der Projektsuche
|
||
(`search.Registry`), ohne Suchbegriff kommen die zuletzt geänderten
|
||
Einträge. Ausgewählt wird der Bezeichner (slug), nicht der Titel — das ist,
|
||
was `[[…]]` erwartet.
|
||
|
||
**✅ Graph-Ansicht umgesetzt:** `/p/{project}/graph` (Seite) und
|
||
`/p/{project}/graph/data` (JSON, `handleEntryGraphData`). Datenseitig neu ist
|
||
nur `Store.LinkGraph` (`internal/store/entries.go`) — eine Abfrage über den
|
||
bereits vorhandenen, indizierten `entry_links`. Verweise auf einen
|
||
Bezeichner ohne Eintrag erzeugen einen eigenen, gestrichelten Knoten
|
||
(`Exists: false`) statt stillschweigend zu verschwinden — sichtbar macht,
|
||
wohin schon verwiesen wird, was aber noch fehlt.
|
||
|
||
Begrenzung wie in der Aufwands-/Risikoabschätzung besprochen:
|
||
`graphNodeLimit = 150` — bei mehr Einträgen bleiben die am stärksten
|
||
verknüpften übrig (nach Grad sortiert), die Antwort trägt `truncated: true`
|
||
und die Gesamtzahl, die Seite zeigt einen Hinweis. Das hält sowohl die
|
||
Kräftesimulation (`static/graph.js`, O(Knoten²) je Durchlauf) als auch die
|
||
SVG-Darstellung in einem Bereich, der im Browser flüssig bleibt.
|
||
|
||
Kein externes Diagramm-Werkzeug: Die Kräftesimulation (Abstoßung zwischen
|
||
allen Knotenpaaren, Anziehung entlang der Kanten, schwache Zentrierung) ist
|
||
selbst geschrieben, läuft einmal beim Laden durch und rendert dann reines
|
||
SVG. Texte werden ausschließlich über `textContent` gesetzt, nie
|
||
`innerHTML` — Eintragstitel kommen von Menschen und Agenten und sind damit
|
||
nicht vertrauenswürdig. Zoom (Mausrad) und Pan (Ziehen) über eine simple
|
||
Transform-Gruppe, keine Abhängigkeit auf eine Bibliothek.
|
||
|
||
---
|
||
|
||
## Empfohlene Reihenfolge
|
||
|
||
Wenn es demnächst weitergehen soll, in dieser Folge — jeweils kleiner Aufwand,
|
||
sichtbarer Gewinn:
|
||
|
||
~~1. Zugriffsprotokoll (4.1)~~ · ~~2. Umlaute falten (2.1)~~ ·
|
||
~~3. Automatisierte Tests (4.2)~~ — erledigt.
|
||
|
||
~~4. Obergrenze je Projekt (3.8)~~ — erledigt.
|
||
|
||
Als Nächstes:
|
||
|
||
*Stand der Priorisierung: 2026-09-24.*
|
||
|
||
**Vor dem zweiten Workspace**
|
||
|
||
1. ~~**Offene Reste aus 4.4** (WP12 und WP13): Protokoll aufräumen und Export
|
||
für alle Workspaces.~~ — erledigt 2026-09-24, zusammen mit der
|
||
Unterscheidung angelegt/aktualisiert im Protokoll (Voraussetzung für 4.6).
|
||
|
||
**Klein, sofort spürbar für die Teams**
|
||
|
||
2. ~~**Fassung wiederherstellen** (5.1) und **Server-Adresse auf der
|
||
Zugangsseite** (5.2). Zusammen ein kleines Paket.~~ — erledigt 2026-09-24.
|
||
3. ~~**Mehr Markdown** (5.3) und **Hilfe an Ort und Stelle** (5.4).~~ —
|
||
erledigt 2026-09-24.
|
||
|
||
**Mittel, wichtig bei vielen Teams**
|
||
|
||
4. ~~**Papierkorb** (3.9): schließt den einzigen Schaden, der sich heute nicht
|
||
rückgängig machen lässt.~~ — erledigt 2026-09-24.
|
||
5. ~~**Nutzungsübersicht** (4.6): zeigt, ob die Teams Munin wirklich benutzen,
|
||
und liefert die Zahlen für Punkt 7.~~ — erledigt 2026-09-24.
|
||
6. ~~**Ungenutzte Zugänge** (4.7), zunächst nur Stufe 1.~~ — erledigt
|
||
2026-09-25. Stufe 2 (Ablaufdatum) erst, wenn Stufe 1 nicht reicht.
|
||
|
||
**Bewusst entscheiden, nicht nebenbei**
|
||
|
||
7. **`instructions` schärfen** (3.1) — braucht ein paar Wochen echter Nutzung.
|
||
Das Zugriffsprotokoll, 4.6 und seit 2026-10-14 das Suchprotokoll liefern
|
||
die Daten dafür: Wie viele `remember`-Aufrufe legen neu an, wie viele
|
||
Suchen bleiben ohne Treffer?
|
||
8. ~~**Kandidaten bei Dubletten** (3.3)~~ — erledigt 2026-10-14, zusammen mit
|
||
dem Schutz kuratierter Einträge (3.3a). **Bezeichner vorschlagen** (3.4)
|
||
bleibt offen; es ändert den MCP-Vertrag.
|
||
9. ~~**Admin je Workspace** (4.8)~~ — erledigt 2026-09-30, ohne Keycloak
|
||
(siehe die Entscheidung dort). **Keycloak** (4.3) bleibt offen, bis es
|
||
gebraucht wird.
|
||
10. **PostgreSQL als zweiter Speicherort** (4.10) — produktiv nutzbar seit
|
||
2026-10-16 (Schritte 1, 2, 3, 5). Offen: mehrere Schreiber (Schritt 4),
|
||
erst wenn die Wartezeiten beim Schreiben es verlangen. Den genauen Stand
|
||
führt 4.10.
|
||
11. ~~**`/live` und `/ready`, Probe der Sicherung** (4.11), **Verweise in Code**
|
||
(5.6), **Dublettenprüfung nachgeschärft** (3.3)~~ — erledigt 2026-10-16.
|
||
12. ~~**Weboberfläche:** ähnliche Einträge beim Anlegen (5.7), Rückfrage bei
|
||
ungespeicherten Änderungen (5.8), verwandte Einträge (5.9), veraltete
|
||
Einträge (5.10), Protokoll filtern (5.11), Systemseite (5.12), Suche über
|
||
alle Projekte (5.13), Tastenkürzel (5.14), **Ablaufdatum für Keys** (4.7
|
||
Stufe 2)~~ — erledigt 2026-10-16.
|
||
13. ~~**Systemseite: Zustand, Aktivität, Bestand** (5.16) und **Überblick für
|
||
Workspace-Admins** (5.17)~~ — erledigt 2026-10-16. Als Nächstes dort: `/metrics` bzw. OpenTelemetry (4.5),
|
||
sobald das zentrale Monitoring steht.
|
||
|
||
Nebenbei zu beobachten: ob 1.000 Aufrufe je Stunde die richtige Zahl sind. Die
|
||
Vermutung ist, dass echte Projekte weit darunter bleiben — nachsehen lässt es
|
||
sich in der Spalte „Schreibbudget" unter `/admin/projects`. Wenn dort nie etwas
|
||
knapp wird, ist die Zahl gut gewählt; wird sie regelmäßig erreicht, ist entweder
|
||
das Limit zu eng oder ein Agent tut etwas Unsinniges.
|
||
|
||
Zusätzlich vorgezogen und erledigt 2026-09-25: **Verwandte Einträge** (3.7),
|
||
**Aktualität, kleiner Schritt** (3.5) und **Importer** (3.2).
|
||
|
||
Alles darüber hinaus erst, wenn der Betrieb zeigt, dass es gebraucht wird.
|
||
|
||
**Was ausdrücklich nicht auf dieser Liste steht:** FTS5, getrennte Datenbanken,
|
||
semantische Suche, Zugriff über ein Projekt hinaus (4.9). Alle vier sind gute
|
||
Ideen für den Fall, dass ihre Auslöser eintreten — und vier gute Wege, Zeit in
|
||
Probleme zu stecken, die es noch nicht gibt.
|