/** * Backlog Aggregator Server * * Real-time multi-project task aggregator that: * - Scans directories for backlog projects * - Watches task files for changes * - Provides unified WebSocket-based real-time updates * - Serves aggregated task data with project metadata */ import { type Server, type ServerWebSocket, $ } from "bun"; import { watch, type FSWatcher } from "node:fs"; import { readdir, stat, readFile, writeFile, mkdir, unlink } from "node:fs/promises"; import { join, basename, dirname } from "node:path"; import { parseTask } from "../markdown/parser.ts"; import type { Task } from "../types/index.ts"; import { sortByTaskId } from "../utils/task-sorting.ts"; import { fileURLToPath } from "node:url"; // @ts-expect-error - Bun file import import favicon from "../web/favicon.png" with { type: "file" }; // Get the directory of this file const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); interface ProjectConfig { path: string; name: string; color: string; } interface AggregatedTask extends Task { projectName: string; projectColor: string; projectPath: string; } interface AggregatorConfig { scanPaths: string[]; port: number; colors: string[]; } const DEFAULT_COLORS = [ "#8b5cf6", // purple - canvas-website "#22c55e", // green - hyperindex "#3b82f6", // blue - mycofi "#f97316", // orange - decolonize-time "#ef4444", // red - ai-orchestrator "#ec4899", // pink "#14b8a6", // teal "#eab308", // yellow "#6366f1", // indigo "#84cc16", // lime ]; export class BacklogAggregator { private server: Server | null = null; private sockets = new Set>(); private projects = new Map(); private tasks = new Map(); private watchers = new Map(); private colorIndex = 0; private config: AggregatorConfig; private debounceTimers = new Map(); constructor(config: Partial = {}) { this.config = { scanPaths: config.scanPaths || ["/opt/websites", "/opt/apps"], port: config.port || 6420, colors: config.colors || DEFAULT_COLORS, }; } private getNextColor(): string { const color = this.config.colors[this.colorIndex % this.config.colors.length]; this.colorIndex++; return color; } async start(): Promise { console.log("Starting Backlog Aggregator..."); // Initial scan for projects await this.scanForProjects(); // Start the HTTP/WebSocket server this.server = Bun.serve({ port: this.config.port, development: process.env.NODE_ENV === "development", routes: { // NOTE: "/" route is handled in fetch() to allow WebSocket upgrade // IMPORTANT: Static routes must come BEFORE parameterized routes to avoid `:project` matching literal paths "/api/projects": { GET: async () => this.handleGetProjects(), }, "/api/tasks": { GET: async (req: Request) => this.handleGetTasks(req), }, "/api/tasks/update": { PATCH: async (req: Request) => this.handleUpdateTask(req), }, "/api/tasks/create": { POST: async (req: Request) => this.handleCreateTask(req), }, "/api/tasks/archive": { POST: async (req: Request) => this.handleArchiveTask(req), }, "/api/tasks/delete": { DELETE: async (req: Request) => this.handleDeleteTask(req), }, "/api/tasks/:project/:id": { GET: async (req: Request & { params: { project: string; id: string } }) => this.handleGetTask(req.params.project, req.params.id), }, "/api/statuses": { GET: async () => this.handleGetStatuses(), }, "/api/health": { GET: async () => Response.json({ status: "ok", projects: this.projects.size, tasks: this.tasks.size }), }, }, fetch: async (req: Request, server: Server) => { const url = new URL(req.url); // Handle WebSocket upgrade if (req.headers.get("upgrade") === "websocket") { const success = server.upgrade(req, { data: undefined }); if (success) { return new Response(null, { status: 101 }); } return new Response("WebSocket upgrade failed", { status: 400 }); } // Serve favicon if (url.pathname.startsWith("/favicon")) { const faviconFile = Bun.file(favicon); return new Response(faviconFile, { headers: { "Content-Type": "image/png" }, }); } // Serve the HTML app for root path if (url.pathname === "/" || url.pathname === "") { const htmlPath = join(__dirname, "web", "index.html"); const htmlFile = Bun.file(htmlPath); return new Response(htmlFile, { headers: { "Content-Type": "text/html" } }); } return new Response("Not Found", { status: 404 }); }, websocket: { open: (ws: ServerWebSocket) => { this.sockets.add(ws); // Send initial state ws.send( JSON.stringify({ type: "init", projects: Array.from(this.projects.values()), tasks: Array.from(this.tasks.values()), }), ); }, message: (ws: ServerWebSocket, message: string | Buffer) => { const data = typeof message === "string" ? message : message.toString(); if (data === "ping") { ws.send("pong"); } }, close: (ws: ServerWebSocket) => { this.sockets.delete(ws); }, }, }); const url = `http://localhost:${this.config.port}`; console.log(`Backlog Aggregator running at ${url}`); console.log(`Watching ${this.projects.size} projects with ${this.tasks.size} tasks`); // Set up periodic rescan for new projects setInterval(() => this.scanForProjects(), 60000); // Every minute // Set up periodic task polling as fallback for Docker environments // where inotify may not work reliably across bind mounts setInterval(() => this.pollAllTasks(), 5000); // Every 5 seconds } private async pollAllTasks(): Promise { let hasChanges = false; const previousTaskCount = this.tasks.size; for (const [projectPath] of this.projects) { const project = this.projects.get(projectPath); if (!project) continue; const tasksPath = join(projectPath, "backlog", "tasks"); try { const entries = await readdir(tasksPath, { withFileTypes: true }); for (const entry of entries) { if (!entry.isFile() || !entry.name.endsWith(".md")) continue; const taskPath = join(tasksPath, entry.name); try { const content = await readFile(taskPath, "utf-8"); const task = parseTask(content, taskPath); if (task) { const key = `${projectPath}:${task.id}`; const existing = this.tasks.get(key); // Check if task is new or changed if (!existing || existing.rawContent !== task.rawContent || existing.status !== task.status) { const aggregatedTask: AggregatedTask = { ...task, projectName: project.name, projectColor: project.color, projectPath: projectPath, }; this.tasks.set(key, aggregatedTask); hasChanges = true; } } } catch { // File read error } } } catch { // Directory read error } } if (hasChanges || this.tasks.size !== previousTaskCount) { this.broadcastUpdate(); } } async stop(): Promise { // Stop all watchers for (const [path, watcher] of this.watchers) { watcher.close(); } this.watchers.clear(); // Clear debounce timers for (const timer of this.debounceTimers.values()) { clearTimeout(timer); } this.debounceTimers.clear(); // Stop server if (this.server) { await this.server.stop(); this.server = null; } console.log("Aggregator stopped"); } private async scanForProjects(): Promise { const foundProjects = new Set(); for (const scanPath of this.config.scanPaths) { try { const entries = await readdir(scanPath, { withFileTypes: true }); for (const entry of entries) { if (!entry.isDirectory()) continue; const projectPath = join(scanPath, entry.name); const backlogPath = join(projectPath, "backlog"); try { const backlogStat = await stat(backlogPath); if (backlogStat.isDirectory()) { foundProjects.add(projectPath); await this.addProject(projectPath); } } catch { // No backlog directory } } } catch (error) { console.warn(`Could not scan ${scanPath}:`, error); } } // Remove projects that no longer exist for (const [path] of this.projects) { if (!foundProjects.has(path)) { this.removeProject(path); } } } private async addProject(projectPath: string): Promise { if (this.projects.has(projectPath)) return; // Try to load project name from config let projectName = basename(projectPath); const configPath = join(projectPath, "backlog", "config.yml"); try { const configContent = await readFile(configPath, "utf-8"); const nameMatch = configContent.match(/project_name:\s*["']?([^"'\n]+)["']?/); if (nameMatch) { projectName = nameMatch[1].trim(); } } catch { // Use directory name } const project: ProjectConfig = { path: projectPath, name: projectName, color: this.getNextColor(), }; this.projects.set(projectPath, project); console.log(`Added project: ${projectName} (${projectPath})`); // Load initial tasks await this.loadProjectTasks(projectPath); // Set up file watcher this.watchProject(projectPath); } private removeProject(projectPath: string): void { const project = this.projects.get(projectPath); if (!project) return; // Remove tasks for (const [key] of this.tasks) { if (key.startsWith(projectPath + ":")) { this.tasks.delete(key); } } // Stop watcher const watcher = this.watchers.get(projectPath); if (watcher) { watcher.close(); this.watchers.delete(projectPath); } this.projects.delete(projectPath); console.log(`Removed project: ${project.name}`); this.broadcastUpdate(); } private async loadProjectTasks(projectPath: string): Promise { const project = this.projects.get(projectPath); if (!project) return; const tasksPath = join(projectPath, "backlog", "tasks"); try { const entries = await readdir(tasksPath, { withFileTypes: true }); for (const entry of entries) { if (!entry.isFile() || !entry.name.endsWith(".md")) continue; const taskPath = join(tasksPath, entry.name); await this.loadTask(projectPath, taskPath); } } catch (error) { console.warn(`Could not load tasks from ${tasksPath}:`, error); } } private async loadTask(projectPath: string, taskPath: string): Promise { const project = this.projects.get(projectPath); if (!project) return; try { const content = await readFile(taskPath, "utf-8"); const task = parseTask(content, taskPath); if (task) { const aggregatedTask: AggregatedTask = { ...task, projectName: project.name, projectColor: project.color, projectPath: projectPath, }; const key = `${projectPath}:${task.id}`; this.tasks.set(key, aggregatedTask); } } catch (error) { console.warn(`Could not load task ${taskPath}:`, error); } } private watchProject(projectPath: string): void { const tasksPath = join(projectPath, "backlog", "tasks"); try { const watcher = watch(tasksPath, { recursive: true }, (eventType, filename) => { if (!filename || !filename.endsWith(".md")) return; // Debounce rapid changes const key = `${projectPath}:${filename}`; const existing = this.debounceTimers.get(key); if (existing) { clearTimeout(existing); } this.debounceTimers.set( key, setTimeout(async () => { this.debounceTimers.delete(key); const taskPath = join(tasksPath, filename); try { await stat(taskPath); await this.loadTask(projectPath, taskPath); console.log(`Task updated: ${filename} in ${this.projects.get(projectPath)?.name}`); } catch { // File deleted - remove from tasks const taskId = basename(filename, ".md").replace(/^task-/, "task-"); const taskKey = `${projectPath}:task-${taskId.replace(/^task-/, "")}`; // Try various key formats for (const [k] of this.tasks) { if (k.startsWith(`${projectPath}:`) && k.includes(basename(filename, ".md"))) { this.tasks.delete(k); break; } } console.log(`Task removed: ${filename} from ${this.projects.get(projectPath)?.name}`); } this.broadcastUpdate(); }, 100), ); }); watcher.on("error", (error) => { console.warn(`Watcher error for ${projectPath}:`, error); }); this.watchers.set(projectPath, watcher); } catch (error) { console.warn(`Could not watch ${tasksPath}:`, error); } } private broadcastUpdate(): void { const message = JSON.stringify({ type: "update", projects: Array.from(this.projects.values()), tasks: Array.from(this.tasks.values()), timestamp: Date.now(), }); for (const ws of this.sockets) { try { ws.send(message); } catch { // Socket closed } } } // HTTP Handlers private handleGetProjects(): Response { return Response.json(Array.from(this.projects.values())); } private handleGetTasks(req: Request): Response { const url = new URL(req.url); const project = url.searchParams.get("project"); const status = url.searchParams.get("status"); const priority = url.searchParams.get("priority"); let tasks = Array.from(this.tasks.values()); if (project) { tasks = tasks.filter((t) => t.projectName === project || t.projectPath.includes(project)); } if (status) { tasks = tasks.filter((t) => t.status.toLowerCase() === status.toLowerCase()); } if (priority) { tasks = tasks.filter((t) => (t.priority ?? "").toLowerCase() === priority.toLowerCase()); } // Sort by project then by task ID tasks = tasks.sort((a, b) => { const projectCompare = a.projectName.localeCompare(b.projectName); if (projectCompare !== 0) return projectCompare; return sortByTaskId([a, b])[0] === a ? -1 : 1; }); return Response.json(tasks); } private handleGetTask(project: string, id: string): Response { for (const [key, task] of this.tasks) { if ((task.projectName === project || task.projectPath.includes(project)) && task.id === id) { return Response.json(task); } } return Response.json({ error: "Task not found" }, { status: 404 }); } private handleGetStatuses(): Response { // Collect unique statuses from all projects const statuses = new Set(); for (const task of this.tasks.values()) { statuses.add(task.status); } // Return common defaults plus any found const common = ["To Do", "In Progress", "Done"]; for (const s of common) statuses.add(s); return Response.json(Array.from(statuses)); } // Task modification handlers private async handleUpdateTask(req: Request): Promise { try { const body = await req.json(); const { projectPath, taskId, status, title, description, priority, labels, assignee } = body as { projectPath: string; taskId: string; status?: string; title?: string; description?: string; priority?: string; labels?: string[]; assignee?: string[]; }; if (!projectPath || !taskId) { return Response.json({ error: "Missing required fields: projectPath, taskId" }, { status: 400 }); } // Find the task const taskKey = `${projectPath}:${taskId}`; const task = this.tasks.get(taskKey); if (!task) { return Response.json({ error: "Task not found" }, { status: 404 }); } // Use the stored file path from when the task was loaded const taskFilePath = task.filePath; if (!taskFilePath) { return Response.json({ error: "Task file path not found" }, { status: 404 }); } // Read the current task file let content: string; try { content = await readFile(taskFilePath, "utf-8"); } catch { return Response.json({ error: "Task file not found" }, { status: 404 }); } // Update the fields in the frontmatter let updatedContent = content; if (status !== undefined) { updatedContent = this.updateTaskField(updatedContent, "status", status); } if (title !== undefined) { updatedContent = this.updateTaskField(updatedContent, "title", title); } if (priority !== undefined) { updatedContent = this.updateTaskField(updatedContent, "priority", priority); } if (labels !== undefined) { updatedContent = this.updateTaskField(updatedContent, "labels", `[${labels.join(", ")}]`); } if (assignee !== undefined) { updatedContent = this.updateTaskField(updatedContent, "assignee", `[${assignee.join(", ")}]`); } if (description !== undefined) { updatedContent = this.updateTaskDescription(updatedContent, description); } // Add updated_date updatedContent = this.addUpdatedDate(updatedContent); // Write the updated content back await writeFile(taskFilePath, updatedContent, "utf-8"); const updatedFields = []; if (status !== undefined) updatedFields.push(`status="${status}"`); if (title !== undefined) updatedFields.push(`title="${title}"`); if (priority !== undefined) updatedFields.push(`priority="${priority}"`); if (labels !== undefined) updatedFields.push(`labels=[${labels.join(", ")}]`); if (assignee !== undefined) updatedFields.push(`assignee=[${assignee.join(", ")}]`); if (description !== undefined) updatedFields.push("description updated"); console.log(`Task ${taskId} updated (${updatedFields.join(", ")}) in ${this.projects.get(projectPath)?.name}`); // The file watcher will pick up the change and broadcast update // But we can also force an immediate update await this.loadTask(projectPath, taskFilePath); this.broadcastUpdate(); return Response.json({ success: true, taskId, updated: updatedFields }); } catch (error) { console.error("Error updating task:", error); return Response.json({ error: "Failed to update task" }, { status: 500 }); } } private async handleCreateTask(req: Request): Promise { try { const body = await req.json(); const { projectPath, title, description, priority, status, labels } = body as { projectPath: string; title: string; description?: string; priority?: string; status?: string; labels?: string[]; }; if (!projectPath || !title) { return Response.json({ error: "Missing required fields: projectPath, title" }, { status: 400 }); } // Verify project exists if (!this.projects.has(projectPath)) { return Response.json({ error: "Project not found" }, { status: 404 }); } // Generate next task ID const taskId = await this.getNextTaskId(projectPath); // Generate the task markdown content const now = new Date(); const dateStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")} ${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`; let frontmatter = `--- id: ${taskId} title: ${title} status: ${status || "To Do"} assignee: [] created_date: '${dateStr}' `; if (labels && labels.length > 0) { frontmatter += `labels: [${labels.join(", ")}]\n`; } else { frontmatter += `labels: []\n`; } if (priority) { frontmatter += `priority: ${priority}\n`; } frontmatter += `dependencies: []\n---\n\n`; let content = frontmatter; content += `## Description\n\n${description || "No description provided."}\n`; // Ensure tasks directory exists const tasksDir = join(projectPath, "backlog", "tasks"); try { await mkdir(tasksDir, { recursive: true }); } catch { // Directory might already exist } // Write the task file const taskFilePath = join(tasksDir, `${taskId}.md`); await writeFile(taskFilePath, content, "utf-8"); console.log(`Created task ${taskId} in ${this.projects.get(projectPath)?.name}`); // Load the new task and broadcast await this.loadTask(projectPath, taskFilePath); this.broadcastUpdate(); return Response.json({ success: true, taskId, projectPath }); } catch (error) { console.error("Error creating task:", error); return Response.json({ error: "Failed to create task" }, { status: 500 }); } } private async handleArchiveTask(req: Request): Promise { try { const body = await req.json(); const { projectPath, taskId } = body as { projectPath: string; taskId: string; }; if (!projectPath || !taskId) { return Response.json({ error: "Missing required fields: projectPath, taskId" }, { status: 400 }); } // Find the task const taskKey = `${projectPath}:${taskId}`; const task = this.tasks.get(taskKey); if (!task) { return Response.json({ error: "Task not found" }, { status: 404 }); } // Use the stored file path from when the task was loaded const taskFilePath = task.filePath; if (!taskFilePath) { return Response.json({ error: "Task file path not found" }, { status: 404 }); } // Build archive path preserving the original filename const archiveDir = join(projectPath, "backlog", "archive", "tasks"); const archiveFilePath = join(archiveDir, basename(taskFilePath)); // Ensure archive directory exists try { await mkdir(archiveDir, { recursive: true }); } catch { // Directory might already exist } // Read the task file let content: string; try { content = await readFile(taskFilePath, "utf-8"); } catch { return Response.json({ error: "Task file not found" }, { status: 404 }); } // Move to archive (write to archive, delete from tasks) await writeFile(archiveFilePath, content, "utf-8"); await unlink(taskFilePath); // Remove from in-memory tasks this.tasks.delete(taskKey); console.log(`Task ${taskId} archived in ${this.projects.get(projectPath)?.name}`); this.broadcastUpdate(); return Response.json({ success: true, taskId, archived: true }); } catch (error) { console.error("Error archiving task:", error); return Response.json({ error: "Failed to archive task" }, { status: 500 }); } } private async handleDeleteTask(req: Request): Promise { try { const body = await req.json(); const { projectPath, taskId } = body as { projectPath: string; taskId: string; }; if (!projectPath || !taskId) { return Response.json({ error: "Missing required fields: projectPath, taskId" }, { status: 400 }); } // Find the task const taskKey = `${projectPath}:${taskId}`; const task = this.tasks.get(taskKey); if (!task) { return Response.json({ error: "Task not found" }, { status: 404 }); } // Use the stored file path from when the task was loaded const taskFilePath = task.filePath; if (!taskFilePath) { return Response.json({ error: "Task file path not found" }, { status: 404 }); } // Delete the file try { await unlink(taskFilePath); } catch { return Response.json({ error: "Task file not found" }, { status: 404 }); } // Remove from in-memory tasks this.tasks.delete(taskKey); console.log(`Task ${taskId} deleted from ${this.projects.get(projectPath)?.name}`); this.broadcastUpdate(); return Response.json({ success: true, taskId, deleted: true }); } catch (error) { console.error("Error deleting task:", error); return Response.json({ error: "Failed to delete task" }, { status: 500 }); } } private updateTaskStatus(content: string, newStatus: string): string { return this.updateTaskField(content, "status", newStatus); } private updateTaskField(content: string, field: string, value: string): string { // Parse frontmatter and update the field const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/); if (!frontmatterMatch || !frontmatterMatch[1]) { return content; } const frontmatter = frontmatterMatch[1]; const fieldRegex = new RegExp(`^${field}:\\s*.+$`, "m"); let updatedFrontmatter: string; if (fieldRegex.test(frontmatter)) { // Update existing field updatedFrontmatter = frontmatter.replace(fieldRegex, `${field}: ${value}`); } else { // Add new field updatedFrontmatter = frontmatter + `\n${field}: ${value}`; } return content.replace(/^---\n[\s\S]*?\n---/, `---\n${updatedFrontmatter}\n---`); } private updateTaskDescription(content: string, newDescription: string): string { // Find and replace the description section const descriptionRegex = /## Description\n\n[\s\S]*?(?=\n## |\n---|\Z)/; const newDescriptionSection = `## Description\n\n${newDescription}\n`; if (descriptionRegex.test(content)) { return content.replace(descriptionRegex, newDescriptionSection); } // If no description section, add one after frontmatter return content.replace(/^(---\n[\s\S]*?\n---\n\n?)/, `$1${newDescriptionSection}\n`); } private addUpdatedDate(content: string): string { const now = new Date(); const dateStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")} ${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`; const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/); if (!frontmatterMatch || !frontmatterMatch[1]) { return content; } const frontmatter = frontmatterMatch[1]; let updatedFrontmatter: string; if (frontmatter.includes("updated_date:")) { updatedFrontmatter = frontmatter.replace(/^updated_date:\s*.+$/m, `updated_date: '${dateStr}'`); } else { updatedFrontmatter = frontmatter + `\nupdated_date: '${dateStr}'`; } return content.replace(/^---\n[\s\S]*?\n---/, `---\n${updatedFrontmatter}\n---`); } private async getNextTaskId(projectPath: string): Promise { // Find the highest task ID in the project const tasksPath = join(projectPath, "backlog", "tasks"); let maxId = 0; try { const entries = await readdir(tasksPath, { withFileTypes: true }); for (const entry of entries) { if (!entry.isFile() || !entry.name.endsWith(".md")) continue; const match = entry.name.match(/^task-(\d+)\.md$/); if (match) { const id = Number.parseInt(match[1], 10); if (id > maxId) maxId = id; } } } catch { // Directory might not exist yet } // Return next ID with zero-padding return `task-${String(maxId + 1).padStart(3, "0")}`; } } // CLI entry point if (import.meta.main) { const args = process.argv.slice(2); const portIndex = args.indexOf("--port"); const port = portIndex !== -1 ? Number.parseInt(args[portIndex + 1], 10) : 6420; const pathsIndex = args.indexOf("--paths"); const paths = pathsIndex !== -1 ? args[pathsIndex + 1].split(",") : ["/opt/websites", "/opt/apps"]; const aggregator = new BacklogAggregator({ port, scanPaths: paths }); process.on("SIGINT", async () => { await aggregator.stop(); process.exit(0); }); process.on("SIGTERM", async () => { await aggregator.stop(); process.exit(0); }); aggregator.start().catch((error) => { console.error("Failed to start aggregator:", error); process.exit(1); }); }