feat(DIS-151): channel categorization + lifecycle management
Some checks failed
CI / build-and-test (ubuntu-latest) (pull_request) Has been cancelled
CI / build-and-test (windows-latest) (pull_request) Has been cancelled
CI / lint (pull_request) Has been cancelled

- 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 <noreply@anthropic.com>
This commit is contained in:
Nick Tabeling 2026-04-10 14:43:32 +02:00
parent 3ea356d29d
commit 56b861f34c
7 changed files with 936 additions and 8 deletions

View file

@ -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 ---

View file

@ -46,7 +46,8 @@ export const newAgentCommand = new SlashCommandBuilder()
export async function handleNewAgent(
interaction: ChatInputCommandInteraction,
db: DisclawDatabase,
config: DisclawConfig
config: DisclawConfig,
activeCategoryId?: string
): Promise<void> {
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

View file

@ -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(

View file

@ -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<ResolvedCategories> {
const resolved: Partial<ResolvedCategories> = {};
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<void> {
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)");
}
}

View file

@ -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<void> {
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");
}
}

158
src/lifecycle/reconcile.ts Normal file
View file

@ -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<ReconcileResult> {
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<string>();
const workspaceDirChannelMap = new Map<string, string>(); // 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<string>();
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;
}

View file

@ -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<V>(entries: [string, V][] = []) {
const m = new Map<string, V>(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<string, V> & { find: (fn: (v: V) => boolean) => V | undefined };
}
type AnyChannelObj = {
id: string;
name: string;
type: number;
setParent: ReturnType<typeof vi.fn>;
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<typeof vi.fn>;
create: ReturnType<typeof vi.fn>;
};
} = {
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<typeof makeGuild>;
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<ReturnType<typeof ensureCategories>>;
}
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<typeof reconcile>[0],
db as unknown as Parameters<typeof reconcile>[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<typeof reconcile>[0],
db as unknown as Parameters<typeof reconcile>[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<typeof reconcile>[0],
db as unknown as Parameters<typeof reconcile>[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<typeof reconcile>[0],
db as unknown as Parameters<typeof reconcile>[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<typeof reconcile>[0],
db as unknown as Parameters<typeof reconcile>[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<typeof reconcile>[0],
db as unknown as Parameters<typeof reconcile>[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<typeof reconcile>[0],
db as unknown as Parameters<typeof reconcile>[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<typeof reconcile>[0],
db as unknown as Parameters<typeof reconcile>[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<typeof handleChannelDelete>[0], {
db: db as unknown as Parameters<typeof handleChannelDelete>[1]["db"],
config: makeConfig() as unknown as Parameters<typeof handleChannelDelete>[1]["config"],
guild: guild as unknown as Parameters<typeof handleChannelDelete>[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<typeof handleChannelDelete>[0], {
db: db as unknown as Parameters<typeof handleChannelDelete>[1]["db"],
config: makeConfig() as unknown as Parameters<typeof handleChannelDelete>[1]["config"],
guild: guild as unknown as Parameters<typeof handleChannelDelete>[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<typeof handleChannelDelete>[0], {
db: db as unknown as Parameters<typeof handleChannelDelete>[1]["db"],
config: makeConfig() as unknown as Parameters<typeof handleChannelDelete>[1]["config"],
guild: guild as unknown as Parameters<typeof handleChannelDelete>[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<typeof ensureCategories>[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<typeof ensureCategories>[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();
});
});