backlog-md/src/aggregator/index.ts

897 lines
27 KiB
TypeScript

/**
* 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<ServerWebSocket<unknown>>();
private projects = new Map<string, ProjectConfig>();
private tasks = new Map<string, AggregatedTask>();
private watchers = new Map<string, FSWatcher>();
private colorIndex = 0;
private config: AggregatorConfig;
private debounceTimers = new Map<string, Timer>();
constructor(config: Partial<AggregatorConfig> = {}) {
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<void> {
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<void> {
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<void> {
// 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<void> {
const foundProjects = new Set<string>();
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<void> {
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<void> {
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<void> {
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<string>();
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<Response> {
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<Response> {
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<Response> {
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<Response> {
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<string> {
// 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);
});
}