diff --git a/.veritas-kanban/telemetry/events-2026-01-28.ndjson b/.veritas-kanban/telemetry/events-2026-01-28.ndjson index d2e2acd3..402cfdd6 100644 --- a/.veritas-kanban/telemetry/events-2026-01-28.ndjson +++ b/.veritas-kanban/telemetry/events-2026-01-28.ndjson @@ -330,3 +330,6 @@ {"type":"task.status_changed","taskId":"task_20260128_tDh8SO","project":"veritas-kanban","status":"in-progress","previousStatus":"todo","id":"evt_oTg72ClmUegb","timestamp":"2026-01-28T07:11:17.460Z"} {"type":"task.restored","taskId":"task_20260128_cllk71","project":"veritas-kanban","status":"done","id":"evt_4fcYqxmjTMxb","timestamp":"2026-01-28T07:12:02.508Z"} {"type":"task.archived","taskId":"task_20260128_cllk71","project":"veritas-kanban","status":"done","id":"evt_BiEHiWRStoBu","timestamp":"2026-01-28T07:12:05.263Z"} +{"type":"task.status_changed","taskId":"task_20260128_tDh8SO","project":"veritas-kanban","status":"done","previousStatus":"in-progress","id":"evt_aL7-lVvh-icH","timestamp":"2026-01-28T07:15:45.862Z"} +{"type":"task.status_changed","taskId":"task_20260128_Zk4oV0","project":"veritas-kanban","status":"in-progress","previousStatus":"todo","id":"evt_moXq5uwb9F_S","timestamp":"2026-01-28T07:16:02.124Z"} +{"type":"task.created","taskId":"task_20260128_MnEoHV","status":"todo","id":"evt_w_L_RFmqhIOl","timestamp":"2026-01-28T07:20:14.943Z"} diff --git a/server/.veritas-kanban/activity.json b/server/.veritas-kanban/activity.json index 4e2971c5..2466aa13 100644 --- a/server/.veritas-kanban/activity.json +++ b/server/.veritas-kanban/activity.json @@ -1,4 +1,55 @@ [ + { + "id": "activity_1769584844268_0af9y7c7k", + "type": "task_deleted", + "taskId": "task_20260128_MnEoHV", + "taskTitle": "TEST: Real-time sync verification", + "timestamp": "2026-01-28T07:20:44.269Z" + }, + { + "id": "activity_1769584814944_rj5pnt66t", + "type": "task_created", + "taskId": "task_20260128_MnEoHV", + "taskTitle": "TEST: Real-time sync verification", + "details": { + "type": "code", + "priority": "low" + }, + "timestamp": "2026-01-28T07:20:14.944Z" + }, + { + "id": "activity_1769584562124_j4txg8me8", + "type": "status_changed", + "taskId": "task_20260128_Zk4oV0", + "taskTitle": "BUG: Kanban requires refresh to see progress", + "details": { + "from": "todo", + "status": "in-progress" + }, + "timestamp": "2026-01-28T07:16:02.124Z" + }, + { + "id": "activity_1769584550594_xx8avtepj", + "type": "comment_added", + "taskId": "task_20260128_tDh8SO", + "taskTitle": "BUG: You can't view or restore archived tasks", + "details": { + "author": "Veritas", + "preview": "Added read-only view for archived tasks (click to ..." + }, + "timestamp": "2026-01-28T07:15:50.594Z" + }, + { + "id": "activity_1769584545862_qjr4r5px4", + "type": "status_changed", + "taskId": "task_20260128_tDh8SO", + "taskTitle": "BUG: You can't view or restore archived tasks", + "details": { + "from": "in-progress", + "status": "done" + }, + "timestamp": "2026-01-28T07:15:45.862Z" + }, { "id": "activity_1769584325263_iv5d040ns", "type": "task_archived", @@ -1007,61 +1058,5 @@ "project": "veritas-kanban" }, "timestamp": "2026-01-28T06:52:50.296Z" - }, - { - "id": "activity_1769583170269_4se7u3s82", - "type": "task_created", - "taskId": "task_20260128_Sujk2d", - "taskTitle": "US-401: Diff viewer component", - "details": { - "type": "code", - "priority": "high", - "project": "veritas-kanban" - }, - "timestamp": "2026-01-28T06:52:50.269Z" - }, - { - "id": "activity_1769583158455_7r6vxey60", - "type": "comment_added", - "taskId": "task_20260128_qImfEu", - "taskTitle": "US-307: Agent completion handling", - "details": { - "author": "Veritas", - "preview": "Historical backfill — completed as part of Sprint ..." - }, - "timestamp": "2026-01-28T06:52:38.455Z" - }, - { - "id": "activity_1769583158445_7q2a7h0tm", - "type": "status_changed", - "taskId": "task_20260128_qImfEu", - "taskTitle": "US-307: Agent completion handling", - "details": { - "from": "todo", - "status": "done" - }, - "timestamp": "2026-01-28T06:52:38.445Z" - }, - { - "id": "activity_1769583158435_9sbnapa4g", - "type": "comment_added", - "taskId": "task_20260128_8De44X", - "taskTitle": "US-306: Stop agent", - "details": { - "author": "Veritas", - "preview": "Historical backfill — completed as part of Sprint ..." - }, - "timestamp": "2026-01-28T06:52:38.435Z" - }, - { - "id": "activity_1769583158426_jcsu1g66m", - "type": "status_changed", - "taskId": "task_20260128_8De44X", - "taskTitle": "US-306: Stop agent", - "details": { - "from": "todo", - "status": "done" - }, - "timestamp": "2026-01-28T06:52:38.426Z" } ] \ No newline at end of file diff --git a/server/src/index.ts b/server/src/index.ts index dc0de4b8..951df37a 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -22,6 +22,7 @@ import metricsRoutes from './routes/metrics.js'; import tracesRoutes from './routes/traces.js'; import attachmentRoutes from './routes/attachments.js'; import { getTelemetryService } from './services/telemetry-service.js'; +import { initBroadcast } from './services/broadcast-service.js'; import type { AgentOutput } from './services/agent-service.js'; const app = express(); @@ -68,6 +69,9 @@ const server = createServer(app); // WebSocket server for real-time updates const wss = new WebSocketServer({ server, path: '/ws' }); +// Initialize broadcast service for task change notifications +initBroadcast(wss); + // Track subscriptions: taskId -> Set of WebSocket clients const agentSubscriptions = new Map>(); diff --git a/server/src/routes/tasks.ts b/server/src/routes/tasks.ts index c9b31e7b..a713fed2 100644 --- a/server/src/routes/tasks.ts +++ b/server/src/routes/tasks.ts @@ -4,6 +4,7 @@ import { TaskService } from '../services/task-service.js'; import { WorktreeService } from '../services/worktree-service.js'; import { activityService } from '../services/activity-service.js'; import type { CreateTaskInput, UpdateTaskInput } from '@veritas-kanban/shared'; +import { broadcastTaskChange } from '../services/broadcast-service.js'; const router: RouterType = Router(); const taskService = new TaskService(); @@ -139,6 +140,7 @@ router.post('/reorder', async (req, res) => { return res.status(400).json({ error: 'orderedIds must be a non-empty array of task IDs' }); } const updated = await taskService.reorderTasks(orderedIds); + broadcastTaskChange('reordered'); res.json({ updated: updated.length }); } catch (error) { console.error('Error reordering tasks:', error); @@ -203,6 +205,7 @@ router.post('/', async (req, res) => { try { const input = createTaskSchema.parse(req.body) as CreateTaskInput; const task = await taskService.createTask(input); + broadcastTaskChange('created', task.id); // Log activity await activityService.logActivity('task_created', task.id, task.title, { @@ -248,6 +251,7 @@ router.patch('/:id', async (req, res) => { if (!task) { return res.status(404).json({ error: 'Task not found' }); } + broadcastTaskChange('updated', task.id); // Log activity for status changes if (input.status && oldTask.status !== input.status) { @@ -277,6 +281,7 @@ router.delete('/:id', async (req, res) => { if (!success) { return res.status(404).json({ error: 'Task not found' }); } + broadcastTaskChange('deleted', req.params.id); // Log activity if (task) { @@ -298,6 +303,7 @@ router.post('/:id/archive', async (req, res) => { if (!success) { return res.status(404).json({ error: 'Task not found' }); } + broadcastTaskChange('archived', req.params.id); // Log activity if (task) { @@ -349,6 +355,7 @@ router.post('/:id/restore', async (req, res) => { if (!task) { return res.status(404).json({ error: 'Archived task not found' }); } + broadcastTaskChange('restored', task.id); // Log activity await activityService.logActivity('status_changed', task.id, task.title, { diff --git a/server/src/services/broadcast-service.ts b/server/src/services/broadcast-service.ts new file mode 100644 index 00000000..bccc7117 --- /dev/null +++ b/server/src/services/broadcast-service.ts @@ -0,0 +1,43 @@ +import type { WebSocketServer, WebSocket } from 'ws'; + +/** + * Simple broadcast service that sends task change events to all connected WebSocket clients. + * Initialized with the WebSocketServer instance from index.ts. + */ +let wssRef: WebSocketServer | null = null; + +export function initBroadcast(wss: WebSocketServer): void { + wssRef = wss; +} + +export type TaskChangeType = 'created' | 'updated' | 'deleted' | 'archived' | 'restored' | 'reordered'; + +export interface TaskChangeEvent { + type: 'task:changed'; + changeType: TaskChangeType; + taskId?: string; + timestamp: string; +} + +/** + * Broadcast a task change to all connected WebSocket clients. + * Clients can listen for 'task:changed' messages and invalidate their query caches. + */ +export function broadcastTaskChange(changeType: TaskChangeType, taskId?: string): void { + if (!wssRef) return; + + const message: TaskChangeEvent = { + type: 'task:changed', + changeType, + taskId, + timestamp: new Date().toISOString(), + }; + + const payload = JSON.stringify(message); + + wssRef.clients.forEach((client: WebSocket) => { + if (client.readyState === 1) { // WebSocket.OPEN = 1 + client.send(payload); + } + }); +} diff --git a/web/src/App.tsx b/web/src/App.tsx index a378b3cb..386f8add 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -4,8 +4,12 @@ import { Toaster } from './components/ui/toaster'; import { KeyboardProvider } from './hooks/useKeyboard'; import { KeyboardShortcutsDialog } from './components/layout/KeyboardShortcutsDialog'; import { BulkActionsProvider } from './hooks/useBulkActions'; +import { useTaskSync } from './hooks/useTaskSync'; function App() { + // Connect to WebSocket for real-time task updates + useTaskSync(); + return ( diff --git a/web/src/hooks/useTaskSync.ts b/web/src/hooks/useTaskSync.ts new file mode 100644 index 00000000..fe9cc788 --- /dev/null +++ b/web/src/hooks/useTaskSync.ts @@ -0,0 +1,85 @@ +import { useEffect, useRef } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; + +/** + * Connects to the Veritas Kanban WebSocket server and listens for + * task:changed events. When received, invalidates the React Query + * task cache so the board updates in real-time. + */ +export function useTaskSync() { + const queryClient = useQueryClient(); + const wsRef = useRef(null); + const reconnectTimeoutRef = useRef(null); + + useEffect(() => { + let mounted = true; + + function connect() { + if (!mounted) return; + + // Determine WebSocket URL from current location + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + // In dev (port 3000/5173), the API is on port 3001; in prod it's same host + const isDev = ['3000', '5173'].includes(window.location.port); + const wsHost = isDev + ? `${protocol}//localhost:3001/ws` + : `${protocol}//${window.location.host}/ws`; + + const ws = new WebSocket(wsHost); + wsRef.current = ws; + + ws.onopen = () => { + // Send a subscribe message for task changes + ws.send(JSON.stringify({ type: 'subscribe:tasks' })); + }; + + ws.onmessage = (event) => { + try { + const message = JSON.parse(event.data); + + if (message.type === 'task:changed') { + // Invalidate task queries to trigger a refetch + queryClient.invalidateQueries({ queryKey: ['tasks'] }); + + // Also invalidate specific task if we know which one + if (message.taskId) { + queryClient.invalidateQueries({ queryKey: ['tasks', message.taskId] }); + } + + // If it's an archive-related change, also invalidate archive queries + if (message.changeType === 'archived' || message.changeType === 'restored') { + queryClient.invalidateQueries({ queryKey: ['tasks', 'archived'] }); + queryClient.invalidateQueries({ queryKey: ['tasks', 'archive-suggestions'] }); + } + } + } catch { + // Ignore parse errors for non-task messages + } + }; + + ws.onclose = () => { + wsRef.current = null; + // Reconnect after 3 seconds + if (mounted) { + reconnectTimeoutRef.current = window.setTimeout(connect, 3000); + } + }; + + ws.onerror = () => { + ws.close(); + }; + } + + connect(); + + return () => { + mounted = false; + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + } + if (wsRef.current) { + wsRef.current.close(); + } + }; + }, [queryClient]); +} diff --git a/web/src/hooks/useTasks.ts b/web/src/hooks/useTasks.ts index 2134cf84..a6a60d7d 100644 --- a/web/src/hooks/useTasks.ts +++ b/web/src/hooks/useTasks.ts @@ -6,6 +6,8 @@ export function useTasks() { return useQuery({ queryKey: ['tasks'], queryFn: api.tasks.list, + refetchInterval: 10000, // Poll every 10s as fallback (WebSocket handles instant updates) + staleTime: 5000, // Consider data stale after 5s }); }