feat(DIS-151): channel categorization + lifecycle management #60
8 changed files with 1092 additions and 27 deletions
44
src/bot.ts
44
src/bot.ts
|
|
@ -12,6 +12,9 @@ import { DisclawConfig, EnvConfig } from "./config/loader";
|
||||||
import { newAgentCommand, handleNewAgent } from "./commands/new-agent";
|
import { newAgentCommand, handleNewAgent } from "./commands/new-agent";
|
||||||
import { routeMessage } from "./router";
|
import { routeMessage } from "./router";
|
||||||
import { childLogger } from "./runtime/logger";
|
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");
|
const log = childLogger("bot");
|
||||||
|
|
||||||
|
|
@ -28,8 +31,11 @@ export async function startBot(
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
// Track the management channel id once resolved
|
// Shared mutable state for lifecycle management
|
||||||
let managementChannelId: string | null = null;
|
const state = {
|
||||||
|
managementChannelId: null as string | null,
|
||||||
|
categories: null as ResolvedCategories | null,
|
||||||
|
};
|
||||||
|
|
||||||
// --- Event: Ready ---
|
// --- Event: Ready ---
|
||||||
client.once("ready", async () => {
|
client.once("ready", async () => {
|
||||||
|
|
@ -57,7 +63,7 @@ export async function startBot(
|
||||||
existingWorkspace.channel_id
|
existingWorkspace.channel_id
|
||||||
);
|
);
|
||||||
if (existingChannel) {
|
if (existingChannel) {
|
||||||
managementChannelId = existingWorkspace.channel_id;
|
state.managementChannelId = existingWorkspace.channel_id;
|
||||||
log.info({ channelName: managementName }, "Management channel found");
|
log.info({ channelName: managementName }, "Management channel found");
|
||||||
} else {
|
} else {
|
||||||
// Channel was deleted from Discord, recreate it
|
// Channel was deleted from Discord, recreate it
|
||||||
|
|
@ -81,7 +87,7 @@ export async function startBot(
|
||||||
managementName,
|
managementName,
|
||||||
"__management__"
|
"__management__"
|
||||||
);
|
);
|
||||||
managementChannelId = found.id;
|
state.managementChannelId = found.id;
|
||||||
mgmtChannel = found;
|
mgmtChannel = found;
|
||||||
log.info({ channelName: managementName }, "Adopted existing channel");
|
log.info({ channelName: managementName }, "Adopted existing channel");
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -96,7 +102,7 @@ export async function startBot(
|
||||||
managementName,
|
managementName,
|
||||||
"__management__"
|
"__management__"
|
||||||
);
|
);
|
||||||
managementChannelId = newChannel.id;
|
state.managementChannelId = newChannel.id;
|
||||||
mgmtChannel = newChannel;
|
mgmtChannel = newChannel;
|
||||||
log.info({ channelName: managementName }, "Created management channel");
|
log.info({ channelName: managementName }, "Created management channel");
|
||||||
}
|
}
|
||||||
|
|
@ -109,6 +115,30 @@ export async function startBot(
|
||||||
.catch(() => {});
|
.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 ---
|
// --- Register slash commands ---
|
||||||
const rest = new REST({ version: "10" }).setToken(
|
const rest = new REST({ version: "10" }).setToken(
|
||||||
envConfig.DISCORD_BOT_TOKEN
|
envConfig.DISCORD_BOT_TOKEN
|
||||||
|
|
@ -132,7 +162,7 @@ export async function startBot(
|
||||||
if (!interaction.isChatInputCommand()) return;
|
if (!interaction.isChatInputCommand()) return;
|
||||||
|
|
||||||
if (interaction.commandName === "new-agent") {
|
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
|
// Only handle text channels in guilds
|
||||||
if (!message.guild || message.channel.type !== ChannelType.GuildText) return;
|
if (!message.guild || message.channel.type !== ChannelType.GuildText) return;
|
||||||
|
|
||||||
await routeMessage(message, db, disclawConfig, managementChannelId);
|
await routeMessage(message, db, disclawConfig, state.managementChannelId);
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Login ---
|
// --- Login ---
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,8 @@ export const newAgentCommand = new SlashCommandBuilder()
|
||||||
export async function handleNewAgent(
|
export async function handleNewAgent(
|
||||||
interaction: ChatInputCommandInteraction,
|
interaction: ChatInputCommandInteraction,
|
||||||
db: DisclawDatabase,
|
db: DisclawDatabase,
|
||||||
config: DisclawConfig
|
config: DisclawConfig,
|
||||||
|
activeCategoryId?: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const name = interaction.options.getString("name", true).toLowerCase();
|
const name = interaction.options.getString("name", true).toLowerCase();
|
||||||
const role = interaction.options.getString("role", true);
|
const role = interaction.options.getString("role", true);
|
||||||
|
|
@ -89,6 +90,7 @@ export async function handleNewAgent(
|
||||||
name,
|
name,
|
||||||
type: ChannelType.GuildText,
|
type: ChannelType.GuildText,
|
||||||
topic: `Agent: ${name} | Rolle: ${role}`,
|
topic: `Agent: ${name} | Rolle: ${role}`,
|
||||||
|
parent: activeCategoryId ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2. Create workspace with full Claude Code environment
|
// 2. Create workspace with full Claude Code environment
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,14 @@ export class DisclawDatabase {
|
||||||
.get(result.lastInsertRowid) as Workspace;
|
.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 ---
|
// --- Conversation queries ---
|
||||||
|
|
||||||
addConversation(
|
addConversation(
|
||||||
|
|
|
||||||
65
src/lifecycle/categories.ts
Normal file
65
src/lifecycle/categories.ts
Normal 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)");
|
||||||
|
}
|
||||||
|
}
|
||||||
83
src/lifecycle/channel-delete-handler.ts
Normal file
83
src/lifecycle/channel-delete-handler.ts
Normal 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
158
src/lifecycle/reconcile.ts
Normal 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;
|
||||||
|
}
|
||||||
175
tasks/DIS-151.md
175
tasks/DIS-151.md
|
|
@ -1,12 +1,12 @@
|
||||||
---
|
---
|
||||||
id: DIS-151
|
id: DIS-151
|
||||||
status: ready
|
status: in-progress
|
||||||
phase: 1.5
|
phase: 1.5
|
||||||
priority: p1
|
priority: p1
|
||||||
labels: [phase:1.5, type:feat, priority:p1]
|
labels: [phase:1.5, type:feat, priority:p1]
|
||||||
branch: refinement/channel-categories
|
branch: refinement/channel-categories
|
||||||
assignee: null
|
assignee: developer-agent
|
||||||
started: null
|
started: 2026-04-10
|
||||||
pr: null
|
pr: null
|
||||||
merged: null
|
merged: null
|
||||||
---
|
---
|
||||||
|
|
@ -32,38 +32,175 @@ oder Agents die aus der DB entfernt wurden aber noch einen Workspace haben.
|
||||||
### Startup-Reconciliation (`src/lifecycle/reconcile.ts`)
|
### Startup-Reconciliation (`src/lifecycle/reconcile.ts`)
|
||||||
Beim Start einmalig ausführen, nach DB-Init und Bot-Login:
|
Beim Start einmalig ausführen, nach DB-Init und Bot-Login:
|
||||||
|
|
||||||
1. **Alle Channels des Servers scannen** — welche sind in welcher Kategorie?
|
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
|
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`
|
3. **Workspace-Verzeichnisse scannen** — alle Verzeichnisse unter `workspaces_root` mit `agent.yaml`
|
||||||
4. **Einordnung**:
|
4. **Einordnung**:
|
||||||
- DB-Eintrag + Workspace vorhanden + Channel existiert → **aktiv** (Kategorie + Channel-Name)
|
- DB-Eintrag + Workspace vorhanden + Channel existiert → **aktiv**
|
||||||
- Workspace vorhanden, aber kein DB-Eintrag → **inaktiv** (in Kategorie verschieben)
|
- Workspace vorhanden, aber kein DB-Eintrag / kein Channel → **inaktiv**
|
||||||
- Weder DB noch Workspace, aber Channel existiert → **archiv** (Channel in Archiv-Kategorie verschieben)
|
- Weder DB noch Workspace, aber Channel existiert → **archiv**
|
||||||
- Workspace existiert, kein Channel in aktiv/inaktiv/archiv → Workspace-Verzeichnis löschen (Cleanup)
|
- Workspace existiert, kein Channel in aktiv/inaktiv/archiv → Workspace-Verzeichnis löschen
|
||||||
|
|
||||||
### Live-Event: `channelDelete`
|
### Live-Event: `channelDelete`
|
||||||
- Wenn ein Agent-Channel gelöscht wird: Workspace-Verzeichnis (`~/.disclaw/workspaces/<name>`) ebenfalls löschen, DB-Eintrag entfernen
|
- 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)
|
- Wenn Management-Channel gelöscht wird: automatisch neu anlegen (KI-002.1)
|
||||||
- Kategorie-Channels (aktiv/inaktiv/archiv) werden bei Löschung neu angelegt
|
- Kategorie-Channels (aktiv/inaktiv/archiv) werden bei Löschung neu angelegt
|
||||||
|
|
||||||
### `src/bot.ts` / `src/index.ts`
|
|
||||||
- `reconcileChannels(guild, db, config)` nach Bot-Ready aufrufen
|
|
||||||
- `channelDelete`-Event-Handler registrieren
|
|
||||||
|
|
||||||
## Definition of Done
|
## Definition of Done
|
||||||
- [ ] Kategorien werden beim Start automatisch angelegt (falls nicht vorhanden)
|
- [ ] Kategorien werden beim Start automatisch angelegt (falls nicht vorhanden)
|
||||||
- [ ] Channels werden korrekt eingeordnet (aktiv/inaktiv/archiv)
|
- [ ] Channels werden korrekt eingeordnet (aktiv/inaktiv/archiv)
|
||||||
- [ ] Workspace-Verzeichnisse ohne Channel werden beim Start gelöscht
|
- [ ] Workspace-Verzeichnisse ohne Channel werden beim Start gelöscht
|
||||||
- [ ] `channelDelete`-Event: Workspace + DB-Eintrag werden entfernt
|
- [ ] `channelDelete`-Event: Workspace + DB-Eintrag werden entfernt
|
||||||
- [ ] Management-Channel-Löschung: wird neu angelegt
|
- [ ] Management-Channel-Löschung: wird neu angelegt
|
||||||
- [ ] Unit-Test `tests/unit/reconcile.test.ts`: Mock-Guild, Mock-DB — alle Reconcile-Cases
|
- [ ] Unit-Test `tests/unit/reconcile.test.ts`: alle Reconcile-Cases grün
|
||||||
- [ ] `npm run build && npm test` grün
|
- [ ] `npm run build && npm test` grün
|
||||||
|
|
||||||
## Dateien (erwartet)
|
---
|
||||||
- `src/lifecycle/reconcile.ts`
|
|
||||||
- `src/bot.ts`
|
## Implementierungsplan (Senior Developer Review)
|
||||||
- `src/index.ts`
|
|
||||||
- `tests/unit/reconcile.test.ts`
|
### 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<ResolvedCategories>
|
||||||
|
- moveToCategory(channelId: string, category: CategoryChannel): Promise<void>
|
||||||
|
|
||||||
|
SCHRITT 3: src/lifecycle/reconcile.ts (NEU)
|
||||||
|
- ReconcileResult-Interface exportieren
|
||||||
|
- reconcile(guild, db, categories, workspacesRoot, managementChannelId): Promise<ReconcileResult>
|
||||||
|
- 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<void>
|
||||||
|
- 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
|
## Abhängigkeiten
|
||||||
Phase 0 + Phase 1 abgeschlossen.
|
Phase 0 + Phase 1 abgeschlossen.
|
||||||
|
|
|
||||||
582
tests/unit/reconcile.test.ts
Normal file
582
tests/unit/reconcile.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue