From 99cd20c5a46c4e406097fb27c7e1eb28c0e6c849 Mon Sep 17 00:00:00 2001 From: Brad Groux Date: Wed, 28 Jan 2026 12:18:41 -0600 Subject: [PATCH] perf: add in-memory task caching with file watchers --- .../telemetry/events-2026-01-28.ndjson | 56 +++++ server/.veritas-kanban/activity.json | 121 +++++++++++ server/src/__tests__/task-service.test.ts | 2 + server/src/index.ts | 122 ++++++----- server/src/middleware/api-version.ts | 39 ++++ server/src/middleware/cache-control.ts | 108 ++++++++++ server/src/routes/tasks.ts | 9 + server/src/routes/v1/index.ts | 77 +++++++ server/src/services/task-service.ts | 204 ++++++++++++++++-- web/src/components/task/TaskCard.tsx | 70 +++++- 10 files changed, 726 insertions(+), 82 deletions(-) create mode 100644 server/src/middleware/api-version.ts create mode 100644 server/src/middleware/cache-control.ts create mode 100644 server/src/routes/v1/index.ts diff --git a/.veritas-kanban/telemetry/events-2026-01-28.ndjson b/.veritas-kanban/telemetry/events-2026-01-28.ndjson index 76843c05..05ec3c6d 100644 --- a/.veritas-kanban/telemetry/events-2026-01-28.ndjson +++ b/.veritas-kanban/telemetry/events-2026-01-28.ndjson @@ -2111,3 +2111,59 @@ {"type":"task.created","taskId":"task_20260128_13CrZo","project":"project-b","status":"todo","id":"evt_YNGhKOJL_Tmu","timestamp":"2026-01-28T18:14:17.786Z"} {"type":"task.created","taskId":"task_20260128_RJXi4k","status":"todo","id":"evt_21ZTMT4yYCOp","timestamp":"2026-01-28T18:14:17.799Z"} {"type":"task.status_changed","taskId":"task_20260128_9t1KEa","project":"veritas-kanban","status":"in-progress","previousStatus":"todo","id":"evt_E2py7Rsv0FZs","timestamp":"2026-01-28T18:14:39.641Z"} +{"type":"task.status_changed","taskId":"task_20260128_FWEVBg","project":"veritas-kanban","status":"done","previousStatus":"in-progress","id":"evt_vvQZmrJPozle","timestamp":"2026-01-28T18:14:44.015Z"} +{"type":"task.status_changed","taskId":"task_20260128_9t1KEa","project":"veritas-kanban","status":"done","previousStatus":"in-progress","id":"evt_Lv1wSZAvkEDD","timestamp":"2026-01-28T18:14:52.071Z"} +{"type":"task.status_changed","taskId":"task_20260128_07AFaA","project":"veritas-kanban","status":"done","previousStatus":"todo","id":"evt_dHfy9xIYTh_9","timestamp":"2026-01-28T18:14:52.466Z"} +{"type":"task.status_changed","taskId":"task_20260128_45e5GC","project":"veritas-kanban","status":"done","previousStatus":"todo","id":"evt_JRf5h4DFgvtn","timestamp":"2026-01-28T18:15:02.862Z"} +{"type":"task.status_changed","taskId":"task_20260128_yR0web","project":"veritas-kanban","status":"done","previousStatus":"todo","id":"evt_WHLUeUZL7-lF","timestamp":"2026-01-28T18:15:03.146Z"} +{"type":"task.status_changed","taskId":"task_20260128_HUc92E","project":"veritas-kanban","status":"in-progress","previousStatus":"todo","id":"evt_6xDNi6r1RM7Z","timestamp":"2026-01-28T18:16:20.965Z"} +{"type":"task.status_changed","taskId":"task_20260128_lLNafd","project":"veritas-kanban","status":"in-progress","previousStatus":"todo","id":"evt_L_mnTcbZO16Y","timestamp":"2026-01-28T18:16:21.655Z"} +{"type":"task.status_changed","taskId":"task_20260128_pz20DY","project":"veritas-kanban","status":"in-progress","previousStatus":"todo","id":"evt_2KjsVsiDbRIg","timestamp":"2026-01-28T18:16:39.491Z"} +{"type":"task.created","taskId":"task_20260128_jiPWYL","project":"my-project","status":"todo","id":"evt_7xg2GVize0st","timestamp":"2026-01-28T18:18:29.693Z"} +{"type":"task.created","taskId":"task_20260128_kZ41Fl","status":"todo","id":"evt_Kmlu864L36IL","timestamp":"2026-01-28T18:18:29.694Z"} +{"type":"task.created","taskId":"task_20260128_Q4VFzR","status":"todo","id":"evt_-MvDdSFwfQ_0","timestamp":"2026-01-28T18:18:29.696Z"} +{"type":"task.created","taskId":"task_20260128_7U-RTa","status":"todo","id":"evt_6R71hYzLGbxX","timestamp":"2026-01-28T18:18:29.697Z"} +{"type":"task.status_changed","taskId":"task_20260128_7U-RTa","status":"in-progress","previousStatus":"todo","id":"evt_Ehol161xYYWk","timestamp":"2026-01-28T18:18:29.709Z"} +{"type":"task.created","taskId":"task_20260128_UQDeGB","status":"todo","id":"evt_Kf8VQnlU8ID_","timestamp":"2026-01-28T18:18:29.711Z"} +{"type":"task.created","taskId":"task_20260128_rD3-qK","status":"todo","id":"evt_4aBvgK_vGDRq","timestamp":"2026-01-28T18:18:29.713Z"} +{"type":"task.created","taskId":"task_20260128_myxndB","status":"todo","id":"evt_vDY-8_cVdeXZ","timestamp":"2026-01-28T18:18:29.758Z"} +{"type":"task.archived","taskId":"task_20260128_myxndB","status":"todo","id":"evt_63k-VKdXQPsF","timestamp":"2026-01-28T18:18:29.759Z"} +{"type":"task.created","taskId":"task_20260128_st2L32","status":"todo","id":"evt_Iq7M_gk0J1WG","timestamp":"2026-01-28T18:18:35.859Z"} +{"type":"task.created","taskId":"task_20260128_FHk-6Y","status":"todo","id":"evt_FP11bC0kfesF","timestamp":"2026-01-28T18:18:35.865Z"} +{"type":"task.created","taskId":"task_20260128_V0vFE8","status":"todo","id":"evt_g3AeTtDK3gk0","timestamp":"2026-01-28T18:18:35.866Z"} +{"type":"task.created","taskId":"task_20260128_qdvqaq","status":"todo","id":"evt_rG8uVv4ezD_a","timestamp":"2026-01-28T18:18:35.868Z"} +{"type":"task.created","taskId":"task_20260128_tTHtMz","status":"todo","id":"evt_xc0mEn8PguVZ","timestamp":"2026-01-28T18:18:35.879Z"} +{"type":"task.created","taskId":"task_20260128_xkonCg","status":"todo","id":"evt_7NbPmw-iF6yf","timestamp":"2026-01-28T18:18:35.882Z"} +{"type":"task.created","taskId":"task_20260128_YmsbiL","project":"test-project","status":"todo","id":"evt_nndU-nZIaZAC","timestamp":"2026-01-28T18:18:35.886Z"} +{"type":"task.created","taskId":"task_20260128_bpVmQF","status":"todo","id":"evt_xGJxswyp-cgd","timestamp":"2026-01-28T18:18:35.887Z"} +{"type":"task.created","taskId":"task_20260128_Di25qe","status":"todo","id":"evt_8YMwnBmcNDce","timestamp":"2026-01-28T18:18:35.888Z"} +{"type":"task.created","taskId":"task_20260128_5eQak0","status":"todo","id":"evt_41OOrJcStoZJ","timestamp":"2026-01-28T18:18:35.889Z"} +{"type":"task.created","taskId":"task_20260128_XTIn0-","status":"todo","id":"evt_QGp2_YI7DEbe","timestamp":"2026-01-28T18:18:35.896Z"} +{"type":"task.created","taskId":"task_20260128_3YdJvy","status":"todo","id":"evt_jMHy4hyMm2ZW","timestamp":"2026-01-28T18:18:35.897Z"} +{"type":"task.status_changed","taskId":"task_20260128_XTIn0-","status":"in-progress","previousStatus":"todo","id":"evt__3r-TFEQURy0","timestamp":"2026-01-28T18:18:35.898Z"} +{"type":"task.created","taskId":"task_20260128_PcIxMu","status":"todo","id":"evt_NkPqP2G6BAfd","timestamp":"2026-01-28T18:18:35.901Z"} +{"type":"task.created","taskId":"task_20260128_SSKL-I","status":"todo","id":"evt_2-Ht7ioRhWBb","timestamp":"2026-01-28T18:18:35.903Z"} +{"type":"task.created","taskId":"task_20260128_aSbiLC","status":"todo","id":"evt_n6e6gjedSK0J","timestamp":"2026-01-28T18:18:35.905Z"} +{"type":"task.created","taskId":"task_20260128_SH9oiw","status":"todo","id":"evt_c4UYtaOo04T5","timestamp":"2026-01-28T18:18:35.908Z"} +{"type":"task.created","taskId":"task_20260128_qyJ6RA","status":"todo","id":"evt_Zh4bWjokAEkn","timestamp":"2026-01-28T18:18:35.908Z"} +{"type":"task.created","taskId":"task_20260128_UJBnDB","status":"todo","id":"evt_jd_nlCeroitU","timestamp":"2026-01-28T18:18:35.910Z"} +{"type":"task.created","taskId":"task_20260128_R7cELY","status":"todo","id":"evt_V_dU0AUfl2QU","timestamp":"2026-01-28T18:18:35.912Z"} +{"type":"task.created","taskId":"task_20260128_a_we0G","status":"todo","id":"evt_RM2Ftl92z-dE","timestamp":"2026-01-28T18:18:35.921Z"} +{"type":"task.created","taskId":"task_20260128_i0xgOv","status":"todo","id":"evt_FNJLqqkjFJAL","timestamp":"2026-01-28T18:18:35.923Z"} +{"type":"task.created","taskId":"task_20260128_e2Ew-C","status":"todo","id":"evt_hCSswzw_yuKz","timestamp":"2026-01-28T18:18:35.926Z"} +{"type":"task.created","taskId":"task_20260128_MfWvJe","status":"todo","id":"evt_vKwBAGbKmP34","timestamp":"2026-01-28T18:18:35.929Z"} +{"type":"task.created","taskId":"task_20260128_mO4RWK","status":"todo","id":"evt_Mhm3X8xCNksm","timestamp":"2026-01-28T18:18:35.931Z"} +{"type":"task.created","taskId":"task_20260128_Gxsapv","status":"todo","id":"evt_BZnhzcg2AbLl","timestamp":"2026-01-28T18:18:35.932Z"} +{"type":"task.created","taskId":"task_20260128_MPU9eX","status":"todo","id":"evt_eykkI7NYJJK5","timestamp":"2026-01-28T18:18:35.934Z"} +{"type":"task.created","taskId":"task_20260128_iS20Qk","status":"todo","id":"evt_W0t9fswSj-4v","timestamp":"2026-01-28T18:18:35.937Z"} +{"type":"task.created","taskId":"task_20260128_jMdwqv","status":"todo","id":"evt_9iKhJpGSl52e","timestamp":"2026-01-28T18:18:35.940Z"} +{"type":"task.created","taskId":"task_20260128_AzY9Uk","status":"todo","id":"evt__jghdBGzC6Qb","timestamp":"2026-01-28T18:18:35.946Z"} +{"type":"task.created","taskId":"task_20260128_88p6SG","status":"todo","id":"evt__FhJGCVdyPuN","timestamp":"2026-01-28T18:18:35.946Z"} +{"type":"task.created","taskId":"task_20260128_C3EWZ_","status":"todo","id":"evt_8jcqvpO03QAv","timestamp":"2026-01-28T18:18:35.955Z"} +{"type":"task.created","taskId":"task_20260128_3I-vCx","project":"project-a","status":"todo","id":"evt_2E3QZWyzxnMx","timestamp":"2026-01-28T18:18:35.960Z"} +{"type":"task.created","taskId":"task_20260128_ye5KB5","project":"project-a","status":"todo","id":"evt_UFNO4e5Ae2PK","timestamp":"2026-01-28T18:18:35.961Z"} +{"type":"task.created","taskId":"task_20260128_-KX1Ap","project":"project-b","status":"todo","id":"evt_P_yDSfTDsV55","timestamp":"2026-01-28T18:18:35.961Z"} +{"type":"task.created","taskId":"task_20260128_yP3fTo","status":"todo","id":"evt_Zz2sbkTi16cz","timestamp":"2026-01-28T18:18:35.968Z"} +{"type":"task.created","taskId":"task_20260128_OgXGkk","status":"todo","id":"evt_WxsQ4lTtVVlo","timestamp":"2026-01-28T18:18:35.974Z"} +{"type":"task.created","taskId":"task_20260128_N81BNu","status":"todo","id":"evt_DqdIl0sJ62OO","timestamp":"2026-01-28T18:18:35.974Z"} +{"type":"task.created","taskId":"task_20260128_-MLrjY","status":"todo","id":"evt_Uq3tmL-gTpTY","timestamp":"2026-01-28T18:18:35.975Z"} diff --git a/server/.veritas-kanban/activity.json b/server/.veritas-kanban/activity.json index 3a6da53c..1b9d7b34 100644 --- a/server/.veritas-kanban/activity.json +++ b/server/.veritas-kanban/activity.json @@ -1,4 +1,125 @@ [ + { + "id": "activity_1769624199491_2nk1070ar", + "type": "status_changed", + "taskId": "task_20260128_pz20DY", + "taskTitle": "PERF: Add Cache-Control headers for static resources and GET endpoints", + "details": { + "from": "todo", + "status": "in-progress" + }, + "timestamp": "2026-01-28T18:16:39.491Z" + }, + { + "id": "activity_1769624181655_nnsj0ja0d", + "type": "status_changed", + "taskId": "task_20260128_lLNafd", + "taskTitle": "SECURITY: Add API versioning (/api/v1/...)", + "details": { + "from": "todo", + "status": "in-progress" + }, + "timestamp": "2026-01-28T18:16:21.655Z" + }, + { + "id": "activity_1769624180965_afrv1yy1d", + "type": "status_changed", + "taskId": "task_20260128_HUc92E", + "taskTitle": "PERF: Implement in-memory caching for TaskService with file watchers", + "details": { + "from": "todo", + "status": "in-progress" + }, + "timestamp": "2026-01-28T18:16:20.965Z" + }, + { + "id": "activity_1769624103323_t8m2tkaat", + "type": "comment_added", + "taskId": "task_20260128_yR0web", + "taskTitle": "SECURITY: Validate Origin header for WebSocket connections", + "details": { + "author": "Veritas", + "preview": "Added WebSocket Origin validation with verifyClien..." + }, + "timestamp": "2026-01-28T18:15:03.323Z" + }, + { + "id": "activity_1769624103146_uws0q05qa", + "type": "status_changed", + "taskId": "task_20260128_yR0web", + "taskTitle": "SECURITY: Validate Origin header for WebSocket connections", + "details": { + "from": "todo", + "status": "done" + }, + "timestamp": "2026-01-28T18:15:03.146Z" + }, + { + "id": "activity_1769624103009_w3at4njlg", + "type": "comment_added", + "taskId": "task_20260128_45e5GC", + "taskTitle": "PERF: Reduce polling interval or disable when WebSocket connected", + "details": { + "author": "Veritas", + "preview": "Reduced polling from 10s to 60s when WebSocket con..." + }, + "timestamp": "2026-01-28T18:15:03.009Z" + }, + { + "id": "activity_1769624102862_kz3mh5ar8", + "type": "status_changed", + "taskId": "task_20260128_45e5GC", + "taskTitle": "PERF: Reduce polling interval or disable when WebSocket connected", + "details": { + "from": "todo", + "status": "done" + }, + "timestamp": "2026-01-28T18:15:02.862Z" + }, + { + "id": "activity_1769624092702_vbn6m9ir4", + "type": "comment_added", + "taskId": "task_20260128_07AFaA", + "taskTitle": "PERF: Cache config in memory and invalidate on write", + "details": { + "author": "Veritas", + "preview": "Added TTL-based in-memory caching (60s) to ConfigS..." + }, + "timestamp": "2026-01-28T18:14:52.702Z" + }, + { + "id": "activity_1769624092467_i0e4xwf0m", + "type": "status_changed", + "taskId": "task_20260128_07AFaA", + "taskTitle": "PERF: Cache config in memory and invalidate on write", + "details": { + "from": "todo", + "status": "done" + }, + "timestamp": "2026-01-28T18:14:52.467Z" + }, + { + "id": "activity_1769624092075_9q5s50qfg", + "type": "comment_added", + "taskId": "task_20260128_FWEVBg", + "taskTitle": "SECURITY: Sanitize Markdown content to prevent stored XSS", + "details": { + "author": "Veritas", + "preview": "Implemented XSS sanitization with DOMPurify. Creat..." + }, + "timestamp": "2026-01-28T18:14:52.075Z" + }, + { + "id": "activity_1769624084016_6wsrymn6s", + "type": "status_changed", + "taskId": "task_20260128_FWEVBg", + "taskTitle": "SECURITY: Sanitize Markdown content to prevent stored XSS", + "details": { + "from": "in-progress", + "status": "done" + }, + "timestamp": "2026-01-28T18:14:44.016Z" + }, { "id": "activity_1769624079642_g7c232tud", "type": "status_changed", diff --git a/server/src/__tests__/task-service.test.ts b/server/src/__tests__/task-service.test.ts index 06ad9706..b2c1d971 100644 --- a/server/src/__tests__/task-service.test.ts +++ b/server/src/__tests__/task-service.test.ts @@ -27,6 +27,8 @@ describe('TaskService', () => { }); afterEach(async () => { + // Dispose watchers before removing directories + service.dispose(); // Clean up test directories if (testRoot) { await fs.rm(testRoot, { recursive: true, force: true }).catch(() => {}); diff --git a/server/src/index.ts b/server/src/index.ts index 76cf6914..790a13cc 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -8,33 +8,10 @@ import { WebSocketServer, WebSocket } from 'ws'; import { createServer } from 'http'; import path from 'path'; import { fileURLToPath } from 'url'; -import { taskRoutes } from './routes/tasks.js'; -import { taskCommentRoutes } from './routes/task-comments.js'; -import { taskSubtaskRoutes } from './routes/task-subtasks.js'; -import { taskTimeRoutes } from './routes/task-time.js'; -import { taskArchiveRoutes } from './routes/task-archive.js'; -import { configRoutes } from './routes/config.js'; -import { agentRoutes, agentService } from './routes/agents.js'; -import { diffRoutes } from './routes/diff.js'; -import { automationRoutes } from './routes/automation.js'; -import { summaryRoutes } from './routes/summary.js'; -import { notificationRoutes } from './routes/notifications.js'; -import templateRoutes from './routes/templates.js'; -import taskTypeRoutes from './routes/task-types.js'; -import projectRoutes from './routes/projects.js'; -import sprintRoutes from './routes/sprints.js'; -import activityRoutes from './routes/activity.js'; -import githubRoutes from './routes/github.js'; -import previewRoutes from './routes/preview.js'; -import conflictRoutes from './routes/conflicts.js'; -import telemetryRoutes from './routes/telemetry.js'; -import metricsRoutes from './routes/metrics.js'; -import tracesRoutes from './routes/traces.js'; -import attachmentRoutes from './routes/attachments.js'; -import digestRoutes from './routes/digest.js'; -import { settingsRoutes, syncSettingsToServices } from './routes/settings.js'; -import { agentStatusRoutes, initAgentStatus } from './routes/agent-status.js'; -import { statusHistoryRoutes } from './routes/status-history.js'; +import { v1Router } from './routes/v1/index.js'; +import { agentService } from './routes/agents.js'; +import { syncSettingsToServices } from './routes/settings.js'; +import { initAgentStatus } from './routes/agent-status.js'; import { getTelemetryService } from './services/telemetry-service.js'; import { ConfigService } from './services/config-service.js'; import { initBroadcast } from './services/broadcast-service.js'; @@ -43,11 +20,21 @@ import { errorHandler } from './middleware/error-handler.js'; import { authenticate, authenticateWebSocket, validateWebSocketOrigin, getAuthStatus, type AuthenticatedWebSocket } from './middleware/auth.js'; import authRoutes from './routes/auth.js'; import { apiRateLimit } from './middleware/rate-limit.js'; +import { apiVersionMiddleware } from './middleware/api-version.js'; +import { apiCacheHeaders } from './middleware/cache-control.js'; import type { AgentOutput } from './services/clawdbot-agent-service.js'; const app = express(); const PORT = process.env.PORT || 3001; +// ============================================ +// Performance: ETag Generation +// ============================================ +// Express generates weak ETags for JSON responses by default. +// Explicitly enable for clarity and to support conditional requests +// (If-None-Match → 304 Not Modified). +app.set('etag', 'weak'); + // ============================================ // Security: HTTP Headers (Helmet) // ============================================ @@ -132,7 +119,7 @@ const corsOptions: cors.CorsOptions = { }, credentials: true, methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], - allowedHeaders: ['Content-Type', 'Authorization', 'X-API-Key'], + allowedHeaders: ['Content-Type', 'Authorization', 'X-API-Key', 'X-API-Version'], }; // Middleware @@ -150,52 +137,52 @@ app.get('/health', (_req, res) => { }); // Auth diagnostic endpoint (separate from auth routes) +// Available at both /api/auth/diagnostics and /api/v1/auth/diagnostics app.get('/api/auth/diagnostics', (_req, res) => { res.json(getAuthStatus()); }); +app.get('/api/v1/auth/diagnostics', (_req, res) => { + res.json(getAuthStatus()); +}); // ============================================ // Auth Routes (unauthenticated - for login/setup) +// Available at both /api/auth and /api/v1/auth // ============================================ +app.use('/api/v1/auth', authRoutes); app.use('/api/auth', authRoutes); // ============================================ // Security: Rate Limiting (100 req/min) +// Applies to both /api/* and /api/v1/* (since /api/v1 starts with /api) // ============================================ app.use('/api', apiRateLimit); // Apply authentication to all API routes (except /api/auth which is handled above) app.use('/api', authenticate); -// API Routes - Task routes (split for maintainability) -// Archive and time routes must be mounted before main taskRoutes to handle /archived, /time/summary before /:id -app.use('/api/tasks', taskArchiveRoutes); -app.use('/api/tasks', taskTimeRoutes); -app.use('/api/tasks', taskRoutes); -app.use('/api/tasks', taskCommentRoutes); -app.use('/api/tasks', taskSubtaskRoutes); -app.use('/api/tasks', attachmentRoutes); -app.use('/api/config', configRoutes); -app.use('/api/agents', agentRoutes); -app.use('/api/diff', diffRoutes); -app.use('/api/automation', automationRoutes); -app.use('/api/summary', summaryRoutes); -app.use('/api/notifications', notificationRoutes); -app.use('/api/templates', templateRoutes); -app.use('/api/task-types', taskTypeRoutes); -app.use('/api/projects', projectRoutes); -app.use('/api/sprints', sprintRoutes); -app.use('/api/activity', activityRoutes); -app.use('/api/github', githubRoutes); -app.use('/api/preview', previewRoutes); -app.use('/api/conflicts', conflictRoutes); -app.use('/api/telemetry', telemetryRoutes); -app.use('/api/metrics', metricsRoutes); -app.use('/api/traces', tracesRoutes); -app.use('/api/settings', settingsRoutes); -app.use('/api/agent/status', agentStatusRoutes); -app.use('/api/status-history', statusHistoryRoutes); -app.use('/api/digest', digestRoutes); +// ============================================ +// API Versioning Middleware +// Sets X-API-Version response header and validates requested version +// ============================================ +app.use('/api', apiVersionMiddleware); + +// ============================================ +// Performance: Cache-Control Headers +// ============================================ +// Route-pattern middleware that sets Cache-Control, ETag, and related +// headers for all API responses. See middleware/cache-control.ts for +// profile definitions. Static asset caching is configured separately +// in the express.static() section below. +app.use('/api', apiCacheHeaders); + +// ============================================ +// API Routes — Versioned +// Canonical: /api/v1/... +// Alias: /api/... (backwards-compatible, same handlers) +// ============================================ +app.use('/api/v1', v1Router); +app.use('/api', v1Router); // ============================================ // Static File Serving (Production SPA) @@ -206,9 +193,25 @@ if (process.env.NODE_ENV === 'production') { const __dirname = path.dirname(fileURLToPath(import.meta.url)); const webDistPath = path.resolve(__dirname, '../../web/dist'); - app.use(express.static(webDistPath, { - maxAge: '1d', + // Hashed assets (JS/CSS/images in /assets/) — immutable, 1 year cache + app.use('/assets', express.static(path.join(webDistPath, 'assets'), { + maxAge: '365d', + immutable: true, etag: true, + lastModified: true, + })); + + // All other static files (index.html, favicon, manifest) — always revalidate + app.use(express.static(webDistPath, { + maxAge: 0, + etag: true, + lastModified: true, + setHeaders(res, filePath) { + // index.html must never be cached stale — it references hashed bundles + if (filePath.endsWith('.html')) { + res.set('Cache-Control', 'no-cache'); + } + }, })); // SPA fallback: serve index.html for any non-API route @@ -217,6 +220,7 @@ if (process.env.NODE_ENV === 'production') { if (_req.path.startsWith('/api') || _req.path.startsWith('/ws') || _req.path === '/health') { return next(); } + res.set('Cache-Control', 'no-cache'); res.sendFile(path.join(webDistPath, 'index.html')); }); } diff --git a/server/src/middleware/api-version.ts b/server/src/middleware/api-version.ts new file mode 100644 index 00000000..496ff971 --- /dev/null +++ b/server/src/middleware/api-version.ts @@ -0,0 +1,39 @@ +/** + * API Version Middleware + * + * Responsibilities: + * 1. Sets the `X-API-Version` response header so clients know which version served them. + * 2. Validates the optional `X-API-Version` request header — if a client explicitly + * requests a version that doesn't exist, return 400 early. + * + * Supported versions: v1 (current and default). + * When v2 is introduced, update SUPPORTED_VERSIONS and route accordingly. + */ +import type { Request, Response, NextFunction } from 'express'; + +export const CURRENT_API_VERSION = 'v1'; +export const SUPPORTED_VERSIONS = ['v1']; + +/** + * Middleware that stamps every API response with X-API-Version + * and rejects requests that explicitly ask for an unsupported version. + */ +export function apiVersionMiddleware(req: Request, res: Response, next: NextFunction): void { + // Always tell the client which version is serving the response + res.setHeader('X-API-Version', CURRENT_API_VERSION); + + // If the client explicitly requests a version, validate it + const requestedVersion = req.headers['x-api-version'] as string | undefined; + + if (requestedVersion && !SUPPORTED_VERSIONS.includes(requestedVersion)) { + res.status(400).json({ + error: 'Unsupported API version', + requested: requestedVersion, + supported: SUPPORTED_VERSIONS, + current: CURRENT_API_VERSION, + }); + return; + } + + next(); +} diff --git a/server/src/middleware/cache-control.ts b/server/src/middleware/cache-control.ts new file mode 100644 index 00000000..8e721e64 --- /dev/null +++ b/server/src/middleware/cache-control.ts @@ -0,0 +1,108 @@ +import type { Request, Response, NextFunction } from 'express'; + +/** + * Cache-Control middleware for HTTP responses. + * + * Provides named cache profiles that map to Cache-Control header values, + * plus a route-pattern middleware that applies the correct profile based on + * the request URL. This keeps caching policy centralised and easy to audit. + * + * Profiles: + * static-immutable – Vite hashed assets (1 year, immutable) + * static-html – SPA shell (must revalidate every request) + * task-list – GET /api/tasks (short TTL, private) + * task-detail – GET /api/tasks/:id (moderate TTL, private) + * config – GET /api/config (always revalidate) + * no-store – Mutating responses / sensitive data + */ + +export type CacheProfile = + | 'static-immutable' + | 'static-html' + | 'task-list' + | 'task-detail' + | 'config' + | 'no-store'; + +const CACHE_PROFILES: Record = { + 'static-immutable': 'public, max-age=31536000, immutable', + 'static-html': 'no-cache', + 'task-list': 'private, max-age=10, must-revalidate', + 'task-detail': 'private, max-age=60', + 'config': 'private, no-cache', + 'no-store': 'no-store', +}; + +/** + * Returns middleware that sets Cache-Control for a given profile. + * Only applies to GET/HEAD requests; mutating methods get no-store. + */ +export function cacheControl(profile: CacheProfile) { + const headerValue = CACHE_PROFILES[profile]; + + return (_req: Request, res: Response, next: NextFunction): void => { + if (_req.method === 'GET' || _req.method === 'HEAD') { + res.set('Cache-Control', headerValue); + } else { + res.set('Cache-Control', 'no-store'); + } + next(); + }; +} + +/** + * Route-pattern middleware applied once at the app level. + * Matches the request path against known API patterns and sets the + * appropriate Cache-Control header for GET/HEAD requests. + * + * Non-GET/HEAD requests always receive `no-store`. + */ +export function apiCacheHeaders(req: Request, res: Response, next: NextFunction): void { + // Only cache GET/HEAD; everything else is no-store + if (req.method !== 'GET' && req.method !== 'HEAD') { + res.set('Cache-Control', 'no-store'); + return next(); + } + + const path = req.path; // path relative to the mount point + + // --- Task routes --- + // /api/tasks exactly → task list + if (path === '/tasks' || path === '/tasks/') { + res.set('Cache-Control', CACHE_PROFILES['task-list']); + return next(); + } + + // /api/tasks/:id (single segment after /tasks/) → task detail + // Matches /tasks/task_123 but NOT /tasks/task_123/comments + if (/^\/tasks\/[^/]+\/?$/.test(path)) { + res.set('Cache-Control', CACHE_PROFILES['task-detail']); + return next(); + } + + // --- Config routes --- + if (path.startsWith('/config')) { + res.set('Cache-Control', CACHE_PROFILES['config']); + return next(); + } + + // --- Settings routes --- + if (path.startsWith('/settings')) { + res.set('Cache-Control', CACHE_PROFILES['config']); + return next(); + } + + // --- Default for all other GET API routes: short private cache --- + res.set('Cache-Control', 'private, no-cache'); + next(); +} + +/** + * Sets a Last-Modified header from an ISO date string. + * Call from route handlers: `setLastModified(res, task.updated)`. + */ +export function setLastModified(res: Response, isoDate: string | undefined): void { + if (isoDate) { + res.set('Last-Modified', new Date(isoDate).toUTCString()); + } +} diff --git a/server/src/routes/tasks.ts b/server/src/routes/tasks.ts index 15d98623..61e97603 100644 --- a/server/src/routes/tasks.ts +++ b/server/src/routes/tasks.ts @@ -8,6 +8,7 @@ import type { CreateTaskInput, UpdateTaskInput } from '@veritas-kanban/shared'; import { broadcastTaskChange } from '../services/broadcast-service.js'; import { asyncHandler } from '../middleware/async-handler.js'; import { NotFoundError, ValidationError } from '../middleware/error-handler.js'; +import { setLastModified } from '../middleware/cache-control.js'; const router: RouterType = Router(); const taskService = new TaskService(); @@ -97,6 +98,13 @@ const updateTaskSchema = z.object({ // GET /api/tasks - List all tasks router.get('/', asyncHandler(async (_req, res) => { const tasks = await taskService.listTasks(); + // Last-Modified = most recently updated task + if (tasks.length > 0) { + const newest = tasks.reduce((a, b) => + new Date(a.updated || a.created) > new Date(b.updated || b.created) ? a : b + ); + setLastModified(res, newest.updated || newest.created); + } res.json(tasks); })); @@ -117,6 +125,7 @@ router.get('/:id', asyncHandler(async (req, res) => { if (!task) { throw new NotFoundError('Task not found'); } + setLastModified(res, task.updated || task.created); res.json(task); })); diff --git a/server/src/routes/v1/index.ts b/server/src/routes/v1/index.ts new file mode 100644 index 00000000..d0319c12 --- /dev/null +++ b/server/src/routes/v1/index.ts @@ -0,0 +1,77 @@ +/** + * API v1 Router + * + * Aggregates all route modules into a single Express Router. + * This router is mounted at both `/api/v1` (canonical) and `/api` (backwards-compatible alias). + * + * Route ordering matters: + * - Archive and time routes MUST come before main taskRoutes so that + * /archived and /time/summary are matched before the /:id param. + */ +import { Router } from 'express'; + +// Task routes (order-sensitive — see note above) +import { taskArchiveRoutes } from '../task-archive.js'; +import { taskTimeRoutes } from '../task-time.js'; +import { taskRoutes } from '../tasks.js'; +import { taskCommentRoutes } from '../task-comments.js'; +import { taskSubtaskRoutes } from '../task-subtasks.js'; +import attachmentRoutes from '../attachments.js'; + +// Feature routes +import { configRoutes } from '../config.js'; +import { agentRoutes } from '../agents.js'; +import { diffRoutes } from '../diff.js'; +import { automationRoutes } from '../automation.js'; +import { summaryRoutes } from '../summary.js'; +import { notificationRoutes } from '../notifications.js'; +import templateRoutes from '../templates.js'; +import taskTypeRoutes from '../task-types.js'; +import projectRoutes from '../projects.js'; +import sprintRoutes from '../sprints.js'; +import activityRoutes from '../activity.js'; +import githubRoutes from '../github.js'; +import previewRoutes from '../preview.js'; +import conflictRoutes from '../conflicts.js'; +import telemetryRoutes from '../telemetry.js'; +import metricsRoutes from '../metrics.js'; +import tracesRoutes from '../traces.js'; +import { settingsRoutes } from '../settings.js'; +import { agentStatusRoutes } from '../agent-status.js'; +import { statusHistoryRoutes } from '../status-history.js'; +import digestRoutes from '../digest.js'; + +const v1Router = Router(); + +// ── Task routes (order-sensitive) ──────────────────────────── +v1Router.use('/tasks', taskArchiveRoutes); +v1Router.use('/tasks', taskTimeRoutes); +v1Router.use('/tasks', taskRoutes); +v1Router.use('/tasks', taskCommentRoutes); +v1Router.use('/tasks', taskSubtaskRoutes); +v1Router.use('/tasks', attachmentRoutes); + +// ── Feature routes ─────────────────────────────────────────── +v1Router.use('/config', configRoutes); +v1Router.use('/agents', agentRoutes); +v1Router.use('/diff', diffRoutes); +v1Router.use('/automation', automationRoutes); +v1Router.use('/summary', summaryRoutes); +v1Router.use('/notifications', notificationRoutes); +v1Router.use('/templates', templateRoutes); +v1Router.use('/task-types', taskTypeRoutes); +v1Router.use('/projects', projectRoutes); +v1Router.use('/sprints', sprintRoutes); +v1Router.use('/activity', activityRoutes); +v1Router.use('/github', githubRoutes); +v1Router.use('/preview', previewRoutes); +v1Router.use('/conflicts', conflictRoutes); +v1Router.use('/telemetry', telemetryRoutes); +v1Router.use('/metrics', metricsRoutes); +v1Router.use('/traces', tracesRoutes); +v1Router.use('/settings', settingsRoutes); +v1Router.use('/agent/status', agentStatusRoutes); +v1Router.use('/status-history', statusHistoryRoutes); +v1Router.use('/digest', digestRoutes); + +export { v1Router }; diff --git a/server/src/services/task-service.ts b/server/src/services/task-service.ts index c761d18c..2486c95d 100644 --- a/server/src/services/task-service.ts +++ b/server/src/services/task-service.ts @@ -1,4 +1,5 @@ import fs from 'fs/promises'; +import { watch, type FSWatcher } from 'fs'; import path from 'path'; import matter from 'gray-matter'; import { nanoid } from 'nanoid'; @@ -37,11 +38,22 @@ export interface TaskServiceOptions { telemetryService?: TelemetryService; } +/** Ignore file-watcher events within this window after our own writes */ +const WRITE_DEBOUNCE_MS = 200; + export class TaskService { private tasksDir: string; private archiveDir: string; private telemetry: TelemetryService; + // ============ In-Memory Cache ============ + private cache: Map = new Map(); + private cacheInitialized = false; + private cacheLoading: Promise | null = null; + private watcher: FSWatcher | null = null; + private lastWriteTime = 0; + private cacheStats = { hits: 0, misses: 0 }; + constructor(options: TaskServiceOptions = {}) { this.tasksDir = options.tasksDir || DEFAULT_TASKS_DIR; this.archiveDir = options.archiveDir || DEFAULT_ARCHIVE_DIR; @@ -49,6 +61,144 @@ export class TaskService { this.ensureDirectories(); } + // ============ Cache Helpers ============ + + /** + * Initialize the cache by loading all tasks from disk and starting the file watcher. + * Safe to call multiple times; only the first call does work. + */ + private async initCache(): Promise { + if (this.cacheInitialized) return; + + // Prevent concurrent initialization (e.g. parallel listTasks + getTask) + if (this.cacheLoading) { + await this.cacheLoading; + return; + } + + this.cacheLoading = this.loadCacheFromDisk(); + await this.cacheLoading; + this.cacheLoading = null; + this.cacheInitialized = true; + this.startWatcher(); + console.debug(`[TaskCache] Initialized with ${this.cache.size} tasks`); + } + + /** Read every .md file in tasksDir and populate the cache */ + private async loadCacheFromDisk(): Promise { + await this.ensureDirectories(); + const files = await fs.readdir(this.tasksDir); + const mdFiles = files.filter(f => f.endsWith('.md')); + + this.cache.clear(); + await Promise.all( + mdFiles.map(async (filename) => { + const filepath = path.join(this.tasksDir, filename); + const content = await fs.readFile(filepath, 'utf-8'); + const task = this.parseTaskFile(content, filename); + if (task) { + this.cache.set(task.id, task); + } + }), + ); + } + + /** Reload a single file from disk into the cache */ + private async reloadFile(filename: string): Promise { + const filepath = path.join(this.tasksDir, filename); + try { + const content = await fs.readFile(filepath, 'utf-8'); + const task = this.parseTaskFile(content, filename); + if (task) { + console.debug(`[TaskCache] Reloaded ${task.id} from disk`); + this.cache.set(task.id, task); + } + } catch { + // File was deleted — find and remove matching cache entry + this.invalidateByFilename(filename); + } + } + + /** Remove a cache entry whose filename matches (used when a file is deleted externally) */ + private invalidateByFilename(filename: string): void { + // Task IDs are the first segment of the filename (before the slug) + const idMatch = filename.match(/^(task_[a-zA-Z0-9_-]+)-/); + if (idMatch) { + const id = idMatch[1]; + if (this.cache.delete(id)) { + console.debug(`[TaskCache] Invalidated ${id} (file removed)`); + } + } + } + + /** Invalidate a specific task by ID */ + private cacheInvalidate(id: string): boolean { + const deleted = this.cache.delete(id); + if (deleted) { + console.debug(`[TaskCache] Invalidated ${id}`); + } + return deleted; + } + + /** Get a task from the cache */ + private cacheGet(id: string): Task | undefined { + const task = this.cache.get(id); + if (task) { + this.cacheStats.hits++; + console.debug(`[TaskCache] HIT ${id} (hits=${this.cacheStats.hits})`); + } else { + this.cacheStats.misses++; + console.debug(`[TaskCache] MISS ${id} (misses=${this.cacheStats.misses})`); + } + return task; + } + + /** Get all cached tasks sorted by updated date descending */ + private cacheList(): Task[] { + const tasks = Array.from(this.cache.values()); + return tasks.sort( + (a, b) => new Date(b.updated).getTime() - new Date(a.updated).getTime(), + ); + } + + /** Record that we are about to write — suppresses watcher for WRITE_DEBOUNCE_MS */ + private markWrite(): void { + this.lastWriteTime = Date.now(); + } + + /** Start watching tasksDir for external file changes */ + private startWatcher(): void { + try { + this.watcher = watch(this.tasksDir, (eventType, filename) => { + if (!filename || !filename.endsWith('.md')) return; + + // Ignore events caused by our own writes + if (Date.now() - this.lastWriteTime < WRITE_DEBOUNCE_MS) return; + + console.debug(`[TaskCache] File change detected: ${eventType} ${filename}`); + // Re-read the changed file (or remove from cache if deleted) + this.reloadFile(filename).catch(err => + console.error(`[TaskCache] Error reloading ${filename}:`, err), + ); + }); + } catch (err) { + // fs.watch can fail on some platforms or when dir doesn't exist yet + console.warn('[TaskCache] Could not start file watcher:', err); + } + } + + /** Clean up watchers and cache. Call on server shutdown. */ + dispose(): void { + if (this.watcher) { + this.watcher.close(); + this.watcher = null; + } + this.cache.clear(); + this.cacheInitialized = false; + this.cacheLoading = null; + console.debug(`[TaskCache] Disposed (final stats: hits=${this.cacheStats.hits}, misses=${this.cacheStats.misses})`); + } + private async ensureDirectories(): Promise { await fs.mkdir(this.tasksDir, { recursive: true }); await fs.mkdir(this.archiveDir, { recursive: true }); @@ -156,26 +306,8 @@ export class TaskService { } async listTasks(): Promise { - await this.ensureDirectories(); - - const files = await fs.readdir(this.tasksDir); - const mdFiles = files.filter(f => f.endsWith('.md')); - - const results = await Promise.all( - mdFiles.map(async (filename) => { - const filepath = path.join(this.tasksDir, filename); - const content = await fs.readFile(filepath, 'utf-8'); - return this.parseTaskFile(content, filename); - }) - ); - - // Filter out null values from failed parses - const tasks = results.filter((t): t is Task => t !== null); - - // Sort by updated date, newest first - return tasks.sort((a: Task, b: Task) => - new Date(b.updated).getTime() - new Date(a.updated).getTime() - ); + await this.initCache(); + return this.cacheList(); } /** @@ -208,8 +340,8 @@ export class TaskService { } async getTask(id: string): Promise { - const tasks = await this.listTasks(); - return tasks.find(t => t.id === id) || null; + await this.initCache(); + return this.cacheGet(id) ?? null; } async createTask(input: CreateTaskInput): Promise { @@ -234,8 +366,12 @@ export class TaskService { const filepath = path.join(this.tasksDir, filename); const content = this.taskToMarkdown(task); + this.markWrite(); await fs.writeFile(filepath, content, 'utf-8'); + // Write-through: update cache immediately + this.cache.set(task.id, task); + // Emit telemetry event await this.telemetry.emit({ type: 'task.created', @@ -271,6 +407,7 @@ export class TaskService { const oldFilename = this.taskToFilename(task); const newFilename = this.taskToFilename(updatedTask); + this.markWrite(); if (oldFilename !== newFilename) { await fs.unlink(path.join(this.tasksDir, oldFilename)).catch(() => {}); } @@ -280,6 +417,9 @@ export class TaskService { await fs.writeFile(filepath, content, 'utf-8'); + // Write-through: update cache immediately + this.cache.set(updatedTask.id, updatedTask); + // Emit telemetry event if status changed if (statusChanged) { await this.telemetry.emit({ @@ -299,8 +439,12 @@ export class TaskService { if (!task) return false; const filename = this.taskToFilename(task); + this.markWrite(); await fs.unlink(path.join(this.tasksDir, filename)); + // Remove from cache + this.cacheInvalidate(id); + // Delete attachments const { getAttachmentService } = await import('./attachment-service.js'); const attachmentService = getAttachmentService(); @@ -317,8 +461,12 @@ export class TaskService { const sourcePath = path.join(this.tasksDir, filename); const destPath = path.join(this.archiveDir, filename); + this.markWrite(); await fs.rename(sourcePath, destPath); + // Remove from active cache (archived tasks are not cached) + this.cacheInvalidate(id); + // Move attachments to archive const { getAttachmentService } = await import('./attachment-service.js'); const attachmentService = getAttachmentService(); @@ -387,8 +535,12 @@ export class TaskService { }; const content = this.taskToMarkdown(restoredTask); + this.markWrite(); await fs.writeFile(destPath, content, 'utf-8'); + // Write-through: add restored task to active cache + this.cache.set(restoredTask.id, restoredTask); + // Emit telemetry event await this.telemetry.emit({ type: 'task.restored', @@ -657,3 +809,11 @@ export function getTaskService(): TaskService { } return taskServiceInstance; } + +/** Dispose and reset the singleton (useful for tests and shutdown) */ +export function disposeTaskService(): void { + if (taskServiceInstance) { + taskServiceInstance.dispose(); + taskServiceInstance = null; + } +} diff --git a/web/src/components/task/TaskCard.tsx b/web/src/components/task/TaskCard.tsx index d54d884e..be4e0f27 100644 --- a/web/src/components/task/TaskCard.tsx +++ b/web/src/components/task/TaskCard.tsx @@ -45,6 +45,74 @@ interface TaskCardProps { cardMetrics?: TaskCardMetrics; } +/** + * Custom comparison for React.memo to prevent unnecessary re-renders. + * The default shallow comparison fails because parent renders create new + * object/array/function references for onClick, cardMetrics, blockerTitles, + * and the task object itself (from React Query refetches). + */ +function areTaskCardPropsEqual(prev: TaskCardProps, next: TaskCardProps): boolean { + // Simple scalar/boolean props + if (prev.isDragging !== next.isDragging) return false; + if (prev.isSelected !== next.isSelected) return false; + if (prev.isBlocked !== next.isBlocked) return false; + // onClick is intentionally skipped — always a new closure but functionally equivalent + + // Task object — compare fields that affect rendering rather than reference + const pt = prev.task; + const nt = next.task; + if (pt !== nt) { + if (pt.id !== nt.id) return false; + if (pt.title !== nt.title) return false; + if (pt.description !== nt.description) return false; + if (pt.status !== nt.status) return false; + if (pt.priority !== nt.priority) return false; + if (pt.type !== nt.type) return false; + if (pt.project !== nt.project) return false; + if (pt.sprint !== nt.sprint) return false; + if (pt.timeTracking?.totalSeconds !== nt.timeTracking?.totalSeconds) return false; + if (pt.timeTracking?.isRunning !== nt.timeTracking?.isRunning) return false; + if (pt.attempt?.status !== nt.attempt?.status) return false; + if (pt.attempt?.agent !== nt.attempt?.agent) return false; + if (pt.blockedReason?.category !== nt.blockedReason?.category) return false; + if (pt.blockedReason?.note !== nt.blockedReason?.note) return false; + // Subtasks — compare count and completion state + const pSubs = pt.subtasks || []; + const nSubs = nt.subtasks || []; + if (pSubs.length !== nSubs.length) return false; + for (let i = 0; i < pSubs.length; i++) { + if (pSubs[i].completed !== nSubs[i].completed) return false; + } + // Attachments — only count matters for the badge + if ((pt.attachments?.length || 0) !== (nt.attachments?.length || 0)) return false; + } + + // blockerTitles — compare array values + const pBlockers = prev.blockerTitles; + const nBlockers = next.blockerTitles; + if (pBlockers !== nBlockers) { + if (!pBlockers || !nBlockers) return false; + if (pBlockers.length !== nBlockers.length) return false; + for (let i = 0; i < pBlockers.length; i++) { + if (pBlockers[i] !== nBlockers[i]) return false; + } + } + + // cardMetrics — compare individual scalar fields + const pm = prev.cardMetrics; + const nm = next.cardMetrics; + if (pm !== nm) { + if (!pm || !nm) return false; + if (pm.totalRuns !== nm.totalRuns) return false; + if (pm.successfulRuns !== nm.successfulRuns) return false; + if (pm.failedRuns !== nm.failedRuns) return false; + if (pm.lastRunSuccess !== nm.lastRunSuccess) return false; + if (pm.totalDurationMs !== nm.totalDurationMs) return false; + } + + return true; +} + const priorityColors: Record = { high: 'bg-red-500/20 text-red-400', medium: 'bg-amber-500/20 text-amber-400', @@ -369,4 +437,4 @@ export const TaskCard = memo(function TaskCard({ task, isDragging, onClick, isSe ); -}); +}, areTaskCardPropsEqual);