From 56b861f34cafdbd202699fbea8c1dbe80d467f4c Mon Sep 17 00:00:00 2001 From: Nick Tabeling Date: Fri, 10 Apr 2026 14:43:32 +0200 Subject: [PATCH] feat(DIS-151): channel categorization + lifecycle management - Add deleteWorkspaceByChannelId() to DisclawDatabase - Add src/lifecycle/categories.ts: ensureCategories() + moveToCategory() - Add src/lifecycle/reconcile.ts: reconcile() assigns channels to active/inactive/archive categories on startup - Add src/lifecycle/channel-delete-handler.ts: handles channelDelete events for management, category, and agent channels - Wire lifecycle into bot.ts: state object, ensureCategories, reconcile, channelDelete listener - Extend handleNewAgent() with optional activeCategoryId to place new channels in the active category - Add 13 unit tests covering all lifecycle scenarios Co-Authored-By: Claude Sonnet 4.6 --- src/bot.ts | 44 +- src/commands/new-agent.ts | 4 +- src/db/database.ts | 8 + src/lifecycle/categories.ts | 65 +++ src/lifecycle/channel-delete-handler.ts | 83 ++++ src/lifecycle/reconcile.ts | 158 +++++++ tests/unit/reconcile.test.ts | 582 ++++++++++++++++++++++++ 7 files changed, 936 insertions(+), 8 deletions(-) create mode 100644 src/lifecycle/categories.ts create mode 100644 src/lifecycle/channel-delete-handler.ts create mode 100644 src/lifecycle/reconcile.ts create mode 100644 tests/unit/reconcile.test.ts diff --git a/src/bot.ts b/src/bot.ts index fff85d0..1c22df5 100644 --- a/src/bot.ts +++ b/src/bot.ts @@ -12,6 +12,9 @@ import { DisclawConfig, EnvConfig } from "./config/loader"; import { newAgentCommand, handleNewAgent } from "./commands/new-agent"; import { routeMessage } from "./router"; import { childLogger } from "./runtime/logger"; +import { ensureCategories, ResolvedCategories } from "./lifecycle/categories"; +import { reconcile } from "./lifecycle/reconcile"; +import { handleChannelDelete } from "./lifecycle/channel-delete-handler"; const log = childLogger("bot"); @@ -28,8 +31,11 @@ export async function startBot( ], }); - // Track the management channel id once resolved - let managementChannelId: string | null = null; + // Shared mutable state for lifecycle management + const state = { + managementChannelId: null as string | null, + categories: null as ResolvedCategories | null, + }; // --- Event: Ready --- client.once("ready", async () => { @@ -57,7 +63,7 @@ export async function startBot( existingWorkspace.channel_id ); if (existingChannel) { - managementChannelId = existingWorkspace.channel_id; + state.managementChannelId = existingWorkspace.channel_id; log.info({ channelName: managementName }, "Management channel found"); } else { // Channel was deleted from Discord, recreate it @@ -81,7 +87,7 @@ export async function startBot( managementName, "__management__" ); - managementChannelId = found.id; + state.managementChannelId = found.id; mgmtChannel = found; log.info({ channelName: managementName }, "Adopted existing channel"); } else { @@ -96,7 +102,7 @@ export async function startBot( managementName, "__management__" ); - managementChannelId = newChannel.id; + state.managementChannelId = newChannel.id; mgmtChannel = newChannel; log.info({ channelName: managementName }, "Created management channel"); } @@ -109,6 +115,30 @@ export async function startBot( .catch(() => {}); } + // --- Ensure lifecycle categories and reconcile --- + state.categories = await ensureCategories(guild); + await reconcile( + guild, + db, + state.categories, + disclawConfig.workspaces_root, + state.managementChannelId! + ); + + // --- Register channelDelete listener --- + client.on("channelDelete", async (channel) => { + if (!state.categories || !state.managementChannelId) return; + await handleChannelDelete(channel, { + db, + config: disclawConfig, + guild, + categories: state.categories, + managementChannelId: state.managementChannelId, + setManagementChannelId: (id) => { state.managementChannelId = id; }, + setCategories: (cats) => { state.categories = cats; }, + }); + }); + // --- Register slash commands --- const rest = new REST({ version: "10" }).setToken( envConfig.DISCORD_BOT_TOKEN @@ -132,7 +162,7 @@ export async function startBot( if (!interaction.isChatInputCommand()) return; if (interaction.commandName === "new-agent") { - await handleNewAgent(interaction, db, disclawConfig); + await handleNewAgent(interaction, db, disclawConfig, state.categories?.active.id); } }); @@ -144,7 +174,7 @@ export async function startBot( // Only handle text channels in guilds if (!message.guild || message.channel.type !== ChannelType.GuildText) return; - await routeMessage(message, db, disclawConfig, managementChannelId); + await routeMessage(message, db, disclawConfig, state.managementChannelId); }); // --- Login --- diff --git a/src/commands/new-agent.ts b/src/commands/new-agent.ts index f71a7a3..f1c9488 100644 --- a/src/commands/new-agent.ts +++ b/src/commands/new-agent.ts @@ -46,7 +46,8 @@ export const newAgentCommand = new SlashCommandBuilder() export async function handleNewAgent( interaction: ChatInputCommandInteraction, db: DisclawDatabase, - config: DisclawConfig + config: DisclawConfig, + activeCategoryId?: string ): Promise { const name = interaction.options.getString("name", true).toLowerCase(); const role = interaction.options.getString("role", true); @@ -89,6 +90,7 @@ export async function handleNewAgent( name, type: ChannelType.GuildText, topic: `Agent: ${name} | Rolle: ${role}`, + parent: activeCategoryId ?? null, }); // 2. Create workspace with full Claude Code environment diff --git a/src/db/database.ts b/src/db/database.ts index 2b49ddb..8eeba24 100644 --- a/src/db/database.ts +++ b/src/db/database.ts @@ -77,6 +77,14 @@ export class DisclawDatabase { .get(result.lastInsertRowid) as Workspace; } + deleteWorkspaceByChannelId(channelId: string): boolean { + const ws = this.getWorkspaceByChannelId(channelId); + if (!ws) return false; + this.db.prepare("DELETE FROM conversations WHERE workspace_id = ?").run(ws.id); + this.db.prepare("DELETE FROM workspaces WHERE id = ?").run(ws.id); + return true; + } + // --- Conversation queries --- addConversation( diff --git a/src/lifecycle/categories.ts b/src/lifecycle/categories.ts new file mode 100644 index 0000000..fb27d1f --- /dev/null +++ b/src/lifecycle/categories.ts @@ -0,0 +1,65 @@ +import { Guild, ChannelType, CategoryChannel, GuildChannel } from "discord.js"; // GuildChannel used for cast +import { childLogger } from "../runtime/logger"; + +const log = childLogger("lifecycle:categories"); + +export const CATEGORY_NAMES = { + active: "🟒 Aktive Agents", + inactive: "πŸ”΄ Inaktive Agents", + archive: "πŸ—„οΈ Archiv", +} as const; + +export type CategoryKey = keyof typeof CATEGORY_NAMES; + +export interface ResolvedCategories { + active: CategoryChannel; + inactive: CategoryChannel; + archive: CategoryChannel; +} + +/** + * Ensures all three lifecycle categories exist on the guild. + * Creates missing ones. Returns their resolved references. + */ +export async function ensureCategories(guild: Guild): Promise { + const resolved: Partial = {}; + + for (const key of Object.keys(CATEGORY_NAMES) as CategoryKey[]) { + const name = CATEGORY_NAMES[key]; + const existing = guild.channels.cache.find( + (ch) => ch.type === ChannelType.GuildCategory && ch.name === name + ) as CategoryChannel | undefined; + + if (existing) { + resolved[key] = existing; + log.info({ name }, "Category channel found"); + } else { + const created = await guild.channels.create({ + name, + type: ChannelType.GuildCategory, + }); + resolved[key] = created as CategoryChannel; + log.info({ name }, "Category channel created"); + } + } + + return resolved as ResolvedCategories; +} + +/** + * Moves a text channel into the specified category. + * Silently swallows errors (channel may have been deleted concurrently). + */ +export async function moveToCategory( + channelId: string, + category: CategoryChannel +): Promise { + try { + const ch = category.guild.channels.cache.get(channelId); + if (!ch) return; + if (typeof (ch as GuildChannel).setParent !== "function") return; + await (ch as GuildChannel).setParent(category.id); + } catch (err) { + log.warn({ channelId, categoryId: category.id, err }, "moveToCategory failed (swallowed)"); + } +} diff --git a/src/lifecycle/channel-delete-handler.ts b/src/lifecycle/channel-delete-handler.ts new file mode 100644 index 0000000..34bb43d --- /dev/null +++ b/src/lifecycle/channel-delete-handler.ts @@ -0,0 +1,83 @@ +import { Channel, ChannelType, Guild } from "discord.js"; +import * as fs from "node:fs"; +import { DisclawDatabase } from "../db/database.js"; +import { DisclawConfig } from "../config/loader.js"; +import { ResolvedCategories, ensureCategories } from "./categories.js"; +import { childLogger } from "../runtime/logger.js"; + +const log = childLogger("lifecycle:channel-delete"); + +export interface ChannelDeleteContext { + db: DisclawDatabase; + config: DisclawConfig; + guild: Guild; + categories: ResolvedCategories; + managementChannelId: string; + setManagementChannelId: (id: string) => void; + setCategories: (cats: ResolvedCategories) => void; +} + +export async function handleChannelDelete( + channel: Channel, + ctx: ChannelDeleteContext +): Promise { + const channelId = channel.id; + + // 1. Was the management channel deleted? + if (channelId === ctx.managementChannelId) { + log.warn({ channelId }, "Management channel was deleted β€” recreating"); + try { + const newChannel = await ctx.guild.channels.create({ + name: ctx.config.management_channel, + type: ChannelType.GuildText, + topic: "DisClaw Management -- verwende /new-agent um Agenten zu erstellen", + }); + ctx.setManagementChannelId(newChannel.id); + // Update DB: remove old entry, add new one + ctx.db.deleteWorkspaceByChannelId(channelId); + ctx.db.createWorkspace(newChannel.id, ctx.guild.id, ctx.config.management_channel, "__management__"); + log.info({ newChannelId: newChannel.id }, "Management channel recreated"); + } catch (err) { + log.error({ err }, "Failed to recreate management channel"); + } + return; + } + + // 2. Was one of the lifecycle category channels deleted? + const isCategoryChannel = + channelId === ctx.categories.active.id || + channelId === ctx.categories.inactive.id || + channelId === ctx.categories.archive.id; + + if (isCategoryChannel) { + log.warn({ channelId }, "Lifecycle category channel was deleted β€” re-ensuring categories"); + try { + const newCats = await ensureCategories(ctx.guild); + ctx.setCategories(newCats); + log.info("Categories re-ensured"); + } catch (err) { + log.error({ err }, "Failed to re-ensure categories"); + } + return; + } + + // 3. Was an agent channel deleted? + const workspace = ctx.db.getWorkspaceByChannelId(channelId); + if (workspace) { + const workspacePath = workspace.workspace_path; + + // Delete workspace directory before removing from DB + if (workspacePath && workspacePath !== "__management__") { + try { + fs.rmSync(workspacePath, { recursive: true, force: true }); + log.info({ workspacePath }, "Agent workspace directory deleted"); + } catch (err) { + log.warn({ workspacePath, err }, "Failed to delete agent workspace directory"); + } + } + + // Remove from DB + ctx.db.deleteWorkspaceByChannelId(channelId); + log.info({ channelId, agentName: workspace.agent_name }, "Agent channel and workspace removed from DB"); + } +} diff --git a/src/lifecycle/reconcile.ts b/src/lifecycle/reconcile.ts new file mode 100644 index 0000000..c5f7333 --- /dev/null +++ b/src/lifecycle/reconcile.ts @@ -0,0 +1,158 @@ +import { Guild, ChannelType } from "discord.js"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { DisclawDatabase } from "../db/database.js"; +import { ResolvedCategories, moveToCategory } from "./categories.js"; +import { childLogger } from "../runtime/logger.js"; + +const log = childLogger("lifecycle:reconcile"); + +export interface ReconcileResult { + active: string[]; + inactive: string[]; + archived: string[]; + deletedWorkspaces: string[]; +} + +export async function reconcile( + guild: Guild, + db: DisclawDatabase, + categories: ResolvedCategories, + workspacesRoot: string, + managementChannelId: string +): Promise { + const result: ReconcileResult = { + active: [], + inactive: [], + archived: [], + deletedWorkspaces: [], + }; + + // 1. Ensure the full channel cache is populated + await guild.channels.fetch(); + + // 2. Get all DB workspaces, excluding the management placeholder + const allDbWorkspaces = db + .getAllWorkspaces() + .filter((w) => w.workspace_path !== "__management__"); + + // 3. Scan workspace directories for directories containing agent.yaml + const workspaceDirsWithYaml = new Set(); + const workspaceDirChannelMap = new Map(); // dirName -> channel_id + + if (fs.existsSync(workspacesRoot)) { + for (const entry of fs.readdirSync(workspacesRoot)) { + const dirPath = path.join(workspacesRoot, entry); + try { + const stat = fs.statSync(dirPath); + if (!stat.isDirectory()) continue; + } catch { + continue; + } + const yamlPath = path.join(dirPath, "agent.yaml"); + if (fs.existsSync(yamlPath)) { + workspaceDirsWithYaml.add(entry); + // Try to read channel_id from agent.yaml + try { + const content = fs.readFileSync(yamlPath, "utf-8"); + // Simple extraction without full YAML parse to avoid import issues in tests + const match = content.match(/channel_id:\s*["']?([^"'\s]+)["']?\s*$/m); + if (match) { + workspaceDirChannelMap.set(entry, match[1]); + } + } catch { + // ignore unreadable yaml + } + } + } + } + + // 4. Collect category channel IDs to skip during archive scan + const categoryChannelIds = new Set([ + categories.active.id, + categories.inactive.id, + categories.archive.id, + ]); + + // 5. Track handled channel IDs so we don't double-process + const handledChannelIds = new Set(); + handledChannelIds.add(managementChannelId); + categoryChannelIds.forEach((id) => handledChannelIds.add(id)); + + // 6. Process each DB workspace entry + for (const ws of allDbWorkspaces) { + const channelExists = guild.channels.cache.has(ws.channel_id); + const wsDir = Object.keys( + Object.fromEntries( + [...workspaceDirsWithYaml].map((d) => [d, path.join(workspacesRoot, d)]) + ) + ).find((d) => { + const resolvedPath = path.join(workspacesRoot, d); + return resolvedPath === ws.workspace_path || resolvedPath === path.resolve(ws.workspace_path); + }); + const workspaceDirExists = ws.workspace_path !== "__management__" && + fs.existsSync(ws.workspace_path); + + if (channelExists && workspaceDirExists) { + await moveToCategory(ws.channel_id, categories.active); + result.active.push(ws.channel_id); + log.info({ channelId: ws.channel_id, agentName: ws.agent_name }, "Reconcile: active"); + } else { + if (channelExists) { + await moveToCategory(ws.channel_id, categories.inactive); + result.inactive.push(ws.channel_id); + log.info({ channelId: ws.channel_id, agentName: ws.agent_name }, "Reconcile: inactive (workspace missing)"); + } else { + result.inactive.push(ws.channel_id); + log.info({ channelId: ws.channel_id, agentName: ws.agent_name }, "Reconcile: inactive (channel missing)"); + } + } + handledChannelIds.add(ws.channel_id); + void wsDir; // suppress unused warning + } + + // 7. Process workspace directories that have agent.yaml but no DB entry + const dbChannelIds = new Set(allDbWorkspaces.map((w) => w.channel_id)); + + for (const dirName of workspaceDirsWithYaml) { + const channelId = workspaceDirChannelMap.get(dirName); + if (!channelId) continue; + + if (dbChannelIds.has(channelId)) { + // Already handled in step 6 + continue; + } + + const dirPath = path.join(workspacesRoot, dirName); + const channelExists = guild.channels.cache.has(channelId); + + if (channelExists) { + await moveToCategory(channelId, categories.inactive); + result.inactive.push(channelId); + log.info({ channelId, dirName }, "Reconcile: inactive (workspace dir, no DB entry)"); + } else { + // No DB entry, no channel β†’ delete orphaned workspace directory + try { + fs.rmSync(dirPath, { recursive: true, force: true }); + result.deletedWorkspaces.push(dirName); + log.info({ dirName }, "Reconcile: deleted orphaned workspace dir"); + } catch (err) { + log.warn({ dirName, err }, "Reconcile: failed to delete orphaned workspace dir"); + } + } + + if (channelId) handledChannelIds.add(channelId); + } + + // 8. Archive any remaining text channels with no DB mapping + for (const [id, ch] of guild.channels.cache) { + if (handledChannelIds.has(id)) continue; + if (ch.type !== ChannelType.GuildText) continue; + + await moveToCategory(id, categories.archive); + result.archived.push(id); + log.info({ channelId: id }, "Reconcile: archived (no DB entry)"); + } + + return result; +} diff --git a/tests/unit/reconcile.test.ts b/tests/unit/reconcile.test.ts new file mode 100644 index 0000000..b398081 --- /dev/null +++ b/tests/unit/reconcile.test.ts @@ -0,0 +1,582 @@ +/** + * Unit tests for DIS-151: channel categorization + lifecycle management + * + * Covers reconcile(), handleChannelDelete(), ensureCategories() with + * mocked node:fs and Discord.js Guild objects. + * + * All path comparisons use path.join() so tests pass on Windows and Unix. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { MockInstance } from "vitest"; +import * as path from "node:path"; + +// --------------------------------------------------------------------------- +// Mock node:fs β€” must be hoisted before any imports that use it +// --------------------------------------------------------------------------- +vi.mock("node:fs", () => { + return { + existsSync: vi.fn(() => false), + readdirSync: vi.fn(() => [] as string[]), + statSync: vi.fn(() => ({ isDirectory: () => true })), + readFileSync: vi.fn(() => ""), + rmSync: vi.fn(), + mkdirSync: vi.fn(), + writeFileSync: vi.fn(), + }; +}); + +import * as nodefs from "node:fs"; + +// --------------------------------------------------------------------------- +// Helpers to build mock Discord structures +// --------------------------------------------------------------------------- + +/** A Collection-like Map that also has Discord.js Collection.find() */ +function makeCollection(entries: [string, V][] = []) { + const m = new Map(entries); + (m as unknown as { find: (fn: (v: V) => boolean) => V | undefined }).find = (fn: (v: V) => boolean) => { + for (const v of m.values()) { + if (fn(v)) return v; + } + return undefined; + }; + return m as Map & { find: (fn: (v: V) => boolean) => V | undefined }; +} + +type AnyChannelObj = { + id: string; + name: string; + type: number; + setParent: ReturnType; + guild?: unknown; +}; + +function makeCategoryChannel(id: string, name: string): AnyChannelObj { + return { + id, + name, + type: 4, // ChannelType.GuildCategory = 4 + setParent: vi.fn().mockResolvedValue(undefined), + }; +} + +function makeTextChannel(id: string, name: string): AnyChannelObj { + return { + id, + name, + type: 0, // ChannelType.GuildText = 0 + setParent: vi.fn().mockResolvedValue(undefined), + }; +} + +function makeGuild(channels: AnyChannelObj[]) { + const cache = makeCollection(channels.map((c) => [c.id, c] as [string, AnyChannelObj])); + + const guild: { + id: string; + name: string; + channels: { + cache: typeof cache; + fetch: ReturnType; + create: ReturnType; + }; + } = { + id: "guild-1", + name: "Test Guild", + channels: { + cache, + fetch: vi.fn().mockResolvedValue(cache), + create: vi.fn().mockImplementation(({ name, type }: { name: string; type: number }) => { + const newId = `created-${name}`; + let ch: AnyChannelObj; + if (type === 4) { + ch = makeCategoryChannel(newId, name); + } else { + ch = makeTextChannel(newId, name); + } + ch.guild = guild; + cache.set(newId, ch); + return Promise.resolve(ch); + }), + }, + }; + + // Attach guild reference to all channels so moveToCategory can access cache + for (const ch of channels) { + ch.guild = guild; + } + + return guild; +} + +type MockGuild = ReturnType; + +function makeDb(workspaces: Array<{ id: number; channel_id: string; agent_name: string; workspace_path: string; guild_id?: string; created_at?: string }>) { + const rows = workspaces.map((w) => ({ + id: w.id, + channel_id: w.channel_id, + guild_id: w.guild_id ?? "guild-1", + agent_name: w.agent_name, + workspace_path: w.workspace_path, + created_at: w.created_at ?? "2025-01-01T00:00:00Z", + })); + + return { + getAllWorkspaces: vi.fn(() => [...rows]), + getWorkspaceByChannelId: vi.fn((channelId: string) => + rows.find((r) => r.channel_id === channelId) + ), + getWorkspaceByName: vi.fn((name: string) => + rows.find((r) => r.agent_name === name) + ), + createWorkspace: vi.fn(), + deleteWorkspaceByChannelId: vi.fn((channelId: string) => { + const idx = rows.findIndex((r) => r.channel_id === channelId); + if (idx === -1) return false; + rows.splice(idx, 1); + return true; + }), + addConversation: vi.fn(), + getConversationHistory: vi.fn(() => []), + close: vi.fn(), + }; +} + +// --------------------------------------------------------------------------- +// Import modules under test (after mocks are registered) +// --------------------------------------------------------------------------- +import { reconcile } from "../../src/lifecycle/reconcile"; +import { handleChannelDelete } from "../../src/lifecycle/channel-delete-handler"; +import { ensureCategories } from "../../src/lifecycle/categories"; + +// Helpers for typed mock access +const fsMock = nodefs as unknown as { + existsSync: MockInstance; + readdirSync: MockInstance; + statSync: MockInstance; + readFileSync: MockInstance; + rmSync: MockInstance; +}; + +// --------------------------------------------------------------------------- +// Shared category setup β€” categories share guild's cache +// --------------------------------------------------------------------------- +function makeCategories(guild: MockGuild) { + const active = makeCategoryChannel("cat-active", "🟒 Aktive Agents"); + const inactive = makeCategoryChannel("cat-inactive", "πŸ”΄ Inaktive Agents"); + const archive = makeCategoryChannel("cat-archive", "πŸ—„οΈ Archiv"); + active.guild = guild; + inactive.guild = guild; + archive.guild = guild; + + // Add to guild cache so the category lookup in moveToCategory works + guild.channels.cache.set(active.id, active); + guild.channels.cache.set(inactive.id, inactive); + guild.channels.cache.set(archive.id, archive); + + return { active, inactive, archive } as unknown as Awaited>; +} + +const WORKSPACES_ROOT = path.resolve("/fake/workspaces"); +const MGMT_CHANNEL_ID = "mgmt-111"; + +beforeEach(() => { + vi.clearAllMocks(); + // Default: workspacesRoot does not exist + fsMock.existsSync.mockReturnValue(false); + fsMock.readdirSync.mockReturnValue([]); +}); + +// =========================================================================== +// reconcile() tests +// =========================================================================== + +describe("reconcile()", () => { + // ------------------------------------------------------------------------- + // Test 1: aktiv β€” DB entry + workspace dir + Discord channel all exist + // ------------------------------------------------------------------------- + it("1: aktiv: DB + Workspace + Channel β†’ moveToCategory(active) aufgerufen", async () => { + const agentCh = makeTextChannel("ch-agent-1", "my-agent"); + const guild = makeGuild([agentCh]); + const categories = makeCategories(guild); + + const wsPath = path.join(WORKSPACES_ROOT, "my-agent"); + const db = makeDb([ + { id: 1, channel_id: "ch-agent-1", agent_name: "my-agent", workspace_path: wsPath }, + ]); + + const yamlPath = path.join(WORKSPACES_ROOT, "my-agent", "agent.yaml"); + fsMock.existsSync.mockImplementation((p: string) => { + if (p === WORKSPACES_ROOT) return true; + if (p === wsPath) return true; + if (p === yamlPath) return true; + return false; + }); + fsMock.readdirSync.mockReturnValue(["my-agent"]); + fsMock.statSync.mockReturnValue({ isDirectory: () => true }); + fsMock.readFileSync.mockReturnValue('name: my-agent\nchannel_id: "ch-agent-1"\n'); + + const result = await reconcile( + guild as unknown as Parameters[0], + db as unknown as Parameters[1], + categories, + WORKSPACES_ROOT, + MGMT_CHANNEL_ID + ); + + expect(result.active).toContain("ch-agent-1"); + expect(agentCh.setParent).toHaveBeenCalledWith(categories.active.id); + }); + + // ------------------------------------------------------------------------- + // Test 2: inaktiv β€” DB entry exists but channel is NOT in guild + // ------------------------------------------------------------------------- + it("2: inaktiv: DB + kein Channel β†’ in result.inactive", async () => { + const guild = makeGuild([]); + const categories = makeCategories(guild); + + const wsPath = path.join(WORKSPACES_ROOT, "ghost-agent"); + const db = makeDb([ + { id: 1, channel_id: "ch-ghost", agent_name: "ghost-agent", workspace_path: wsPath }, + ]); + + fsMock.existsSync.mockReturnValue(false); + + const result = await reconcile( + guild as unknown as Parameters[0], + db as unknown as Parameters[1], + categories, + WORKSPACES_ROOT, + MGMT_CHANNEL_ID + ); + + expect(result.inactive).toContain("ch-ghost"); + expect(result.active).not.toContain("ch-ghost"); + }); + + // ------------------------------------------------------------------------- + // Test 3: inaktiv β€” workspace dir with agent.yaml exists but no DB entry + // and channel IS in guild + // ------------------------------------------------------------------------- + it("3: inaktiv: Workspace + kein DB β†’ moveToCategory(inactive) aufgerufen", async () => { + const agentCh = makeTextChannel("ch-orphan", "orphan-agent"); + const guild = makeGuild([agentCh]); + const categories = makeCategories(guild); + + const db = makeDb([]); + + const yamlPath = path.join(WORKSPACES_ROOT, "orphan-agent", "agent.yaml"); + fsMock.existsSync.mockImplementation((p: string) => { + if (p === WORKSPACES_ROOT) return true; + if (p === yamlPath) return true; + return false; + }); + fsMock.readdirSync.mockReturnValue(["orphan-agent"]); + fsMock.statSync.mockReturnValue({ isDirectory: () => true }); + fsMock.readFileSync.mockReturnValue('name: orphan-agent\nchannel_id: "ch-orphan"\n'); + + const result = await reconcile( + guild as unknown as Parameters[0], + db as unknown as Parameters[1], + categories, + WORKSPACES_ROOT, + MGMT_CHANNEL_ID + ); + + expect(result.inactive).toContain("ch-orphan"); + expect(agentCh.setParent).toHaveBeenCalledWith(categories.inactive.id); + }); + + // ------------------------------------------------------------------------- + // Test 4: archiv β€” text channel in guild with no DB entry and no workspace + // ------------------------------------------------------------------------- + it("4: archiv: Channel ohne DB und Workspace β†’ moveToCategory(archive) aufgerufen", async () => { + const randomCh = makeTextChannel("ch-random", "random-channel"); + const guild = makeGuild([randomCh]); + const categories = makeCategories(guild); + + const db = makeDb([]); + + fsMock.existsSync.mockReturnValue(false); + fsMock.readdirSync.mockReturnValue([]); + + const result = await reconcile( + guild as unknown as Parameters[0], + db as unknown as Parameters[1], + categories, + WORKSPACES_ROOT, + MGMT_CHANNEL_ID + ); + + expect(result.archived).toContain("ch-random"); + expect(randomCh.setParent).toHaveBeenCalledWith(categories.archive.id); + }); + + // ------------------------------------------------------------------------- + // Test 5: cleanup β€” workspace dir exists but no channel and no DB entry + // β†’ directory is deleted + // ------------------------------------------------------------------------- + it("5: cleanup: Workspace ohne Channel und DB β†’ fs.rmSync aufgerufen", async () => { + const guild = makeGuild([]); + const categories = makeCategories(guild); + + const db = makeDb([]); + + const deadDir = path.join(WORKSPACES_ROOT, "dead-agent"); + const deadYaml = path.join(WORKSPACES_ROOT, "dead-agent", "agent.yaml"); + fsMock.existsSync.mockImplementation((p: string) => { + if (p === WORKSPACES_ROOT) return true; + if (p === deadYaml) return true; + return false; + }); + fsMock.readdirSync.mockReturnValue(["dead-agent"]); + fsMock.statSync.mockReturnValue({ isDirectory: () => true }); + fsMock.readFileSync.mockReturnValue('name: dead-agent\nchannel_id: "ch-dead"\n'); + + const result = await reconcile( + guild as unknown as Parameters[0], + db as unknown as Parameters[1], + categories, + WORKSPACES_ROOT, + MGMT_CHANNEL_ID + ); + + expect(fsMock.rmSync).toHaveBeenCalledWith( + deadDir, + expect.objectContaining({ recursive: true }) + ); + expect(result.deletedWorkspaces).toContain("dead-agent"); + }); + + // ------------------------------------------------------------------------- + // Test 6: management channel is never categorized + // ------------------------------------------------------------------------- + it("6: management-channel ΓΌbersprungen β€” wird nicht kategorisiert", async () => { + const mgmtCh = makeTextChannel(MGMT_CHANNEL_ID, "disclaw"); + const guild = makeGuild([mgmtCh]); + const categories = makeCategories(guild); + + const db = makeDb([]); + fsMock.existsSync.mockReturnValue(false); + + const result = await reconcile( + guild as unknown as Parameters[0], + db as unknown as Parameters[1], + categories, + WORKSPACES_ROOT, + MGMT_CHANNEL_ID + ); + + expect(result.archived).not.toContain(MGMT_CHANNEL_ID); + expect(result.active).not.toContain(MGMT_CHANNEL_ID); + expect(result.inactive).not.toContain(MGMT_CHANNEL_ID); + expect(mgmtCh.setParent).not.toHaveBeenCalled(); + }); + + // ------------------------------------------------------------------------- + // Test 7: category channels themselves are never archived + // ------------------------------------------------------------------------- + it("7: kategorie-channels ΓΌbersprungen β€” nicht als archiv eingestuft", async () => { + const guild = makeGuild([]); + const categories = makeCategories(guild); + // categories are already in guild.channels.cache via makeCategories() + + const db = makeDb([]); + fsMock.existsSync.mockReturnValue(false); + + const result = await reconcile( + guild as unknown as Parameters[0], + db as unknown as Parameters[1], + categories, + WORKSPACES_ROOT, + MGMT_CHANNEL_ID + ); + + expect(result.archived).not.toContain(categories.active.id); + expect(result.archived).not.toContain(categories.inactive.id); + expect(result.archived).not.toContain(categories.archive.id); + }); + + // ------------------------------------------------------------------------- + // Test 8: empty workspacesRoot β€” no error, empty result + // ------------------------------------------------------------------------- + it("8: leerer workspaces_root β€” kein Fehler, leeres Result", async () => { + const guild = makeGuild([]); + const categories = makeCategories(guild); + const db = makeDb([]); + + fsMock.existsSync.mockReturnValue(false); + + const result = await reconcile( + guild as unknown as Parameters[0], + db as unknown as Parameters[1], + categories, + WORKSPACES_ROOT, + MGMT_CHANNEL_ID + ); + + expect(result.active).toHaveLength(0); + expect(result.inactive).toHaveLength(0); + expect(result.archived).toHaveLength(0); + expect(result.deletedWorkspaces).toHaveLength(0); + }); +}); + +// =========================================================================== +// handleChannelDelete() tests +// =========================================================================== + +describe("handleChannelDelete()", () => { + function makeConfig() { + return { + management_channel: "disclaw", + workspaces_root: WORKSPACES_ROOT, + claude_command: "claude", + claude_timeout_seconds: 120, + max_concurrent_agents: 4, + env_blocklist: [] as string[], + }; + } + + // ------------------------------------------------------------------------- + // Test 9: Agent channel deleted β†’ DB + workspace removed + // ------------------------------------------------------------------------- + it("9: channelDelete: Agent-Channel β†’ DB + Workspace gelΓΆscht", async () => { + const agentCh = makeTextChannel("ch-del-agent", "my-agent"); + const guild = makeGuild([agentCh]); + const categories = makeCategories(guild); + const wsPath = path.join(WORKSPACES_ROOT, "my-agent"); + + const db = makeDb([ + { id: 1, channel_id: "ch-del-agent", agent_name: "my-agent", workspace_path: wsPath }, + ]); + + fsMock.existsSync.mockReturnValue(true); + + await handleChannelDelete(agentCh as unknown as Parameters[0], { + db: db as unknown as Parameters[1]["db"], + config: makeConfig() as unknown as Parameters[1]["config"], + guild: guild as unknown as Parameters[1]["guild"], + categories, + managementChannelId: MGMT_CHANNEL_ID, + setManagementChannelId: vi.fn(), + setCategories: vi.fn(), + }); + + expect(db.deleteWorkspaceByChannelId).toHaveBeenCalledWith("ch-del-agent"); + expect(fsMock.rmSync).toHaveBeenCalledWith( + wsPath, + expect.objectContaining({ recursive: true }) + ); + }); + + // ------------------------------------------------------------------------- + // Test 10: Management channel deleted β†’ new channel created, state updated + // ------------------------------------------------------------------------- + it("10: channelDelete: Management-Channel β†’ neuer Channel erstellt", async () => { + const mgmtCh = makeTextChannel(MGMT_CHANNEL_ID, "disclaw"); + const guild = makeGuild([]); + const categories = makeCategories(guild); + + const db = makeDb([ + { id: 99, channel_id: MGMT_CHANNEL_ID, agent_name: "disclaw", workspace_path: "__management__" }, + ]); + + const setManagementChannelId = vi.fn(); + + await handleChannelDelete(mgmtCh as unknown as Parameters[0], { + db: db as unknown as Parameters[1]["db"], + config: makeConfig() as unknown as Parameters[1]["config"], + guild: guild as unknown as Parameters[1]["guild"], + categories, + managementChannelId: MGMT_CHANNEL_ID, + setManagementChannelId, + setCategories: vi.fn(), + }); + + expect(guild.channels.create).toHaveBeenCalledWith( + expect.objectContaining({ name: "disclaw" }) + ); + expect(setManagementChannelId).toHaveBeenCalled(); + expect(db.createWorkspace).toHaveBeenCalled(); + }); + + // ------------------------------------------------------------------------- + // Test 11: Lifecycle category channel deleted β†’ ensureCategories called again + // ------------------------------------------------------------------------- + it("11: channelDelete: Kategorie-Channel β†’ ensureCategories erneut aufgerufen", async () => { + const guild = makeGuild([]); + const categories = makeCategories(guild); + + // Simulate deletion of the active category channel + const deletedCategoryChannel = makeTextChannel(categories.active.id, "🟒 Aktive Agents"); + + // Add replacement category channels so ensureCategories finds them + const newActive = makeCategoryChannel("cat-active-v2", "🟒 Aktive Agents"); + const newInactive = makeCategoryChannel("cat-inactive", "πŸ”΄ Inaktive Agents"); + const newArchive = makeCategoryChannel("cat-archive", "πŸ—„οΈ Archiv"); + newActive.guild = guild; + newInactive.guild = guild; + newArchive.guild = guild; + guild.channels.cache.set("cat-active-v2", newActive); + guild.channels.cache.set("cat-inactive", newInactive); + guild.channels.cache.set("cat-archive", newArchive); + + const db = makeDb([]); + const setCategories = vi.fn(); + + await handleChannelDelete(deletedCategoryChannel as unknown as Parameters[0], { + db: db as unknown as Parameters[1]["db"], + config: makeConfig() as unknown as Parameters[1]["config"], + guild: guild as unknown as Parameters[1]["guild"], + categories, + managementChannelId: MGMT_CHANNEL_ID, + setManagementChannelId: vi.fn(), + setCategories, + }); + + expect(setCategories).toHaveBeenCalled(); + }); +}); + +// =========================================================================== +// ensureCategories() tests +// =========================================================================== + +describe("ensureCategories()", () => { + // ------------------------------------------------------------------------- + // Test 12: All three categories missing β†’ all three created + // ------------------------------------------------------------------------- + it("12: ensureCategories: alle fehlen β†’ 3 Kategorien erstellt", async () => { + const guild = makeGuild([]); + + const result = await ensureCategories(guild as unknown as Parameters[0]); + + expect(guild.channels.create).toHaveBeenCalledTimes(3); + expect(result.active).toBeDefined(); + expect(result.inactive).toBeDefined(); + expect(result.archive).toBeDefined(); + }); + + // ------------------------------------------------------------------------- + // Test 13: Some categories present β†’ only missing ones created + // ------------------------------------------------------------------------- + it("13: ensureCategories: teilweise vorhanden β†’ nur fehlende erstellt", async () => { + const existingActive = makeCategoryChannel("existing-active", "🟒 Aktive Agents"); + const existingArchive = makeCategoryChannel("existing-archive", "πŸ—„οΈ Archiv"); + const guild = makeGuild([existingActive, existingArchive]); + + const result = await ensureCategories(guild as unknown as Parameters[0]); + + // Only the inactive category should be created + expect(guild.channels.create).toHaveBeenCalledTimes(1); + expect(guild.channels.create).toHaveBeenCalledWith( + expect.objectContaining({ name: "πŸ”΄ Inaktive Agents" }) + ); + + expect(result.active.id).toBe("existing-active"); + expect(result.archive.id).toBe("existing-archive"); + expect(result.inactive).toBeDefined(); + }); +});