disclaw/src/lifecycle/reconcile.ts
Nick Tabeling 56b861f34c
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
feat(DIS-151): channel categorization + lifecycle management
- Add deleteWorkspaceByChannelId() to DisclawDatabase
- Add src/lifecycle/categories.ts: ensureCategories() + moveToCategory()
- Add src/lifecycle/reconcile.ts: reconcile() assigns channels to active/inactive/archive categories on startup
- Add src/lifecycle/channel-delete-handler.ts: handles channelDelete events for management, category, and agent channels
- Wire lifecycle into bot.ts: state object, ensureCategories, reconcile, channelDelete listener
- Extend handleNewAgent() with optional activeCategoryId to place new channels in the active category
- Add 13 unit tests covering all lifecycle scenarios

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 14:43:32 +02:00

158 lines
5.4 KiB
TypeScript

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;
}