- 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>
158 lines
5.4 KiB
TypeScript
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;
|
|
}
|