import { Guild, ChannelType } from "discord.js"; import * as fs from "node:fs"; import * as path from "node:path"; import { DisclawDatabase } from "../db/database.js"; import { ResolvedCategories, moveToCategory } from "./categories.js"; import { childLogger } from "../runtime/logger.js"; const log = childLogger("lifecycle:reconcile"); export interface ReconcileResult { active: string[]; inactive: string[]; archived: string[]; deletedWorkspaces: string[]; } export async function reconcile( guild: Guild, db: DisclawDatabase, categories: ResolvedCategories, workspacesRoot: string, managementChannelId: string ): Promise { const result: ReconcileResult = { active: [], inactive: [], archived: [], deletedWorkspaces: [], }; // 1. Ensure the full channel cache is populated await guild.channels.fetch(); // 2. Get all DB workspaces, excluding the management placeholder const allDbWorkspaces = db .getAllWorkspaces() .filter((w) => w.workspace_path !== "__management__"); // 3. Scan workspace directories for directories containing agent.yaml const workspaceDirsWithYaml = new Set(); const workspaceDirChannelMap = new Map(); // dirName -> channel_id if (fs.existsSync(workspacesRoot)) { for (const entry of fs.readdirSync(workspacesRoot)) { const dirPath = path.join(workspacesRoot, entry); try { const stat = fs.statSync(dirPath); if (!stat.isDirectory()) continue; } catch { continue; } const yamlPath = path.join(dirPath, "agent.yaml"); if (fs.existsSync(yamlPath)) { workspaceDirsWithYaml.add(entry); // Try to read channel_id from agent.yaml try { const content = fs.readFileSync(yamlPath, "utf-8"); // Simple extraction without full YAML parse to avoid import issues in tests const match = content.match(/channel_id:\s*["']?([^"'\s]+)["']?\s*$/m); if (match) { workspaceDirChannelMap.set(entry, match[1]); } } catch { // ignore unreadable yaml } } } } // 4. Collect category channel IDs to skip during archive scan const categoryChannelIds = new Set([ categories.active.id, categories.inactive.id, categories.archive.id, ]); // 5. Track handled channel IDs so we don't double-process const handledChannelIds = new Set(); handledChannelIds.add(managementChannelId); categoryChannelIds.forEach((id) => handledChannelIds.add(id)); // 6. Process each DB workspace entry for (const ws of allDbWorkspaces) { const channelExists = guild.channels.cache.has(ws.channel_id); const wsDir = Object.keys( Object.fromEntries( [...workspaceDirsWithYaml].map((d) => [d, path.join(workspacesRoot, d)]) ) ).find((d) => { const resolvedPath = path.join(workspacesRoot, d); return resolvedPath === ws.workspace_path || resolvedPath === path.resolve(ws.workspace_path); }); const workspaceDirExists = ws.workspace_path !== "__management__" && fs.existsSync(ws.workspace_path); if (channelExists && workspaceDirExists) { await moveToCategory(ws.channel_id, categories.active); result.active.push(ws.channel_id); log.info({ channelId: ws.channel_id, agentName: ws.agent_name }, "Reconcile: active"); } else { if (channelExists) { await moveToCategory(ws.channel_id, categories.inactive); result.inactive.push(ws.channel_id); log.info({ channelId: ws.channel_id, agentName: ws.agent_name }, "Reconcile: inactive (workspace missing)"); } else { result.inactive.push(ws.channel_id); log.info({ channelId: ws.channel_id, agentName: ws.agent_name }, "Reconcile: inactive (channel missing)"); } } handledChannelIds.add(ws.channel_id); void wsDir; // suppress unused warning } // 7. Process workspace directories that have agent.yaml but no DB entry const dbChannelIds = new Set(allDbWorkspaces.map((w) => w.channel_id)); for (const dirName of workspaceDirsWithYaml) { const channelId = workspaceDirChannelMap.get(dirName); if (!channelId) continue; if (dbChannelIds.has(channelId)) { // Already handled in step 6 continue; } const dirPath = path.join(workspacesRoot, dirName); const channelExists = guild.channels.cache.has(channelId); if (channelExists) { await moveToCategory(channelId, categories.inactive); result.inactive.push(channelId); log.info({ channelId, dirName }, "Reconcile: inactive (workspace dir, no DB entry)"); } else { // No DB entry, no channel → delete orphaned workspace directory try { fs.rmSync(dirPath, { recursive: true, force: true }); result.deletedWorkspaces.push(dirName); log.info({ dirName }, "Reconcile: deleted orphaned workspace dir"); } catch (err) { log.warn({ dirName, err }, "Reconcile: failed to delete orphaned workspace dir"); } } if (channelId) handledChannelIds.add(channelId); } // 8. Archive any remaining text channels with no DB mapping for (const [id, ch] of guild.channels.cache) { if (handledChannelIds.has(id)) continue; if (ch.type !== ChannelType.GuildText) continue; await moveToCategory(id, categories.archive); result.archived.push(id); log.info({ channelId: id }, "Reconcile: archived (no DB entry)"); } return result; }