perf: add in-memory task caching with file watchers

This commit is contained in:
Brad Groux 2026-01-28 12:18:41 -06:00
parent 7d3a9c404c
commit 99cd20c5a4
10 changed files with 726 additions and 82 deletions

View file

@ -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"}

View file

@ -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",

View file

@ -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(() => {});

View file

@ -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'));
});
}

View file

@ -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();
}

View file

@ -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<CacheProfile, string> = {
'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());
}
}

View file

@ -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);
}));

View file

@ -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 };

View file

@ -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<string, Task> = new Map();
private cacheInitialized = false;
private cacheLoading: Promise<void> | 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<void> {
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<void> {
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<void> {
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<void> {
await fs.mkdir(this.tasksDir, { recursive: true });
await fs.mkdir(this.archiveDir, { recursive: true });
@ -156,26 +306,8 @@ export class TaskService {
}
async listTasks(): Promise<Task[]> {
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<Task | null> {
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<Task> {
@ -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<TaskTelemetryEvent>({
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<TaskTelemetryEvent>({
@ -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<TaskTelemetryEvent>({
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;
}
}

View file

@ -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<TaskPriority, string> = {
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
</Tooltip>
</TooltipProvider>
);
});
}, areTaskCardPropsEqual);