--- id: DIS-151 status: in-progress phase: 1.5 priority: p1 labels: [phase:1.5, type:feat, priority:p1] branch: refinement/channel-categories assignee: developer-agent started: 2026-04-10 pr: null merged: null --- # DIS-151: Channel-Kategorisierung (aktiv / inaktiv / archiv) + Lifecycle-Management ## Ziel Discord-Channels werden beim Start von DisClaw automatisch in Kategorien eingeordnet. Verwaiste Workspaces und Channels werden aufgeräumt. Live-Events halten den Zustand aktuell. ## Kontext Beim Betrieb entstehen Inkonsistenzen: Channels ohne Workspace, Workspaces ohne Channel, oder Agents die aus der DB entfernt wurden aber noch einen Workspace haben. → `docs/ideas/known-issues.md` KI-002, KI-002.1 ## Scope ### Kategorien (werden beim ersten Start automatisch angelegt falls nicht vorhanden) - **`🟢 Aktive Agents`** — Channel ist in DB, Workspace existiert unter `~/.disclaw/workspaces/` - **`🔴 Inaktive Agents`** — Workspace existiert, aber kein Channel in Discord (oder Channel nicht in DB) - **`🗄️ Archiv`** — Kein DB-Eintrag, kein Workspace — Channel existiert aber ist unbekannt ### Startup-Reconciliation (`src/lifecycle/reconcile.ts`) Beim Start einmalig ausführen, nach DB-Init und Bot-Login: 1. **Alle Channels des Servers scannen** — `guild.channels.fetch()` für vollständigen Cache 2. **DB-Einträge abgleichen** — für jeden Workspace-Eintrag in DB prüfen ob Channel noch existiert 3. **Workspace-Verzeichnisse scannen** — alle Verzeichnisse unter `workspaces_root` mit `agent.yaml` 4. **Einordnung**: - DB-Eintrag + Workspace vorhanden + Channel existiert → **aktiv** - Workspace vorhanden, aber kein DB-Eintrag / kein Channel → **inaktiv** - Weder DB noch Workspace, aber Channel existiert → **archiv** - Workspace existiert, kein Channel in aktiv/inaktiv/archiv → Workspace-Verzeichnis löschen ### Live-Event: `channelDelete` - Wenn ein Agent-Channel gelöscht wird: Workspace-Verzeichnis löschen, DB-Eintrag entfernen - Wenn Management-Channel gelöscht wird: automatisch neu anlegen (KI-002.1) - Kategorie-Channels (aktiv/inaktiv/archiv) werden bei Löschung neu angelegt ## Definition of Done - [ ] Kategorien werden beim Start automatisch angelegt (falls nicht vorhanden) - [ ] Channels werden korrekt eingeordnet (aktiv/inaktiv/archiv) - [ ] Workspace-Verzeichnisse ohne Channel werden beim Start gelöscht - [ ] `channelDelete`-Event: Workspace + DB-Eintrag werden entfernt - [ ] Management-Channel-Löschung: wird neu angelegt - [ ] Unit-Test `tests/unit/reconcile.test.ts`: alle Reconcile-Cases grün - [ ] `npm run build && npm test` grün --- ## Implementierungsplan (Senior Developer Review) ### Analyse: Was fehlt | Was | Status | |-----|--------| | `db.deleteWorkspaceByChannelId()` | fehlt — muss in `database.ts` ergänzt werden | | `src/lifecycle/` | komplett neu | | `channelDelete`-Event-Handler | fehlt in `bot.ts` | | `new-agent.ts` übergibt kein `parent` | Channel landet in keiner Kategorie | | `managementChannelId` ist lokale Variable | muss mutierbar sein für Delete-Handler | ### Neue Dateien | Datei | Zweck | |---|---| | `src/lifecycle/categories.ts` | `ensureCategories()` + `moveToCategory()` | | `src/lifecycle/reconcile.ts` | Startup-Reconciliation-Algorithmus | | `src/lifecycle/channel-delete-handler.ts` | `channelDelete`-Event-Logik | | `tests/unit/reconcile.test.ts` | Unit-Tests | ### Modifizierte Dateien | Datei | Änderung | |---|---| | `src/db/database.ts` | `deleteWorkspaceByChannelId(channelId): boolean` | | `src/bot.ts` | State-Objekt, `ensureCategories` + `reconcile` aufrufen, `channelDelete`-Listener | | `src/commands/new-agent.ts` | `parent: activeCategoryId` beim Channel-Erstellen | ### Implementierungsreihenfolge ``` SCHRITT 1: src/db/database.ts - deleteWorkspaceByChannelId(channelId: string): boolean - Löscht zuerst conversations, dann workspace (manuelles CASCADE) - Kein Schema-Change nötig SCHRITT 2: src/lifecycle/categories.ts (NEU) - CATEGORY_NAMES-Konstante exportieren - ResolvedCategories-Interface exportieren - ensureCategories(guild: Guild): Promise - moveToCategory(channelId: string, category: CategoryChannel): Promise SCHRITT 3: src/lifecycle/reconcile.ts (NEU) - ReconcileResult-Interface exportieren - reconcile(guild, db, categories, workspacesRoot, managementChannelId): Promise - guild.channels.fetch() am Anfang für vollständigen Cache - Management-Channel + Kategorie-Channels aus der Einordnung ausschließen - workspace_path === "__management__" in DB-Abfragen filtern - try/catch um jeden channel.setParent()-Aufruf - try/catch um jedes fs.rmSync() SCHRITT 4: src/lifecycle/channel-delete-handler.ts (NEU) - ChannelDeleteContext-Interface: db, config, guild, categories, managementChannelId, setManagementChannelId, setCategories - handleChannelDelete(channel, ctx): Promise - Fall 1: Agent-Channel → DB-Eintrag + Workspace-Dir löschen - Fall 2: Management-Channel → neu erstellen, setManagementChannelId aufrufen - Fall 3: Kategorie-Channel → ensureCategories() erneut aufrufen, setCategories aufrufen SCHRITT 5: src/bot.ts ANPASSEN - State-Objekt: { managementChannelId: string|null, categories: ResolvedCategories|null } - Nach Management-Channel-Setup im ready-Handler: a) categories = await ensureCategories(guild) b) await reconcile(guild, db, categories, config.workspaces_root, managementChannelId) - Neuer Listener: client.on("channelDelete", ch => handleChannelDelete(ch, ctx)) - categories an handleNewAgent() durchreichen SCHRITT 6: src/commands/new-agent.ts ANPASSEN - Signatur: handleNewAgent(interaction, db, config, activeCategoryId?: string) - parent: activeCategoryId bei guild.channels.create() setzen SCHRITT 7: tests/unit/reconcile.test.ts (NEU) - Mock-Factories für Guild, DB, Categories - vi.mock("node:fs") für Filesystem-Operationen - Alle Test-Cases (siehe unten) - npm run build && npm test grün ``` ### Interfaces (Zusammenfassung) ```typescript // src/lifecycle/categories.ts export const CATEGORY_NAMES = { active: "🟢 Aktive Agents", inactive: "🔴 Inaktive Agents", archive: "🗄️ Archiv", } as const; export interface ResolvedCategories { active: CategoryChannel; inactive: CategoryChannel; archive: CategoryChannel; } // src/lifecycle/reconcile.ts export interface ReconcileResult { active: string[]; // channel IDs moved to active inactive: string[]; // channel IDs moved to inactive archived: string[]; // channel IDs moved to archive deletedWorkspaces: string[]; // workspace dirs deleted } // src/lifecycle/channel-delete-handler.ts export interface ChannelDeleteContext { db: DisclawDatabase; config: DisclawConfig; guild: Guild; categories: ResolvedCategories; managementChannelId: string; setManagementChannelId: (id: string) => void; setCategories: (cats: ResolvedCategories) => void; } // src/db/database.ts (Erweiterung) deleteWorkspaceByChannelId(channelId: string): boolean; ``` ### Testplan: `tests/unit/reconcile.test.ts` | # | Szenario | Erwartung | |---|----------|-----------| | 1 | DB + Workspace + Channel vorhanden | Channel → aktiv-Kategorie | | 2 | Workspace + DB, kein Channel in Guild | Channel → inaktiv-Kategorie | | 3 | Workspace, kein DB-Eintrag | → inaktiv | | 4 | Channel in Guild, kein DB, kein Workspace | Channel → archiv-Kategorie | | 5 | Workspace, kein Channel nirgends, kein DB | Workspace-Dir wird gelöscht | | 6 | Management-Channel | wird nicht kategorisiert | | 7 | Kategorie-Channels selbst | werden nicht als archiv eingestuft | | 8 | Leerer workspaces_root, keine DB-Einträge | kein Fehler, leeres Result | | 9 | channelDelete: Agent-Channel | DB-Eintrag + Workspace gelöscht | | 10 | channelDelete: Management-Channel | Neuer Channel wird erstellt | | 11 | channelDelete: Kategorie-Channel | Kategorie wird neu erstellt | | 12 | ensureCategories: alle fehlen | 3 Kategorien werden erstellt | | 13 | ensureCategories: teilweise vorhanden | Nur fehlende werden erstellt | ### Randfälle + Risiken - `guild.channels.fetch()` vor Reconciliation — Cache kann nach `ready` unvollständig sein - `workspace_path === "__management__"` in DB-Abfragen herausfiltern - `fs.rmSync` auf Windows kann bei gesperrten Dateien fehlschlagen → immer `try/catch` - Discord API Rate-Limits bei vielen Channels: `try/catch` pro `setParent()`, nicht alles parallel - `channelDelete` kann während Reconciliation feuern → erst nach `await reconcile()` Listener registrieren ## Abhängigkeiten Phase 0 + Phase 1 abgeschlossen. ## Spec → `docs/ideas/known-issues.md` KI-002, KI-002.1