import fs from "node:fs"; import path from "node:path"; import { ARCHITECTURE_DOCUMENT, FLEET_CONFIG, OPENCLAW_AGENTS_DIR, OPENCLAW_CONFIG_PATH, ZEROCLAW_CONTROL_DIR, ZEROCLAW_PRIMARY_DIR, } from "@/lib/fleet-config"; import { all } from "@/lib/db"; import { normalizeTask } from "@/lib/tasks"; import type { AgentStatus, FleetAgent, TaskEvent, TaskRecord } from "@/lib/types"; type OpenClawAgentConfig = { id: string; name?: string; model?: { primary?: string }; identity?: { name?: string; emoji?: string; theme?: string }; subagents?: { allowAgents?: string[] }; }; type OpenClawConfigShape = { agents?: { list?: OpenClawAgentConfig[] }; channels?: { telegram?: { groups?: Record< string, { topics?: Record; } >; }; }; }; function readTextFile(filePath: string) { if (!fs.existsSync(filePath)) { return ""; } return fs.readFileSync(filePath, "utf8"); } function parseBulletValues(content: string) { return content .split("\n") .map((line) => line.trim()) .filter((line) => line.startsWith("- ")) .map((line) => line.replace(/^- /, "").replace(/`/g, "").trim()); } function parseRoleFromAgentsMd(content: string) { const identityMatch = content.match(/- Scope:\s*(.+)/); if (identityMatch) { return identityMatch[1].trim(); } return "Host-scoped agent"; } function deriveHeartbeatTimestamp(heartbeatPath: string, heartbeatMd: string) { const timestampMatch = heartbeatMd.match( /(Last Heartbeat|Updated|Timestamp):\s*([0-9TZ:.\-+ ]+)/i, ); if (timestampMatch) { const parsed = new Date(timestampMatch[2].trim()); if (!Number.isNaN(parsed.getTime())) { return parsed.toISOString(); } } if (!fs.existsSync(heartbeatPath)) { return null; } return fs.statSync(heartbeatPath).mtime.toISOString(); } function parseResponsibilities(content: string) { const sectionMatch = content.match(/## Responsibilities([\s\S]*?)(##|$)/); return sectionMatch ? parseBulletValues(sectionMatch[1]) : []; } function readWorkspaceAgent(agentRoot: string, fallbackName: string) { const workspaceRoot = path.join(agentRoot, "workspace"); const agentsMd = readTextFile(path.join(workspaceRoot, "AGENTS.md")); const toolsMd = readTextFile(path.join(workspaceRoot, "TOOLS.md")); const identityMd = readTextFile(path.join(workspaceRoot, "IDENTITY.md")); const heartbeatPath = path.join(workspaceRoot, "HEARTBEAT.md"); const heartbeatMd = readTextFile(heartbeatPath); const tools = parseBulletValues(toolsMd); const capabilities = parseResponsibilities(agentsMd); const currentTaskMatch = heartbeatMd.match(/Current Task:\s*(.+)/i); const heartbeatAt = deriveHeartbeatTimestamp(heartbeatPath, heartbeatMd); return { files: ["AGENTS.md", "TOOLS.md", "IDENTITY.md", "HEARTBEAT.md"].filter((fileName) => fs.existsSync(path.join(workspaceRoot, fileName)), ), tools, capabilities, currentTask: currentTaskMatch ? currentTaskMatch[1].trim() : null, heartbeatAt, role: parseRoleFromAgentsMd(agentsMd), noteValues: parseBulletValues(identityMd), workspaceRoot, }; } function readOpenClawConfig(): OpenClawConfigShape { try { return JSON.parse(fs.readFileSync(OPENCLAW_CONFIG_PATH, "utf8")) as OpenClawConfigShape; } catch { return {}; } } function getOpenClawChannels(agentId: string, config: OpenClawConfigShape) { const summaries: { label: string; value: string }[] = [ { label: "Family", value: "OpenClaw telegram + gateway" }, ]; const topicGroups = config.channels?.telegram?.groups?.["-1003809447066"]?.topics; if (!topicGroups) { return summaries; } const topicEntries = Object.entries(topicGroups).filter(([, topic]) => { const prompt = topic.systemPrompt || ""; return prompt.toLowerCase().includes(agentId.toLowerCase()) || agentId === "main"; }); if (topicEntries.length === 0 && agentId === "main") { summaries.push({ label: "Forum", value: "Homelab HQ default route" }); return summaries; } topicEntries.forEach(([topicId]) => { summaries.push({ label: "Topic", value: `Homelab HQ topic ${topicId}` }); }); return summaries; } async function fetchTaskBuckets(aliases: string[]) { const placeholders = aliases.map(() => "?").join(", "); const activeRows = await all & { tags: string }>( `SELECT * FROM tasks WHERE assignee IN (${placeholders}) AND status IN ('Todo', 'In Progress', 'Review') ORDER BY priority DESC, created_at ASC`, aliases, ); const completedRows = await all & { tags: string }>( `SELECT * FROM tasks WHERE assignee IN (${placeholders}) AND status = 'Done' ORDER BY completed_at DESC LIMIT 5`, aliases, ); return { activeTasks: activeRows.map(normalizeTask), completedTasks: completedRows.map(normalizeTask), }; } async function fetchAgentEventSummary(aliases: string[]) { const placeholders = aliases.map(() => "?").join(", "); const latestEvent = await all( `SELECT * FROM task_events WHERE assignee IN (${placeholders}) ORDER BY created_at DESC LIMIT 1`, aliases, ); const failureRows = await all<{ count: number }>( `SELECT COUNT(*) as count FROM task_events WHERE assignee IN (${placeholders}) AND event_type = 'dispatch_failed'`, aliases, ); return { lastEvent: latestEvent[0] || null, failureStreak: failureRows[0]?.count || 0, }; } function deriveHeartbeatAgeMinutes(heartbeatAt: string | null) { if (!heartbeatAt) { return null; } const diffMs = Date.now() - new Date(heartbeatAt).getTime(); return Math.max(0, Math.round(diffMs / 60000)); } function deriveStatus(activeTaskCount: number, heartbeatAt: string | null): AgentStatus { if (activeTaskCount > 0) { return "busy"; } const heartbeatAge = deriveHeartbeatAgeMinutes(heartbeatAt); if (heartbeatAge !== null && heartbeatAge > 180) { return "idle"; } return "active"; } async function buildOpenClawAgents() { const config = readOpenClawConfig(); const agents = config.agents?.list || []; const concreteAgents = await Promise.all( agents.map(async (agentConfig) => { const agentRoot = path.join(OPENCLAW_AGENTS_DIR, agentConfig.id); const workspace = readWorkspaceAgent(agentRoot, agentConfig.identity?.name || agentConfig.id); const aliases = [ agentConfig.id, agentConfig.identity?.name || agentConfig.name || agentConfig.id, ]; const taskBuckets = await fetchTaskBuckets(aliases); const eventSummary = await fetchAgentEventSummary(aliases); const heartbeatAgeMinutes = deriveHeartbeatAgeMinutes(workspace.heartbeatAt); return { slug: agentConfig.id, assignmentKey: agentConfig.id, aliases, family: "openclaw", name: agentConfig.identity?.name || agentConfig.name || agentConfig.id, host: "ubuntu", role: agentConfig.identity?.theme || workspace.role, runtimePath: workspace.workspaceRoot || OPENCLAW_AGENTS_DIR, configPath: OPENCLAW_CONFIG_PATH, defaultDispatchMethod: "openclaw-swarm", model: agentConfig.model?.primary || null, emoji: agentConfig.identity?.emoji || "🦞", channels: getOpenClawChannels(agentConfig.id, config), tools: workspace.tools, capabilities: workspace.capabilities, files: workspace.files, status: deriveStatus(taskBuckets.activeTasks.length, workspace.heartbeatAt), workload: taskBuckets.activeTasks.length, activeTasks: taskBuckets.activeTasks, completedTasks: taskBuckets.completedTasks, currentTask: workspace.currentTask, heartbeatAt: workspace.heartbeatAt, heartbeatAgeMinutes, lastEvent: eventSummary.lastEvent, failureStreak: eventSummary.failureStreak, notes: workspace.noteValues, } satisfies FleetAgent; }), ); const swarmAliases = ["openclaw", "openclaw-swarm", "codex", "opencode", "gemini"]; const swarmTaskBuckets = await fetchTaskBuckets(swarmAliases); const swarmEventSummary = await fetchAgentEventSummary(swarmAliases); const runtimeWorkspace = path.join(OPENCLAW_RUNTIME_ROOT, "workspace"); const heartbeatAt = fs.existsSync(runtimeWorkspace) ? fs.statSync(runtimeWorkspace).mtime.toISOString() : swarmEventSummary.lastEvent?.created_at || null; const swarmAgent = { slug: "openclaw-swarm", assignmentKey: "openclaw", aliases: swarmAliases, family: "openclaw" as const, name: "OpenClaw Swarm", host: "ubuntu", role: "Swarm execution queue and runner aliases for ubuntu-local OpenClaw work.", runtimePath: OPENCLAW_RUNTIME_ROOT, configPath: OPENCLAW_CONFIG_PATH, defaultDispatchMethod: "openclaw-swarm" as const, model: null, emoji: "O", channels: [ { label: "Family", value: "OpenClaw swarm queue" }, { label: "Queue", value: "OpenClaw swarm registry" }, ], tools: ["git worktree", "tmux", "taskboard callbacks", "swarm registry"], capabilities: [ "Queue and launch swarm tasks backed by git worktrees.", "Map runner aliases like codex, opencode, and gemini into the shared swarm executor.", "Report acknowledgement, completion, and failure back to the taskboard.", ], files: [], status: deriveStatus(swarmTaskBuckets.activeTasks.length, heartbeatAt), workload: swarmTaskBuckets.activeTasks.length, activeTasks: swarmTaskBuckets.activeTasks, completedTasks: swarmTaskBuckets.completedTasks, currentTask: swarmTaskBuckets.activeTasks[0]?.title || null, heartbeatAt, heartbeatAgeMinutes: deriveHeartbeatAgeMinutes(heartbeatAt), lastEvent: swarmEventSummary.lastEvent, failureStreak: swarmEventSummary.failureStreak, notes: [ "Synthetic fleet agent representing the OpenClaw swarm dispatcher.", "Covers runner aliases used by Telegram and task templates.", ], } satisfies FleetAgent; return [...concreteAgents, swarmAgent]; } async function buildZeroClawAgents() { const configuredAgents = FLEET_CONFIG.zeroclawAgents.map((agent) => ({ ...agent, runtimePath: agent.slug === "grizzley-zeroclaw" ? ZEROCLAW_PRIMARY_DIR : agent.slug === "ice-zeroclaw" ? ZEROCLAW_CONTROL_DIR : agent.runtimePath, })); return Promise.all( configuredAgents.map(async (configuredAgent) => { const workspace = readWorkspaceAgent(configuredAgent.runtimePath, configuredAgent.name); const taskBuckets = await fetchTaskBuckets(configuredAgent.aliases); const eventSummary = await fetchAgentEventSummary(configuredAgent.aliases); const heartbeatAgeMinutes = deriveHeartbeatAgeMinutes(workspace.heartbeatAt); return { ...configuredAgent, family: "zeroclaw" as const, defaultDispatchMethod: configuredAgent.dispatch.method, tools: workspace.tools, capabilities: workspace.capabilities, files: workspace.files, status: deriveStatus(taskBuckets.activeTasks.length, workspace.heartbeatAt), workload: taskBuckets.activeTasks.length, activeTasks: taskBuckets.activeTasks, completedTasks: taskBuckets.completedTasks, currentTask: workspace.currentTask, heartbeatAt: workspace.heartbeatAt, heartbeatAgeMinutes, lastEvent: eventSummary.lastEvent, failureStreak: eventSummary.failureStreak, notes: [...configuredAgent.notes, ...workspace.noteValues], }; }), ); } async function buildDirectAgents() { return Promise.all( FLEET_CONFIG.directAgents.map(async (configuredAgent) => { const taskBuckets = await fetchTaskBuckets(configuredAgent.aliases); const eventSummary = await fetchAgentEventSummary(configuredAgent.aliases); const heartbeatAt = eventSummary.lastEvent?.created_at || null; const heartbeatAgeMinutes = deriveHeartbeatAgeMinutes(heartbeatAt); return { slug: configuredAgent.slug, assignmentKey: configuredAgent.assignmentKey, aliases: configuredAgent.aliases, family: "direct" as const, name: configuredAgent.name, host: configuredAgent.host, role: configuredAgent.role, runtimePath: configuredAgent.runtimePath, configPath: configuredAgent.configPath, defaultDispatchMethod: configuredAgent.dispatch.method, model: null, emoji: configuredAgent.emoji, channels: configuredAgent.channels, tools: configuredAgent.tools, capabilities: configuredAgent.capabilities, files: configuredAgent.files, status: deriveStatus(taskBuckets.activeTasks.length, heartbeatAt), workload: taskBuckets.activeTasks.length, activeTasks: taskBuckets.activeTasks, completedTasks: taskBuckets.completedTasks, currentTask: taskBuckets.activeTasks[0]?.title || null, heartbeatAt, heartbeatAgeMinutes, lastEvent: eventSummary.lastEvent, failureStreak: eventSummary.failureStreak, notes: configuredAgent.notes, } satisfies FleetAgent; }), ); } export async function listFleetAgents() { const [openclawAgents, zeroclawAgents, directAgents] = await Promise.all([ buildOpenClawAgents(), buildZeroClawAgents(), buildDirectAgents(), ]); return [...openclawAgents, ...zeroclawAgents, ...directAgents]; } export async function findAgentByAssignmentKey(assignmentKey: string) { const agents = await listFleetAgents(); return agents.find((agent) => agent.assignmentKey === assignmentKey || agent.aliases.includes(assignmentKey)) || null; } export async function findAgentBySlug(slug: string) { const agents = await listFleetAgents(); return agents.find((agent) => agent.slug === slug) || null; } export async function listArchitecture() { const agents = await listFleetAgents(); return { ...ARCHITECTURE_DOCUMENT, generatedAt: new Date().toISOString(), sections: ARCHITECTURE_DOCUMENT.sections.map((section) => ({ ...section, configuredAgents: agents .filter((agent) => agent.family === section.id) .map((agent) => `${agent.name} (${agent.host})`), })), }; }