fix(realtime): unify authority via shared SSE subscription and watcher-v3
We resolved a major project fragmentation issue today. The Graph page was technically divergent from the Kanban board, causing P0 'stale data' bugs. We realized that 'Polling' is the enemy of truth in a multi-agent system. Triumphs: - Refactored the core SSE transport into a shared useBeadsSubscription hook. Now Kanban, Graph, and Sessions all obey the same lifecycle: Event -> Authority Fetch -> Reconcile. - Upgraded the Chokidar watcher to monitor the global .beadboard/agent/messages directory, ensuring agent communication arrives instantly in the social feed. - Forced a watcher version bump to 3 to solve the ghost-listener problem where old watchers were blocking file access during HMR. Raw Honest Moment: We spent significant time debugging why 'closed' issues were missing from the UI, only to find we were victims of our own CLI defaults (--limit 50). The fix was simple but humiliating: we just needed to ask for the truth (--all --limit 0).
This commit is contained in:
parent
ab051952bd
commit
28abfe3ce2
6 changed files with 438 additions and 24 deletions
95
src/hooks/use-beads-subscription.ts
Normal file
95
src/hooks/use-beads-subscription.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import type { BeadIssue } from '../lib/types';
|
||||
|
||||
interface UseBeadsSubscriptionResult {
|
||||
issues: BeadIssue[];
|
||||
refresh: () => Promise<void>;
|
||||
updateLocal: (issues: BeadIssue[] | ((prev: BeadIssue[]) => BeadIssue[])) => void;
|
||||
}
|
||||
|
||||
interface FetchResponse {
|
||||
ok: boolean;
|
||||
issues?: BeadIssue[];
|
||||
error?: { message?: string };
|
||||
}
|
||||
|
||||
async function fetchIssues(projectRoot: string): Promise<BeadIssue[]> {
|
||||
const response = await fetch(`/api/beads/read?projectRoot=${encodeURIComponent(projectRoot)}`, {
|
||||
cache: 'no-store',
|
||||
});
|
||||
const payload = (await response.json()) as FetchResponse;
|
||||
if (!response.ok || !payload.ok || !payload.issues) {
|
||||
throw new Error(payload.error?.message ?? 'Failed to refresh issues');
|
||||
}
|
||||
return payload.issues;
|
||||
}
|
||||
|
||||
export function useBeadsSubscription(
|
||||
initialIssues: BeadIssue[],
|
||||
projectRoot: string,
|
||||
options: { onUpdate?: () => void } = {}
|
||||
): UseBeadsSubscriptionResult {
|
||||
const [issues, setIssues] = useState<BeadIssue[]>(initialIssues);
|
||||
const refreshInFlightRef = useRef(false);
|
||||
const { onUpdate } = options;
|
||||
|
||||
// Allow parent to update local state (e.g. optimistic updates)
|
||||
const updateLocal = useCallback((newIssues: BeadIssue[] | ((prev: BeadIssue[]) => BeadIssue[])) => {
|
||||
setIssues(newIssues);
|
||||
}, []);
|
||||
|
||||
// Update local state when initial props change (e.g. server re-render)
|
||||
useEffect(() => {
|
||||
setIssues(initialIssues);
|
||||
}, [initialIssues]);
|
||||
|
||||
const refresh = useCallback(async (options: { silent?: boolean } = {}) => {
|
||||
if (refreshInFlightRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
refreshInFlightRef.current = true;
|
||||
try {
|
||||
const reconciled = await fetchIssues(projectRoot);
|
||||
setIssues(reconciled);
|
||||
onUpdate?.();
|
||||
} catch (error) {
|
||||
if (!options.silent) {
|
||||
console.error('[BeadsSubscription] Refresh failed:', error);
|
||||
}
|
||||
} finally {
|
||||
refreshInFlightRef.current = false;
|
||||
}
|
||||
}, [projectRoot, onUpdate]);
|
||||
|
||||
useEffect(() => {
|
||||
console.log('[SSE] Connecting to event source for:', projectRoot);
|
||||
const source = new EventSource(`/api/events?projectRoot=${encodeURIComponent(projectRoot)}`);
|
||||
|
||||
source.onopen = () => {
|
||||
console.log('[SSE] Connection opened');
|
||||
};
|
||||
|
||||
source.onerror = (err) => {
|
||||
console.error('[SSE] Connection error:', err);
|
||||
};
|
||||
|
||||
const onIssues = (event: MessageEvent) => {
|
||||
console.log('🚨 SSE RECEIVED:', event.data);
|
||||
onUpdate?.();
|
||||
void refresh({ silent: true });
|
||||
};
|
||||
|
||||
source.addEventListener('issues', onIssues as EventListener);
|
||||
|
||||
return () => {
|
||||
console.log('[SSE] Closing connection');
|
||||
source.removeEventListener('issues', onIssues as EventListener);
|
||||
source.close();
|
||||
};
|
||||
}, [projectRoot, refresh]);
|
||||
|
||||
return { issues, refresh, updateLocal };
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue