mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-10-08 12:47:51 +00:00
perf: add in-memory task caching with file watchers
This commit is contained in:
parent
7d3a9c404c
commit
99cd20c5a4
10 changed files with 726 additions and 82 deletions
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(() => {});
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
});
|
||||
}
|
||||
|
|
|
|||
39
server/src/middleware/api-version.ts
Normal file
39
server/src/middleware/api-version.ts
Normal 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();
|
||||
}
|
||||
108
server/src/middleware/cache-control.ts
Normal file
108
server/src/middleware/cache-control.ts
Normal 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}));
|
||||
|
||||
|
|
|
|||
77
server/src/routes/v1/index.ts
Normal file
77
server/src/routes/v1/index.ts
Normal 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 };
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue