897 lines
27 KiB
TypeScript
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);
|
|
});
|
|
}
|