backlog-mcp 0.31.0 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,27 @@
1
+ //#region src/events/event-bus.d.ts
2
+ /**
3
+ * EventBus interface and event types for real-time viewer updates.
4
+ *
5
+ * The interface is designed to be pluggable: start with in-process
6
+ * EventEmitter (LocalEventBus), swap to Redis Pub/Sub or NATS
7
+ * for cloud deployment without changing consumers.
8
+ */
9
+ type BacklogEventType = 'task_changed' | 'task_created' | 'task_deleted' | 'resource_changed';
10
+ interface BacklogEvent {
11
+ seq: number;
12
+ type: BacklogEventType;
13
+ id: string;
14
+ tool: string;
15
+ actor: string;
16
+ ts: string;
17
+ }
18
+ type BacklogEventCallback = (event: BacklogEvent) => void;
19
+ interface EventBus {
20
+ emit(event: Omit<BacklogEvent, 'seq'>): void;
21
+ subscribe(callback: BacklogEventCallback): void;
22
+ unsubscribe(callback: BacklogEventCallback): void;
23
+ replaySince(seq: number): BacklogEvent[];
24
+ }
25
+ //#endregion
26
+ export { BacklogEvent, BacklogEventCallback, BacklogEventType, EventBus };
27
+ //# sourceMappingURL=event-bus.d.mts.map
@@ -0,0 +1 @@
1
+ export { };
@@ -0,0 +1,9 @@
1
+ import { BacklogEvent, BacklogEventCallback, BacklogEventType, EventBus } from "./event-bus.mjs";
2
+ import { LocalEventBus } from "./local-event-bus.mjs";
3
+
4
+ //#region src/events/index.d.ts
5
+ /** Singleton event bus instance. Swap implementation for cloud deployment. */
6
+ declare const eventBus: LocalEventBus;
7
+ //#endregion
8
+ export { type BacklogEvent, type BacklogEventCallback, type BacklogEventType, type EventBus, LocalEventBus, eventBus };
9
+ //# sourceMappingURL=index.d.mts.map
@@ -0,0 +1,9 @@
1
+ import { LocalEventBus } from "./local-event-bus.mjs";
2
+
3
+ //#region src/events/index.ts
4
+ /** Singleton event bus instance. Swap implementation for cloud deployment. */
5
+ const eventBus = new LocalEventBus();
6
+
7
+ //#endregion
8
+ export { LocalEventBus, eventBus };
9
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.mjs","names":[],"sources":["../../src/events/index.ts"],"sourcesContent":["export type { BacklogEvent, BacklogEventType, BacklogEventCallback, EventBus } from './event-bus.js';\nexport { LocalEventBus } from './local-event-bus.js';\n\nimport { LocalEventBus } from './local-event-bus.js';\n\n/** Singleton event bus instance. Swap implementation for cloud deployment. */\nexport const eventBus = new LocalEventBus();\n"],"mappings":";;;;AAMA,MAAa,WAAW,IAAI,eAAe"}
@@ -0,0 +1,16 @@
1
+ import { BacklogEvent, BacklogEventCallback, EventBus } from "./event-bus.mjs";
2
+
3
+ //#region src/events/local-event-bus.d.ts
4
+ declare class LocalEventBus implements EventBus {
5
+ private emitter;
6
+ private seq;
7
+ private buffer;
8
+ constructor();
9
+ emit(event: Omit<BacklogEvent, 'seq'>): void;
10
+ subscribe(callback: BacklogEventCallback): void;
11
+ unsubscribe(callback: BacklogEventCallback): void;
12
+ replaySince(seq: number): BacklogEvent[];
13
+ }
14
+ //#endregion
15
+ export { LocalEventBus };
16
+ //# sourceMappingURL=local-event-bus.d.mts.map
@@ -0,0 +1,39 @@
1
+ import { EventEmitter } from "node:events";
2
+
3
+ //#region src/events/local-event-bus.ts
4
+ /**
5
+ * In-process EventBus implementation using Node.js EventEmitter.
6
+ * Maintains a ring buffer for replay on SSE reconnect.
7
+ */
8
+ const RING_BUFFER_SIZE = 1e3;
9
+ const EVENT_NAME = "backlog";
10
+ var LocalEventBus = class {
11
+ emitter = new EventEmitter();
12
+ seq = 0;
13
+ buffer = [];
14
+ constructor() {
15
+ this.emitter.setMaxListeners(100);
16
+ }
17
+ emit(event) {
18
+ const full = {
19
+ ...event,
20
+ seq: ++this.seq
21
+ };
22
+ if (this.buffer.length >= RING_BUFFER_SIZE) this.buffer.shift();
23
+ this.buffer.push(full);
24
+ this.emitter.emit(EVENT_NAME, full);
25
+ }
26
+ subscribe(callback) {
27
+ this.emitter.on(EVENT_NAME, callback);
28
+ }
29
+ unsubscribe(callback) {
30
+ this.emitter.off(EVENT_NAME, callback);
31
+ }
32
+ replaySince(seq) {
33
+ return this.buffer.filter((e) => e.seq > seq);
34
+ }
35
+ };
36
+
37
+ //#endregion
38
+ export { LocalEventBus };
39
+ //# sourceMappingURL=local-event-bus.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"local-event-bus.mjs","names":[],"sources":["../../src/events/local-event-bus.ts"],"sourcesContent":["/**\n * In-process EventBus implementation using Node.js EventEmitter.\n * Maintains a ring buffer for replay on SSE reconnect.\n */\n\nimport { EventEmitter } from 'node:events';\nimport type { BacklogEvent, BacklogEventCallback, EventBus } from './event-bus.js';\n\nconst RING_BUFFER_SIZE = 1000;\nconst EVENT_NAME = 'backlog';\n\nexport class LocalEventBus implements EventBus {\n private emitter = new EventEmitter();\n private seq = 0;\n private buffer: BacklogEvent[] = [];\n\n constructor() {\n this.emitter.setMaxListeners(100);\n }\n\n emit(event: Omit<BacklogEvent, 'seq'>): void {\n const full: BacklogEvent = { ...event, seq: ++this.seq };\n\n // Ring buffer: drop oldest when full\n if (this.buffer.length >= RING_BUFFER_SIZE) {\n this.buffer.shift();\n }\n this.buffer.push(full);\n\n this.emitter.emit(EVENT_NAME, full);\n }\n\n subscribe(callback: BacklogEventCallback): void {\n this.emitter.on(EVENT_NAME, callback);\n }\n\n unsubscribe(callback: BacklogEventCallback): void {\n this.emitter.off(EVENT_NAME, callback);\n }\n\n replaySince(seq: number): BacklogEvent[] {\n return this.buffer.filter(e => e.seq > seq);\n }\n}\n"],"mappings":";;;;;;;AAQA,MAAM,mBAAmB;AACzB,MAAM,aAAa;AAEnB,IAAa,gBAAb,MAA+C;CAC7C,AAAQ,UAAU,IAAI,cAAc;CACpC,AAAQ,MAAM;CACd,AAAQ,SAAyB,EAAE;CAEnC,cAAc;AACZ,OAAK,QAAQ,gBAAgB,IAAI;;CAGnC,KAAK,OAAwC;EAC3C,MAAM,OAAqB;GAAE,GAAG;GAAO,KAAK,EAAE,KAAK;GAAK;AAGxD,MAAI,KAAK,OAAO,UAAU,iBACxB,MAAK,OAAO,OAAO;AAErB,OAAK,OAAO,KAAK,KAAK;AAEtB,OAAK,QAAQ,KAAK,YAAY,KAAK;;CAGrC,UAAU,UAAsC;AAC9C,OAAK,QAAQ,GAAG,YAAY,SAAS;;CAGvC,YAAY,UAAsC;AAChD,OAAK,QAAQ,IAAI,YAAY,SAAS;;CAGxC,YAAY,KAA6B;AACvC,SAAO,KAAK,OAAO,QAAO,MAAK,EAAE,MAAM,IAAI"}
@@ -2,7 +2,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
 
3
3
  //#region src/operations/middleware.d.ts
4
4
  /**
5
- * Wrap an MCP server to log tool operations.
5
+ * Wrap an MCP server to log tool operations and emit real-time events.
6
6
  * Returns a proxy that intercepts registerTool calls.
7
7
  */
8
8
  declare function withOperationLogging(server: McpServer): McpServer;
@@ -1,8 +1,21 @@
1
+ import { extractResourceId } from "./resource-id.mjs";
2
+ import { WRITE_TOOLS } from "./types.mjs";
1
3
  import { operationLogger } from "./logger.mjs";
4
+ import { eventBus } from "../events/index.mjs";
2
5
 
3
6
  //#region src/operations/middleware.ts
7
+ /** Declarative mapping from write tool to event type. */
8
+ const TOOL_EVENT_MAP = {
9
+ backlog_create: "task_created",
10
+ backlog_update: "task_changed",
11
+ backlog_delete: "task_deleted",
12
+ write_resource: "resource_changed"
13
+ };
14
+ function isWriteTool(name) {
15
+ return WRITE_TOOLS.includes(name);
16
+ }
4
17
  /**
5
- * Wrap an MCP server to log tool operations.
18
+ * Wrap an MCP server to log tool operations and emit real-time events.
6
19
  * Returns a proxy that intercepts registerTool calls.
7
20
  */
8
21
  function withOperationLogging(server) {
@@ -10,7 +23,15 @@ function withOperationLogging(server) {
10
23
  server.registerTool = function(name, config, callback) {
11
24
  const wrappedCallback = async (...args) => {
12
25
  const result = await callback(...args);
13
- operationLogger.log(name, args[0] || {}, result);
26
+ const params = args[0] || {};
27
+ operationLogger.log(name, params, result);
28
+ if (isWriteTool(name)) eventBus.emit({
29
+ type: TOOL_EVENT_MAP[name],
30
+ id: extractResourceId(name, params, result) || "",
31
+ tool: name,
32
+ actor: process.env.BACKLOG_ACTOR_NAME || process.env.USER || "unknown",
33
+ ts: (/* @__PURE__ */ new Date()).toISOString()
34
+ });
14
35
  return result;
15
36
  };
16
37
  return originalRegisterTool(name, config, wrappedCallback);
@@ -1 +1 @@
1
- {"version":3,"file":"middleware.mjs","names":[],"sources":["../../src/operations/middleware.ts"],"sourcesContent":["import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';\nimport { operationLogger } from './logger.js';\n\n/**\n * Wrap an MCP server to log tool operations.\n * Returns a proxy that intercepts registerTool calls.\n */\nexport function withOperationLogging(server: McpServer): McpServer {\n const originalRegisterTool = server.registerTool.bind(server);\n\n // Override registerTool to wrap callbacks with logging\n (server as any).registerTool = function(\n name: string,\n config: any,\n callback: (...args: any[]) => any\n ) {\n const wrappedCallback = async (...args: any[]) => {\n const result = await callback(...args);\n // args[0] is the params object for tool callbacks\n operationLogger.log(name, args[0] || {}, result);\n return result;\n };\n\n return originalRegisterTool(name, config, wrappedCallback as any);\n };\n\n return server;\n}\n"],"mappings":";;;;;;;AAOA,SAAgB,qBAAqB,QAA8B;CACjE,MAAM,uBAAuB,OAAO,aAAa,KAAK,OAAO;AAG7D,CAAC,OAAe,eAAe,SAC7B,MACA,QACA,UACA;EACA,MAAM,kBAAkB,OAAO,GAAG,SAAgB;GAChD,MAAM,SAAS,MAAM,SAAS,GAAG,KAAK;AAEtC,mBAAgB,IAAI,MAAM,KAAK,MAAM,EAAE,EAAE,OAAO;AAChD,UAAO;;AAGT,SAAO,qBAAqB,MAAM,QAAQ,gBAAuB;;AAGnE,QAAO"}
1
+ {"version":3,"file":"middleware.mjs","names":[],"sources":["../../src/operations/middleware.ts"],"sourcesContent":["import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';\nimport { operationLogger } from './logger.js';\nimport { eventBus } from '../events/index.js';\nimport type { BacklogEventType } from '../events/index.js';\nimport { extractResourceId } from './resource-id.js';\nimport { WRITE_TOOLS, type ToolName } from './types.js';\n\n/** Declarative mapping from write tool to event type. */\nconst TOOL_EVENT_MAP: Record<ToolName, BacklogEventType> = {\n backlog_create: 'task_created',\n backlog_update: 'task_changed',\n backlog_delete: 'task_deleted',\n write_resource: 'resource_changed',\n};\n\nfunction isWriteTool(name: string): name is ToolName {\n return WRITE_TOOLS.includes(name as ToolName);\n}\n\n/**\n * Wrap an MCP server to log tool operations and emit real-time events.\n * Returns a proxy that intercepts registerTool calls.\n */\nexport function withOperationLogging(server: McpServer): McpServer {\n const originalRegisterTool = server.registerTool.bind(server);\n\n // Override registerTool to wrap callbacks with logging\n (server as any).registerTool = function(\n name: string,\n config: any,\n callback: (...args: any[]) => any\n ) {\n const wrappedCallback = async (...args: any[]) => {\n const result = await callback(...args);\n const params = args[0] || {};\n\n // Log operation to disk\n operationLogger.log(name, params, result);\n\n // Emit real-time event for SSE consumers\n if (isWriteTool(name)) {\n eventBus.emit({\n type: TOOL_EVENT_MAP[name],\n id: extractResourceId(name, params, result) || '',\n tool: name,\n actor: process.env.BACKLOG_ACTOR_NAME || process.env.USER || 'unknown',\n ts: new Date().toISOString(),\n });\n }\n\n return result;\n };\n\n return originalRegisterTool(name, config, wrappedCallback as any);\n };\n\n return server;\n}\n"],"mappings":";;;;;;;AAQA,MAAM,iBAAqD;CACzD,gBAAgB;CAChB,gBAAgB;CAChB,gBAAgB;CAChB,gBAAgB;CACjB;AAED,SAAS,YAAY,MAAgC;AACnD,QAAO,YAAY,SAAS,KAAiB;;;;;;AAO/C,SAAgB,qBAAqB,QAA8B;CACjE,MAAM,uBAAuB,OAAO,aAAa,KAAK,OAAO;AAG7D,CAAC,OAAe,eAAe,SAC7B,MACA,QACA,UACA;EACA,MAAM,kBAAkB,OAAO,GAAG,SAAgB;GAChD,MAAM,SAAS,MAAM,SAAS,GAAG,KAAK;GACtC,MAAM,SAAS,KAAK,MAAM,EAAE;AAG5B,mBAAgB,IAAI,MAAM,QAAQ,OAAO;AAGzC,OAAI,YAAY,KAAK,CACnB,UAAS,KAAK;IACZ,MAAM,eAAe;IACrB,IAAI,kBAAkB,MAAM,QAAQ,OAAO,IAAI;IAC/C,MAAM;IACN,OAAO,QAAQ,IAAI,sBAAsB,QAAQ,IAAI,QAAQ;IAC7D,qBAAI,IAAI,MAAM,EAAC,aAAa;IAC7B,CAAC;AAGJ,UAAO;;AAGT,SAAO,qBAAqB,MAAM,QAAQ,gBAAuB;;AAGnE,QAAO"}
@@ -2,6 +2,7 @@ import { paths } from "../utils/paths.mjs";
2
2
  import { resourceManager } from "../resources/manager.mjs";
3
3
  import { storage } from "../storage/backlog-service.mjs";
4
4
  import { operationLogger } from "../operations/logger.mjs";
5
+ import { eventBus } from "../events/index.mjs";
5
6
  import "../operations/index.mjs";
6
7
  import { existsSync, readFileSync } from "node:fs";
7
8
  import matter from "gray-matter";
@@ -9,6 +10,7 @@ import fastifyStatic from "@fastify/static";
9
10
  import { exec } from "node:child_process";
10
11
 
11
12
  //#region src/server/viewer-routes.ts
13
+ const SSE_HEARTBEAT_MS = 3e4;
12
14
  function registerViewerRoutes(app) {
13
15
  app.register(fastifyStatic, {
14
16
  root: paths.viewerDist,
@@ -182,6 +184,36 @@ function registerViewerRoutes(app) {
182
184
  const { taskId } = request.params;
183
185
  return { count: operationLogger.countForTask(taskId) };
184
186
  });
187
+ app.get("/events", (request, reply) => {
188
+ const lastEventId = request.headers["last-event-id"];
189
+ reply.hijack();
190
+ const raw = reply.raw;
191
+ raw.writeHead(200, {
192
+ "Content-Type": "text/event-stream",
193
+ "Cache-Control": "no-cache",
194
+ "Connection": "keep-alive",
195
+ "X-Accel-Buffering": "no"
196
+ });
197
+ if (lastEventId) {
198
+ const seq = parseInt(lastEventId, 10);
199
+ if (!isNaN(seq)) {
200
+ const missed = eventBus.replaySince(seq);
201
+ for (const event of missed) raw.write(`id: ${event.seq}\ndata: ${JSON.stringify(event)}\n\n`);
202
+ }
203
+ }
204
+ raw.write(`: connected\n\n`);
205
+ const onEvent = (event) => {
206
+ raw.write(`id: ${event.seq}\ndata: ${JSON.stringify(event)}\n\n`);
207
+ };
208
+ eventBus.subscribe(onEvent);
209
+ const heartbeat = setInterval(() => {
210
+ raw.write(`: heartbeat\n\n`);
211
+ }, SSE_HEARTBEAT_MS);
212
+ request.raw.on("close", () => {
213
+ clearInterval(heartbeat);
214
+ eventBus.unsubscribe(onEvent);
215
+ });
216
+ });
185
217
  }
186
218
 
187
219
  //#endregion
@@ -1 +1 @@
1
- {"version":3,"file":"viewer-routes.mjs","names":[],"sources":["../../src/server/viewer-routes.ts"],"sourcesContent":["import type { FastifyInstance } from 'fastify';\nimport fastifyStatic from '@fastify/static';\nimport { exec } from 'node:child_process';\nimport { existsSync, readFileSync } from 'node:fs';\nimport matter from 'gray-matter';\nimport { storage } from '../storage/backlog-service.js';\nimport { resourceManager } from '../resources/manager.js';\nimport { paths } from '../utils/paths.js';\nimport { operationLogger } from '../operations/index.js';\n\nexport function registerViewerRoutes(app: FastifyInstance) {\n // Static files - serve from dist/viewer (built assets)\n app.register(fastifyStatic, {\n root: paths.viewerDist,\n prefix: '/',\n });\n\n // List tasks\n app.get('/tasks', async (request) => {\n const { filter, limit, q } = request.query as { filter?: string; limit?: string; q?: string };\n \n const statusMap: Record<string, any> = {\n active: { status: ['open', 'in_progress', 'blocked'] },\n completed: { status: ['done', 'cancelled'] },\n all: {},\n };\n \n const filterConfig = statusMap[filter || 'active'] || statusMap.active;\n const tasks = await storage.list({ \n ...filterConfig, \n query: q || undefined,\n limit: limit ? parseInt(limit) : 10000 \n });\n \n return tasks;\n });\n\n // Unified search API - returns proper SearchResult[] with item, score, type\n app.get('/search', async (request, reply) => {\n const { q, types, limit, sort } = request.query as { q?: string; types?: string; limit?: string; sort?: string };\n \n if (!q) {\n return reply.code(400).send({ error: 'Missing required query parameter: q' });\n }\n \n const typeFilter = types \n ? types.split(',').filter((t): t is 'task' | 'epic' | 'resource' => \n t === 'task' || t === 'epic' || t === 'resource')\n : undefined;\n \n const sortMode = sort === 'recent' ? 'recent' : 'relevant';\n \n const results = await storage.searchUnified(q, {\n types: typeFilter?.length ? typeFilter : undefined,\n limit: limit ? parseInt(limit) : 20,\n sort: sortMode,\n });\n \n return results;\n });\n\n // Get single task\n app.get('/tasks/:id', async (request, reply) => {\n const { id } = request.params as { id: string };\n const task = storage.get(id);\n \n if (!task) {\n return reply.code(404).send({ error: 'Task not found' });\n }\n \n // Include raw markdown for copy button\n const raw = storage.getMarkdown(id);\n \n return { ...task, raw };\n });\n\n // System status\n app.get('/api/status', async () => {\n const tasks = await storage.list({ limit: 10000 });\n const address = app.server.address();\n const port = typeof address === 'object' && address ? address.port : 3030;\n \n return {\n version: paths.getVersion(),\n port,\n dataDir: paths.backlogDataDir,\n taskCount: tasks.length,\n uptime: Math.floor(process.uptime())\n };\n });\n\n // Open task in editor\n app.get('/open/:id', async (request, reply) => {\n const { id } = request.params as { id: string };\n const filePath = storage.getFilePath(id);\n \n if (!filePath) {\n return reply.code(404).send({ error: 'Task not found' });\n }\n \n exec(`open \"${filePath}\"`);\n return { status: 'Opening...' };\n });\n\n // Resource proxy\n app.get('/resource', async (request, reply) => {\n const { path: filePath } = request.query as { path?: string };\n \n if (!filePath) {\n return reply.code(400).send({ error: 'Missing path parameter' });\n }\n \n if (!existsSync(filePath)) {\n return reply.code(404).send({ error: 'File not found', path: filePath });\n }\n \n try {\n const content = readFileSync(filePath, 'utf-8');\n const ext = filePath.split('.').pop()?.toLowerCase() || 'txt';\n const mimeMap: Record<string, string> = {\n md: 'text/markdown',\n ts: 'text/typescript',\n js: 'text/javascript',\n json: 'application/json',\n txt: 'text/plain',\n };\n \n let frontmatter = {};\n let bodyContent = content;\n \n // Parse frontmatter for markdown files\n if (ext === 'md') {\n const parsed = matter(content);\n frontmatter = parsed.data;\n bodyContent = parsed.content;\n }\n \n return {\n content: bodyContent,\n frontmatter,\n type: mimeMap[ext] || 'text/plain',\n path: filePath,\n fileUri: `file://${filePath}`,\n mcpUri: resourceManager.toUri(filePath),\n ext\n };\n } catch (error: any) {\n return reply.code(500).send({ error: 'Failed to read file', message: error.message });\n }\n });\n\n // MCP resource proxy\n app.get('/mcp/resource', async (request, reply) => {\n const { uri } = request.query as { uri?: string };\n \n if (!uri || !uri.startsWith('mcp://backlog/')) {\n return reply.code(400).send({ error: 'Invalid MCP URI' });\n }\n \n try {\n const resource = resourceManager.read(uri);\n const filePath = resourceManager.resolve(uri);\n const ext = filePath.split('.').pop()?.toLowerCase() || 'txt';\n \n return {\n content: resource.content,\n frontmatter: resource.frontmatter || {},\n type: resource.mimeType,\n path: filePath,\n fileUri: `file://${filePath}`,\n mcpUri: uri,\n ext\n };\n } catch (error: any) {\n return reply.code(404).send({ error: 'Resource not found', uri, message: error.message });\n }\n });\n\n // Open resource in viewer\n app.get('/open', async (request, reply) => {\n const { uri } = request.query as { uri?: string };\n \n if (!uri) {\n return reply.code(400).send({ error: 'Missing uri parameter' });\n }\n \n return reply.redirect(`/?resource=${encodeURIComponent(uri)}`);\n });\n\n // Operations API - recent activity (enriched with task titles and epic info)\n app.get('/operations', async (request) => {\n const { limit, task, date } = request.query as { limit?: string; task?: string; date?: string };\n \n const operations = operationLogger.read({\n limit: limit ? parseInt(limit) : (date ? 1000 : 50), // Higher limit when filtering by date\n taskId: task || undefined,\n date: date || undefined,\n });\n \n // Enrich operations with task titles and epic info\n // Use in-request cache to avoid duplicate storage lookups\n const taskCache = new Map<string, { title?: string; epicId?: string }>();\n const epicCache = new Map<string, string | undefined>();\n \n const enriched = operations.map(op => {\n if (op.resourceId) {\n if (!taskCache.has(op.resourceId)) {\n const taskData = storage.get(op.resourceId);\n taskCache.set(op.resourceId, {\n title: taskData?.title,\n epicId: taskData?.epic_id,\n });\n }\n const cached = taskCache.get(op.resourceId)!;\n \n // Resolve epic title if task has an epic\n let epicTitle: string | undefined;\n if (cached.epicId) {\n if (!epicCache.has(cached.epicId)) {\n const epicData = storage.get(cached.epicId);\n epicCache.set(cached.epicId, epicData?.title);\n }\n epicTitle = epicCache.get(cached.epicId);\n }\n \n return { ...op, resourceTitle: cached.title, epicId: cached.epicId, epicTitle };\n }\n return op;\n });\n \n return enriched;\n });\n\n // Operation count for a specific task (for badge)\n app.get('/operations/count/:taskId', async (request) => {\n const { taskId } = request.params as { taskId: string };\n return { count: operationLogger.countForTask(taskId) };\n });\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"viewer-routes.mjs","names":[],"sources":["../../src/server/viewer-routes.ts"],"sourcesContent":["import type { FastifyInstance } from 'fastify';\nimport fastifyStatic from '@fastify/static';\nimport { exec } from 'node:child_process';\nimport { existsSync, readFileSync } from 'node:fs';\nimport matter from 'gray-matter';\nimport { storage } from '../storage/backlog-service.js';\nimport { resourceManager } from '../resources/manager.js';\nimport { paths } from '../utils/paths.js';\nimport { operationLogger } from '../operations/index.js';\nimport { eventBus } from '../events/index.js';\nimport type { BacklogEvent } from '../events/index.js';\n\nconst SSE_HEARTBEAT_MS = 30_000;\n\nexport function registerViewerRoutes(app: FastifyInstance) {\n // Static files - serve from dist/viewer (built assets)\n app.register(fastifyStatic, {\n root: paths.viewerDist,\n prefix: '/',\n });\n\n // List tasks\n app.get('/tasks', async (request) => {\n const { filter, limit, q } = request.query as { filter?: string; limit?: string; q?: string };\n \n const statusMap: Record<string, any> = {\n active: { status: ['open', 'in_progress', 'blocked'] },\n completed: { status: ['done', 'cancelled'] },\n all: {},\n };\n \n const filterConfig = statusMap[filter || 'active'] || statusMap.active;\n const tasks = await storage.list({ \n ...filterConfig, \n query: q || undefined,\n limit: limit ? parseInt(limit) : 10000 \n });\n \n return tasks;\n });\n\n // Unified search API - returns proper SearchResult[] with item, score, type\n app.get('/search', async (request, reply) => {\n const { q, types, limit, sort } = request.query as { q?: string; types?: string; limit?: string; sort?: string };\n \n if (!q) {\n return reply.code(400).send({ error: 'Missing required query parameter: q' });\n }\n \n const typeFilter = types \n ? types.split(',').filter((t): t is 'task' | 'epic' | 'resource' => \n t === 'task' || t === 'epic' || t === 'resource')\n : undefined;\n \n const sortMode = sort === 'recent' ? 'recent' : 'relevant';\n \n const results = await storage.searchUnified(q, {\n types: typeFilter?.length ? typeFilter : undefined,\n limit: limit ? parseInt(limit) : 20,\n sort: sortMode,\n });\n \n return results;\n });\n\n // Get single task\n app.get('/tasks/:id', async (request, reply) => {\n const { id } = request.params as { id: string };\n const task = storage.get(id);\n \n if (!task) {\n return reply.code(404).send({ error: 'Task not found' });\n }\n \n // Include raw markdown for copy button\n const raw = storage.getMarkdown(id);\n \n return { ...task, raw };\n });\n\n // System status\n app.get('/api/status', async () => {\n const tasks = await storage.list({ limit: 10000 });\n const address = app.server.address();\n const port = typeof address === 'object' && address ? address.port : 3030;\n \n return {\n version: paths.getVersion(),\n port,\n dataDir: paths.backlogDataDir,\n taskCount: tasks.length,\n uptime: Math.floor(process.uptime())\n };\n });\n\n // Open task in editor\n app.get('/open/:id', async (request, reply) => {\n const { id } = request.params as { id: string };\n const filePath = storage.getFilePath(id);\n \n if (!filePath) {\n return reply.code(404).send({ error: 'Task not found' });\n }\n \n exec(`open \"${filePath}\"`);\n return { status: 'Opening...' };\n });\n\n // Resource proxy\n app.get('/resource', async (request, reply) => {\n const { path: filePath } = request.query as { path?: string };\n \n if (!filePath) {\n return reply.code(400).send({ error: 'Missing path parameter' });\n }\n \n if (!existsSync(filePath)) {\n return reply.code(404).send({ error: 'File not found', path: filePath });\n }\n \n try {\n const content = readFileSync(filePath, 'utf-8');\n const ext = filePath.split('.').pop()?.toLowerCase() || 'txt';\n const mimeMap: Record<string, string> = {\n md: 'text/markdown',\n ts: 'text/typescript',\n js: 'text/javascript',\n json: 'application/json',\n txt: 'text/plain',\n };\n \n let frontmatter = {};\n let bodyContent = content;\n \n // Parse frontmatter for markdown files\n if (ext === 'md') {\n const parsed = matter(content);\n frontmatter = parsed.data;\n bodyContent = parsed.content;\n }\n \n return {\n content: bodyContent,\n frontmatter,\n type: mimeMap[ext] || 'text/plain',\n path: filePath,\n fileUri: `file://${filePath}`,\n mcpUri: resourceManager.toUri(filePath),\n ext\n };\n } catch (error: any) {\n return reply.code(500).send({ error: 'Failed to read file', message: error.message });\n }\n });\n\n // MCP resource proxy\n app.get('/mcp/resource', async (request, reply) => {\n const { uri } = request.query as { uri?: string };\n \n if (!uri || !uri.startsWith('mcp://backlog/')) {\n return reply.code(400).send({ error: 'Invalid MCP URI' });\n }\n \n try {\n const resource = resourceManager.read(uri);\n const filePath = resourceManager.resolve(uri);\n const ext = filePath.split('.').pop()?.toLowerCase() || 'txt';\n \n return {\n content: resource.content,\n frontmatter: resource.frontmatter || {},\n type: resource.mimeType,\n path: filePath,\n fileUri: `file://${filePath}`,\n mcpUri: uri,\n ext\n };\n } catch (error: any) {\n return reply.code(404).send({ error: 'Resource not found', uri, message: error.message });\n }\n });\n\n // Open resource in viewer\n app.get('/open', async (request, reply) => {\n const { uri } = request.query as { uri?: string };\n \n if (!uri) {\n return reply.code(400).send({ error: 'Missing uri parameter' });\n }\n \n return reply.redirect(`/?resource=${encodeURIComponent(uri)}`);\n });\n\n // Operations API - recent activity (enriched with task titles and epic info)\n app.get('/operations', async (request) => {\n const { limit, task, date } = request.query as { limit?: string; task?: string; date?: string };\n \n const operations = operationLogger.read({\n limit: limit ? parseInt(limit) : (date ? 1000 : 50), // Higher limit when filtering by date\n taskId: task || undefined,\n date: date || undefined,\n });\n \n // Enrich operations with task titles and epic info\n // Use in-request cache to avoid duplicate storage lookups\n const taskCache = new Map<string, { title?: string; epicId?: string }>();\n const epicCache = new Map<string, string | undefined>();\n \n const enriched = operations.map(op => {\n if (op.resourceId) {\n if (!taskCache.has(op.resourceId)) {\n const taskData = storage.get(op.resourceId);\n taskCache.set(op.resourceId, {\n title: taskData?.title,\n epicId: taskData?.epic_id,\n });\n }\n const cached = taskCache.get(op.resourceId)!;\n \n // Resolve epic title if task has an epic\n let epicTitle: string | undefined;\n if (cached.epicId) {\n if (!epicCache.has(cached.epicId)) {\n const epicData = storage.get(cached.epicId);\n epicCache.set(cached.epicId, epicData?.title);\n }\n epicTitle = epicCache.get(cached.epicId);\n }\n \n return { ...op, resourceTitle: cached.title, epicId: cached.epicId, epicTitle };\n }\n return op;\n });\n \n return enriched;\n });\n\n // Operation count for a specific task (for badge)\n app.get('/operations/count/:taskId', async (request) => {\n const { taskId } = request.params as { taskId: string };\n return { count: operationLogger.countForTask(taskId) };\n });\n\n // SSE endpoint for real-time viewer updates\n app.get('/events', (request, reply) => {\n const lastEventId = request.headers['last-event-id'] as string | undefined;\n\n reply.hijack();\n const raw = reply.raw;\n\n raw.writeHead(200, {\n 'Content-Type': 'text/event-stream',\n 'Cache-Control': 'no-cache',\n 'Connection': 'keep-alive',\n 'X-Accel-Buffering': 'no', // Disable nginx buffering\n });\n\n // Replay missed events if client reconnected with Last-Event-ID\n if (lastEventId) {\n const seq = parseInt(lastEventId, 10);\n if (!isNaN(seq)) {\n const missed = eventBus.replaySince(seq);\n for (const event of missed) {\n raw.write(`id: ${event.seq}\\ndata: ${JSON.stringify(event)}\\n\\n`);\n }\n }\n }\n\n // Send initial connected event\n raw.write(`: connected\\n\\n`);\n\n // Subscribe to new events\n const onEvent = (event: BacklogEvent) => {\n raw.write(`id: ${event.seq}\\ndata: ${JSON.stringify(event)}\\n\\n`);\n };\n eventBus.subscribe(onEvent);\n\n // Heartbeat to keep connection alive through proxies\n const heartbeat = setInterval(() => {\n raw.write(`: heartbeat\\n\\n`);\n }, SSE_HEARTBEAT_MS);\n\n // Cleanup on disconnect\n request.raw.on('close', () => {\n clearInterval(heartbeat);\n eventBus.unsubscribe(onEvent);\n });\n });\n}\n"],"mappings":"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"}
@@ -3168,6 +3168,39 @@ async function fetchOperationCount(taskId) {
3168
3168
  return data.count || 0;
3169
3169
  }
3170
3170
 
3171
+ // viewer/services/event-source-client.ts
3172
+ var BacklogEvents = class {
3173
+ source = null;
3174
+ listeners = /* @__PURE__ */ new Set();
3175
+ /** Start listening for server events. Call once on app init. */
3176
+ connect() {
3177
+ if (this.source) return;
3178
+ this.source = new EventSource(`${API_URL}/events`);
3179
+ this.source.onmessage = (e) => {
3180
+ try {
3181
+ const event = JSON.parse(e.data);
3182
+ for (const cb of this.listeners) {
3183
+ cb(event);
3184
+ }
3185
+ } catch {
3186
+ }
3187
+ };
3188
+ }
3189
+ /** Subscribe to all backlog change events. */
3190
+ onChange(callback) {
3191
+ this.listeners.add(callback);
3192
+ }
3193
+ /** Unsubscribe from change events. */
3194
+ offChange(callback) {
3195
+ this.listeners.delete(callback);
3196
+ }
3197
+ disconnect() {
3198
+ this.source?.close();
3199
+ this.source = null;
3200
+ }
3201
+ };
3202
+ var backlogEvents = new BacklogEvents();
3203
+
3171
3204
  // viewer/components/breadcrumb.ts
3172
3205
  var Breadcrumb = class extends HTMLElement {
3173
3206
  currentEpicId = null;
@@ -3260,7 +3293,11 @@ var TaskList = class extends HTMLElement {
3260
3293
  this.currentSort = savedSort;
3261
3294
  }
3262
3295
  this.loadTasks();
3263
- setInterval(() => this.loadTasks(), 5e3);
3296
+ backlogEvents.onChange((event) => {
3297
+ if (event.type === "task_changed" || event.type === "task_created" || event.type === "task_deleted") {
3298
+ this.loadTasks();
3299
+ }
3300
+ });
3264
3301
  document.addEventListener("filter-change", ((e) => {
3265
3302
  this.currentFilter = e.detail.filter;
3266
3303
  this.currentType = e.detail.type ?? "all";
@@ -3360,7 +3397,7 @@ var TaskList = class extends HTMLElement {
3360
3397
  const childCount = (task.type ?? "task") === "epic" ? this.allTasks.filter((t) => t.epic_id === task.id).length : 0;
3361
3398
  const isCurrentEpic = this.currentEpicId === task.id;
3362
3399
  return `
3363
- <task-item
3400
+ <task-item
3364
3401
  data-id="${task.id}"
3365
3402
  data-title="${escapeAttr(task.title)}"
3366
3403
  data-status="${task.status}"
@@ -3448,8 +3485,14 @@ function linkify(input) {
3448
3485
  return `<a href="${input.url}" target="_blank" rel="noopener">${input.title || input.url}</a>`;
3449
3486
  }
3450
3487
  var TaskDetail = class extends HTMLElement {
3488
+ currentTaskId = null;
3451
3489
  connectedCallback() {
3452
3490
  this.showEmpty();
3491
+ backlogEvents.onChange((event) => {
3492
+ if (this.currentTaskId && event.type === "task_changed" && event.id === this.currentTaskId) {
3493
+ this.loadTask(this.currentTaskId);
3494
+ }
3495
+ });
3453
3496
  }
3454
3497
  showEmpty() {
3455
3498
  this.innerHTML = `
@@ -3460,6 +3503,7 @@ var TaskDetail = class extends HTMLElement {
3460
3503
  `;
3461
3504
  }
3462
3505
  async loadTask(taskId) {
3506
+ this.currentTaskId = taskId;
3463
3507
  try {
3464
3508
  const task = await fetchTask(taskId);
3465
3509
  this.updatePaneHeader(task);
@@ -7002,7 +7046,6 @@ function getToolIcon(tool) {
7002
7046
  function createUnifiedDiff(oldStr, newStr, filename = "file") {
7003
7047
  return createTwoFilesPatch(filename, filename, oldStr, newStr, "", "", { context: 5 });
7004
7048
  }
7005
- var POLL_INTERVAL = 3e4;
7006
7049
  var MODE_STORAGE_KEY = "backlog:activity-mode";
7007
7050
  var DEFAULT_VISIBLE_ITEMS = 2;
7008
7051
  var ActivityPanel = class extends HTMLElement {
@@ -7010,11 +7053,10 @@ var ActivityPanel = class extends HTMLElement {
7010
7053
  operations = [];
7011
7054
  expandedIndex = null;
7012
7055
  // Changed to timestamp-based ID
7013
- pollTimer = null;
7014
- visibilityHandler = null;
7015
7056
  mode = "timeline";
7016
7057
  selectedDate = getTodayKey();
7017
7058
  expandedTaskGroups = /* @__PURE__ */ new Set();
7059
+ changeHandler = () => this.loadOperations();
7018
7060
  connectedCallback() {
7019
7061
  this.className = "activity-panel";
7020
7062
  const savedMode = localStorage.getItem(MODE_STORAGE_KEY);
@@ -7022,33 +7064,10 @@ var ActivityPanel = class extends HTMLElement {
7022
7064
  this.mode = savedMode;
7023
7065
  }
7024
7066
  this.render();
7025
- this.startPolling();
7067
+ backlogEvents.onChange(this.changeHandler);
7026
7068
  }
7027
7069
  disconnectedCallback() {
7028
- this.stopPolling();
7029
- }
7030
- startPolling() {
7031
- this.pollTimer = window.setInterval(() => {
7032
- if (document.visibilityState === "visible") {
7033
- this.loadOperations();
7034
- }
7035
- }, POLL_INTERVAL);
7036
- this.visibilityHandler = () => {
7037
- if (document.visibilityState === "visible") {
7038
- this.loadOperations();
7039
- }
7040
- };
7041
- document.addEventListener("visibilitychange", this.visibilityHandler);
7042
- }
7043
- stopPolling() {
7044
- if (this.pollTimer !== null) {
7045
- clearInterval(this.pollTimer);
7046
- this.pollTimer = null;
7047
- }
7048
- if (this.visibilityHandler) {
7049
- document.removeEventListener("visibilitychange", this.visibilityHandler);
7050
- this.visibilityHandler = null;
7051
- }
7070
+ backlogEvents.offChange(this.changeHandler);
7052
7071
  }
7053
7072
  setTaskId(taskId) {
7054
7073
  this.taskId = taskId;
@@ -7851,6 +7870,7 @@ var BacklogApp = class extends HTMLElement {
7851
7870
  customElements.define("backlog-app", BacklogApp);
7852
7871
 
7853
7872
  // viewer/main.ts
7873
+ backlogEvents.connect();
7854
7874
  document.addEventListener("filter-change", ((e) => {
7855
7875
  urlState.set({ filter: e.detail.filter, type: e.detail.type });
7856
7876
  }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backlog-mcp",
3
- "version": "0.31.0",
3
+ "version": "0.32.0",
4
4
  "description": "Minimal task backlog MCP server for Claude and AI agents",
5
5
  "keywords": [
6
6
  "mcp",
@@ -23,6 +23,7 @@ import {
23
23
  type JournalEntry,
24
24
  type EpicGroup,
25
25
  } from './activity-utils.js';
26
+ import { backlogEvents, type ChangeCallback } from '../services/event-source-client.js';
26
27
 
27
28
  type ViewMode = 'timeline' | 'journal';
28
29
 
@@ -30,7 +31,6 @@ function createUnifiedDiff(oldStr: string, newStr: string, filename: string = 'f
30
31
  return createTwoFilesPatch(filename, filename, oldStr, newStr, '', '', { context: 5 });
31
32
  }
32
33
 
33
- const POLL_INTERVAL = 30000;
34
34
  const MODE_STORAGE_KEY = 'backlog:activity-mode';
35
35
  const DEFAULT_VISIBLE_ITEMS = 2;
36
36
 
@@ -38,11 +38,10 @@ export class ActivityPanel extends HTMLElement {
38
38
  private taskId: string | null = null;
39
39
  private operations: OperationEntry[] = [];
40
40
  private expandedIndex: string | null = null; // Changed to timestamp-based ID
41
- private pollTimer: number | null = null;
42
- private visibilityHandler: (() => void) | null = null;
43
41
  private mode: ViewMode = 'timeline';
44
42
  private selectedDate: string = getTodayKey();
45
43
  private expandedTaskGroups = new Set<string>();
44
+ private changeHandler: ChangeCallback = () => this.loadOperations();
46
45
 
47
46
  connectedCallback() {
48
47
  this.className = 'activity-panel';
@@ -52,37 +51,11 @@ export class ActivityPanel extends HTMLElement {
52
51
  this.mode = savedMode;
53
52
  }
54
53
  this.render();
55
- this.startPolling();
54
+ backlogEvents.onChange(this.changeHandler);
56
55
  }
57
56
 
58
57
  disconnectedCallback() {
59
- this.stopPolling();
60
- }
61
-
62
- private startPolling() {
63
- this.pollTimer = window.setInterval(() => {
64
- if (document.visibilityState === 'visible') {
65
- this.loadOperations();
66
- }
67
- }, POLL_INTERVAL);
68
-
69
- this.visibilityHandler = () => {
70
- if (document.visibilityState === 'visible') {
71
- this.loadOperations();
72
- }
73
- };
74
- document.addEventListener('visibilitychange', this.visibilityHandler);
75
- }
76
-
77
- private stopPolling() {
78
- if (this.pollTimer !== null) {
79
- clearInterval(this.pollTimer);
80
- this.pollTimer = null;
81
- }
82
- if (this.visibilityHandler) {
83
- document.removeEventListener('visibilitychange', this.visibilityHandler);
84
- this.visibilityHandler = null;
85
- }
58
+ backlogEvents.offChange(this.changeHandler);
86
59
  }
87
60
 
88
61
  setTaskId(taskId: string | null) {
@@ -1,6 +1,7 @@
1
1
  import { fetchTask, fetchOperationCount } from '../utils/api.js';
2
2
  import type { Reference } from '../utils/api.js';
3
3
  import { copyIcon, activityIcon } from '../icons/index.js';
4
+ import { backlogEvents } from '../services/event-source-client.js';
4
5
 
5
6
  function linkify(input: string | Reference): string {
6
7
  if (typeof input === 'string') {
@@ -11,10 +12,19 @@ function linkify(input: string | Reference): string {
11
12
  }
12
13
 
13
14
  export class TaskDetail extends HTMLElement {
15
+ private currentTaskId: string | null = null;
16
+
14
17
  connectedCallback() {
15
18
  this.showEmpty();
19
+
20
+ // Re-fetch displayed task when it changes via SSE
21
+ backlogEvents.onChange((event) => {
22
+ if (this.currentTaskId && event.type === 'task_changed' && event.id === this.currentTaskId) {
23
+ this.loadTask(this.currentTaskId);
24
+ }
25
+ });
16
26
  }
17
-
27
+
18
28
  showEmpty() {
19
29
  this.innerHTML = `
20
30
  <div class="empty-state">
@@ -25,6 +35,7 @@ export class TaskDetail extends HTMLElement {
25
35
  }
26
36
 
27
37
  async loadTask(taskId: string) {
38
+ this.currentTaskId = taskId;
28
39
  try {
29
40
  const task = await fetchTask(taskId);
30
41
 
@@ -1,4 +1,5 @@
1
1
  import { fetchTasks, type Task } from '../utils/api.js';
2
+ import { backlogEvents } from '../services/event-source-client.js';
2
3
  import './breadcrumb.js';
3
4
  import { ringIcon } from '../icons/index.js';
4
5
 
@@ -17,42 +18,48 @@ export class TaskList extends HTMLElement {
17
18
  private selectedTaskId: string | null = null;
18
19
  private currentQuery: string | null = null;
19
20
  private allTasks: Task[] = [];
20
-
21
+
21
22
  connectedCallback() {
22
23
  const params = new URLSearchParams(window.location.search);
23
24
  this.selectedTaskId = params.get('task');
24
25
  this.currentEpicId = params.get('epic');
25
26
  this.currentQuery = params.get('q');
26
-
27
+
27
28
  // Restore sort from localStorage
28
29
  const savedSort = localStorage.getItem(SORT_STORAGE_KEY);
29
30
  if (savedSort) {
30
31
  this.currentSort = savedSort;
31
32
  }
32
-
33
+
33
34
  this.loadTasks();
34
- setInterval(() => this.loadTasks(), 5000);
35
-
35
+
36
+ // Real-time updates via centralized event service
37
+ backlogEvents.onChange((event) => {
38
+ if (event.type === 'task_changed' || event.type === 'task_created' || event.type === 'task_deleted') {
39
+ this.loadTasks();
40
+ }
41
+ });
42
+
36
43
  document.addEventListener('filter-change', ((e: CustomEvent) => {
37
44
  this.currentFilter = e.detail.filter;
38
45
  this.currentType = e.detail.type ?? 'all';
39
46
  this.loadTasks();
40
47
  }) as EventListener);
41
-
48
+
42
49
  document.addEventListener('sort-change', ((e: CustomEvent) => {
43
50
  this.currentSort = e.detail.sort;
44
51
  this.loadTasks();
45
52
  }) as EventListener);
46
-
53
+
47
54
  document.addEventListener('search-change', ((e: CustomEvent) => {
48
55
  this.currentQuery = e.detail.query || null;
49
56
  this.loadTasks();
50
57
  }) as EventListener);
51
-
58
+
52
59
  document.addEventListener('task-selected', ((e: CustomEvent) => {
53
60
  this.setSelected(e.detail.taskId);
54
61
  }) as EventListener);
55
-
62
+
56
63
  document.addEventListener('epic-navigate', ((e: CustomEvent) => {
57
64
  this.currentEpicId = e.detail.epicId;
58
65
  if (e.detail.epicId) {
@@ -61,7 +68,7 @@ export class TaskList extends HTMLElement {
61
68
  this.loadTasks();
62
69
  }) as EventListener);
63
70
  }
64
-
71
+
65
72
  setState(filter: string, type: string, epicId: string | null, taskId: string | null, query: string | null) {
66
73
  this.currentFilter = filter;
67
74
  this.currentType = type;
@@ -70,7 +77,7 @@ export class TaskList extends HTMLElement {
70
77
  this.currentQuery = query;
71
78
  this.loadTasks();
72
79
  }
73
-
80
+
74
81
  private sortTasks(tasks: Task[]): Task[] {
75
82
  const sorted = [...tasks];
76
83
  switch (this.currentSort) {
@@ -83,20 +90,20 @@ export class TaskList extends HTMLElement {
83
90
  return sorted.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime());
84
91
  }
85
92
  }
86
-
93
+
87
94
  async loadTasks() {
88
95
  try {
89
96
  let tasks = await fetchTasks(this.currentFilter as any, this.currentQuery || undefined);
90
97
  this.allTasks = tasks;
91
-
98
+
92
99
  // Type filter
93
100
  if (this.currentType !== 'all') {
94
101
  tasks = tasks.filter(t => (t.type ?? 'task') === this.currentType);
95
102
  }
96
-
103
+
97
104
  // Apply sort
98
105
  tasks = this.sortTasks(tasks);
99
-
106
+
100
107
  // Epic navigation filter
101
108
  if (this.currentEpicId) {
102
109
  const currentEpic = tasks.find(t => t.id === this.currentEpicId);
@@ -108,9 +115,9 @@ export class TaskList extends HTMLElement {
108
115
  const orphanTasks = tasks.filter(t => (t.type ?? 'task') === 'task' && !t.epic_id);
109
116
  tasks = [...rootEpics, ...orphanTasks];
110
117
  }
111
-
118
+
112
119
  this.render(tasks);
113
-
120
+
114
121
  const breadcrumb = this.querySelector('epic-breadcrumb');
115
122
  if (breadcrumb) {
116
123
  (breadcrumb as any).setData(this.currentEpicId, this.allTasks);
@@ -119,13 +126,13 @@ export class TaskList extends HTMLElement {
119
126
  this.innerHTML = `<div class="error">Failed to load tasks: ${(error as Error).message}</div>`;
120
127
  }
121
128
  }
122
-
129
+
123
130
  render(tasks: Task[]) {
124
131
  const isEmpty = tasks.length === 0;
125
132
  const isInsideEpic = !!this.currentEpicId;
126
133
  const currentEpic = isInsideEpic ? tasks.find(t => t.id === this.currentEpicId) : null;
127
134
  const hasOnlyEpic = isInsideEpic && tasks.length === 1 && currentEpic;
128
-
135
+
129
136
  if (isEmpty) {
130
137
  this.innerHTML = `
131
138
  <epic-breadcrumb></epic-breadcrumb>
@@ -140,22 +147,22 @@ export class TaskList extends HTMLElement {
140
147
  }
141
148
  return;
142
149
  }
143
-
150
+
144
151
  // Group: epics first, then tasks
145
152
  const epics = tasks.filter(t => (t.type ?? 'task') === 'epic');
146
153
  const regularTasks = tasks.filter(t => (t.type ?? 'task') === 'task');
147
154
  const grouped = [...epics, ...regularTasks];
148
-
155
+
149
156
  this.innerHTML = `
150
157
  <epic-breadcrumb></epic-breadcrumb>
151
158
  <div class="task-list">
152
159
  ${grouped.map((task, index) => {
153
- const childCount = (task.type ?? 'task') === 'epic'
154
- ? this.allTasks.filter(t => t.epic_id === task.id).length
160
+ const childCount = (task.type ?? 'task') === 'epic'
161
+ ? this.allTasks.filter(t => t.epic_id === task.id).length
155
162
  : 0;
156
163
  const isCurrentEpic = this.currentEpicId === task.id;
157
164
  return `
158
- <task-item
165
+ <task-item
159
166
  data-id="${task.id}"
160
167
  data-title="${escapeAttr(task.title)}"
161
168
  data-status="${task.status}"
@@ -170,13 +177,13 @@ export class TaskList extends HTMLElement {
170
177
  ${hasOnlyEpic ? '<div class="empty-state-inline"><div class="empty-state-icon">—</div><div>No tasks in this epic</div></div>' : ''}
171
178
  </div>
172
179
  `;
173
-
180
+
174
181
  const breadcrumb = this.querySelector('epic-breadcrumb');
175
182
  if (breadcrumb) {
176
183
  (breadcrumb as any).setData(this.currentEpicId, this.allTasks);
177
184
  }
178
185
  }
179
-
186
+
180
187
  setSelected(taskId: string) {
181
188
  this.selectedTaskId = taskId;
182
189
  }
package/viewer/main.ts CHANGED
@@ -16,6 +16,10 @@ import './components/activity-panel.js';
16
16
  import './components/backlog-app.js';
17
17
  import { urlState } from './utils/url-state.js';
18
18
  import { splitPane } from './utils/split-pane.js';
19
+ import { backlogEvents } from './services/event-source-client.js';
20
+
21
+ // Connect to SSE for real-time updates
22
+ backlogEvents.connect();
19
23
 
20
24
  // Component events -> URL updates
21
25
  document.addEventListener('filter-change', ((e: CustomEvent) => {
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Centralized real-time update service for the viewer.
3
+ *
4
+ * Owns the SSE connection and provides a simple subscribe/unsubscribe
5
+ * API for components. Components have zero knowledge of SSE, EventSource,
6
+ * or transport details — they just register callbacks.
7
+ */
8
+
9
+ import { API_URL } from '../utils/api.js';
10
+
11
+ export type BacklogEventType = 'task_changed' | 'task_created' | 'task_deleted' | 'resource_changed';
12
+
13
+ export interface BacklogEvent {
14
+ seq: number;
15
+ type: BacklogEventType;
16
+ id: string;
17
+ tool: string;
18
+ actor: string;
19
+ ts: string;
20
+ }
21
+
22
+ export type ChangeCallback = (event: BacklogEvent) => void;
23
+
24
+ class BacklogEvents {
25
+ private source: EventSource | null = null;
26
+ private listeners = new Set<ChangeCallback>();
27
+
28
+ /** Start listening for server events. Call once on app init. */
29
+ connect(): void {
30
+ if (this.source) return;
31
+
32
+ this.source = new EventSource(`${API_URL}/events`);
33
+
34
+ this.source.onmessage = (e) => {
35
+ try {
36
+ const event: BacklogEvent = JSON.parse(e.data);
37
+ for (const cb of this.listeners) {
38
+ cb(event);
39
+ }
40
+ } catch {
41
+ // Ignore malformed messages
42
+ }
43
+ };
44
+ }
45
+
46
+ /** Subscribe to all backlog change events. */
47
+ onChange(callback: ChangeCallback): void {
48
+ this.listeners.add(callback);
49
+ }
50
+
51
+ /** Unsubscribe from change events. */
52
+ offChange(callback: ChangeCallback): void {
53
+ this.listeners.delete(callback);
54
+ }
55
+
56
+ disconnect(): void {
57
+ this.source?.close();
58
+ this.source = null;
59
+ }
60
+ }
61
+
62
+ export const backlogEvents = new BacklogEvents();