journal-bot/docs/superpowers/specs/2026-06-16-service-tray-gui...

157 lines
8.8 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Design: Windows Service + Tray-GUI für journal-bot
**Datum:** 2026-06-16
**Status:** Approved (brainstorming) → bereit für Implementierungsplan
## Ziel
Zwei dünne .NET-Hüllen um die bestehende Python-CLI:
1. **Windows Service** holt Telegram-Nachrichten alle 15 Minuten selbstständig ab (`ingest`).
2. **Tray-GUI (WPF)** zeigt die Historie, Queue-Status und Logs und triggert `ingest`/`process`/`both` manuell.
Die Python-Codebase bleibt die fachliche Schnittstelle. Service und GUI rufen sie nur über die CLI auf und lesen das `runtime/`-Dateisystem. Keine direkte IPC zwischen Service und GUI — das Dateisystem ist die Schnittstelle.
## Architektur
```
┌─────────────────────┐ ┌──────────────────────────┐
│ JournalBot.Service │ │ JournalBot.Tray (WPF) │
│ (.NET Windows Svc, │ │ User-Prozess, @logon │
│ LocalSystem) │ │ │
│ Timer alle 15 min │ │ Tray-Icon + Fenster │
│ → python -m │ │ Tabs: Historie | Log │
│ journal_bot │ │ Status: pending/failed │
│ ingest │ │ Buttons: Ingest/Process │
│ │ │ /Both │
└──────────┬──────────┘ └───────────┬──────────────┘
│ schreibt │ liest done/, logs/
│ pending/ │ ruft python-CLI
▼ ▼
┌──────────────────────────────────────────┐
│ runtime/ (queue/{pending,done,failed}, │
│ logs/) ← gemeinsame Schnittst. │
└──────────────────────────────────────────┘
python -m journal_bot
(ingest / process / both)
```
**Hybrid-Aufteilung (Kernentscheidung):**
- **Service** macht *nur* `ingest`. Telegram braucht keinen User-Login → sammelt zuverlässig, läuft als `LocalSystem` auch ohne eingeloggten User.
- **Tray-GUI** macht `process`. Das braucht LM Studio, das als interaktive User-App nur bei eingeloggtem User läuft → daher User-Prozess, manueller Trigger.
Damit umgehen wir das Session-0-Isolations-Problem (ein echter Service kann kein Tray-Icon zeichnen, und `LocalSystem` erreicht LM Studio nicht).
Zwei separate .NET-Projekte in einer Solution unter `tools/`, self-contained EXEs (kein .NET-Runtime-Install nötig).
## Komponente 1: JournalBot.Service
`.NET Worker Service`, echter Windows Service via `Microsoft.Extensions.Hosting.WindowsServices`.
- **`Worker : BackgroundService`** — `PeriodicTimer(TimeSpan.FromMinutes(IntervalMinutes))`-Loop. Pro Tick: startet `python.exe -m journal_bot ingest` als Subprozess, wartet aufs Ende, loggt Exit-Code + stdout/stderr.
- **Konfiguration** via `appsettings.json` neben der EXE: `PythonExe` (Pfad zu `.venv\Scripts\python.exe`), `WorkingDir` (Projekt-Root), `IntervalMinutes` (Default 15).
- **Kontext:** `LocalSystem`. `ingest` braucht nur Telegram + Dateisystem, kein LM Studio, kein User-Profil. `WorkingDir` wird explizit gesetzt.
- **Logging:** `runtime/logs/service.log` (eigene Datei) **und** Windows Event Log.
- **Fehlertoleranz:** Ein fehlgeschlagener `ingest`-Aufruf wird geloggt, Loop läuft weiter. Kein Service-Crash.
- **Installation:** `scripts/install-service.ps1` (`New-Service`/`sc.exe`), Service-Name `JournalBotService`.
**Zu verifizieren beim Bauen:** Lädt der Bot die `.env` relativ zum CWD? Falls ja, genügt `WorkingDir` = Projekt-Root. Falls absolut, entsprechend anpassen.
## Komponente 2: JournalBot.Tray (WPF)
WPF-App, `OutputType=WinExe` (kein Konsolenfenster), Autostart @logon.
**Tray-Icon** (`Hardcodet.NotifyIcon.Wpf`):
- Linksklick → Fenster zeigen/verstecken. Fenster schließen = Minimieren in Tray (kein Beenden).
- Kontextmenü: *Ingest* · *Process* · *Both* · *Fenster öffnen* · *Beenden*.
- Icon-Status: roter Indikator bei `failed > 0`.
**Hauptfenster:**
- **Status-Banner oben:** „X pending · Y failed · letzter Lauf: HH:mm" — live aus `runtime/queue/`.
- **Tab „Historie":** `DataGrid` über done-Items, neueste zuerst. Spalten: Zeit (`received_at`), Typ, Text-Snippet, **Ziel-Daily-Note** (aus erweitertem Item), Status. Zeilenklick → Detail (voller Text + geschriebener Eintrag).
- **Tab „Log":** letzte N Zeilen aus `runtime/logs/` (process-Log + service.log), Auto-Refresh, Monospace.
- **Buttons:** *Ingest* · *Process* · *Both*`python -m journal_bot <cmd>` als Subprozess, stdout/stderr in den Log-Tab, Buttons disabled während Lauf, Ergebnis als Statusmeldung.
**Datenzugriff:**
- `RuntimeReader`: liest `done/`, `pending/`, `failed/`, `logs/`. `FileSystemWatcher` auf `runtime/queue/` + Polling-Fallback alle paar Sekunden → Live-Update.
- `BotRunner`: kapselt Subprozess-Start (Python-Pfad + WorkingDir aus `appsettings.json`).
- `QueueItem.cs`: C#-DTO, spiegelt das Python-Pydantic-Modell.
**Config:** eigene `appsettings.json` neben der Tray-EXE (`PythonExe`, `WorkingDir`, `RuntimePath`, `ProcessTimerMinutes`).
**Auto-Process-Timer:** standardmäßig **aus** (`ProcessTimerMinutes` = 0/null). `process` schlägt ohne laufendes LM Studio fehl, daher manueller Trigger als Default. Kann per Config aktiviert werden.
## Komponente 3: Python-Änderungen (erweiterte Historie)
Damit die GUI Schreibziel + geschriebenen Eintrag zeigt:
**`QueueItem`** (`queue.py`) bekommt optionale Result-Felder, von `process` nach erfolgreichem Schreiben befüllt:
```python
target_path: Optional[str] = None # z.B. "05 Daily Notes/2026-06-15.md"
written_entry: Optional[str] = None # der tatsächlich geschriebene Markdown-Eintrag
processed_at: Optional[str] = None # ISO 8601
```
Alle optional → keine bestehenden Tests brechen, alte done-Items ohne Felder bleiben lesbar.
**`process.py`:** vor `queue.complete(item)` die drei Felder setzen (target_path + entry aus VaultWriter-Aufruf / ProcessorOutput, processed_at = now).
**`queue.py``complete`:** analog zu `fail` umbauen — aktualisiertes Item-JSON atomar (`.tmp + replace`) in `done/` schreiben, dann `working/`-Quelle löschen. Erhält die Crash-Safety. (Bisher reines `rename`, das die Result-Info nicht persistieren würde.)
**Tests (TDD):** bestehende `complete`-Tests anpassen; neuer Test: nach `process_once` trägt das done-Item `target_path`/`written_entry`/`processed_at`. Erst failing test, dann Umbau.
## Repo-Layout
```
tools/
JournalBot.sln
JournalBot.Service/
Worker.cs # PeriodicTimer → python ingest
Program.cs # Host + UseWindowsService
appsettings.json # PythonExe, WorkingDir, IntervalMinutes
JournalBot.Service.csproj
JournalBot.Tray/
App.xaml / App.xaml.cs
MainWindow.xaml / .cs # Tabs Historie|Log, Status-Banner, Buttons
TrayIcon.xaml # NotifyIcon + Kontextmenü
Services/
RuntimeReader.cs # liest done/pending/failed/logs, FileSystemWatcher
BotRunner.cs # Subprozess python -m journal_bot <cmd>
QueueItem.cs # C#-DTO, spiegelt Python-Pydantic-Modell
appsettings.json
JournalBot.Tray.csproj
scripts/
install-service.ps1 # New-Service / sc.exe, JournalBotService
install-tray.ps1 # Autostart @logon (Run-Key oder Scheduler)
(install-task.ps1 → entfällt / ersetzt)
```
## Build & Deployment
**Build:** `dotnet publish -c Release -r win-x64 --self-contained` → zwei EXEs, kein .NET-Runtime-Install nötig.
**Deployment-Flow:**
1. `.env` füllen (wie gehabt).
2. `dotnet publish` beider Projekte.
3. `install-service.ps1` (Admin) → registriert + startet `JournalBotService` (ingest alle 15 min).
4. `install-tray.ps1` → Tray-App @logon.
5. LM Studio starten, dann in der Tray-GUI `process`/`both` triggern.
**Voraussetzung:** .NET SDK installiert (`dotnet --version` zu Beginn prüfen).
## Testing
- **Python:** TDD, `pytest` muss grün bleiben (38 Tests + neue).
- **.NET:** `RuntimeReader` + `BotRunner` mit xUnit (JSON-Parsing, Pfadlogik). GUI-XAML nicht unit-getestet → manueller Smoke-Test. Service-Timer-Loop manuell verifiziert (`BotRunner` mockbar).
## Doku
CLAUDE.md um Abschnitt „Service + Tray (tools/)" ergänzen.
## Offene Fragen
- `.env`-Auflösung relativ zum CWD oder absolut? (Beim Bauen des Service verifizieren.)
- Autostart der Tray-App: Registry Run-Key vs. Task Scheduler @logon — beim Implementieren festlegen (Run-Key simpler, Scheduler robuster).
- `.NET` SDK-Version vorhanden? (`dotnet --version` zu Beginn.)