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
|
|
@ -1,4 +1,5 @@
|
|||
import { canonicalizeWindowsPath, windowsPathKey } from './pathing';
|
||||
import type { ActivityEvent } from './activity';
|
||||
|
||||
export type IssuesChangeKind = 'changed' | 'renamed';
|
||||
|
||||
|
|
@ -10,11 +11,21 @@ export interface IssuesChangedEvent {
|
|||
at: string;
|
||||
}
|
||||
|
||||
export interface ActivityDispatchedEvent {
|
||||
id: number;
|
||||
event: ActivityEvent;
|
||||
}
|
||||
|
||||
interface Subscriber {
|
||||
projectKey?: string;
|
||||
listener: (event: IssuesChangedEvent) => void;
|
||||
}
|
||||
|
||||
interface ActivitySubscriber {
|
||||
projectKey?: string;
|
||||
listener: (event: ActivityDispatchedEvent) => void;
|
||||
}
|
||||
|
||||
export interface SubscribeOptions {
|
||||
projectRoot?: string;
|
||||
}
|
||||
|
|
@ -27,6 +38,7 @@ export class IssuesEventBus {
|
|||
private nextSubscriberId = 1;
|
||||
|
||||
emit(projectRoot: string, changedPath?: string, kind: IssuesChangeKind = 'changed'): IssuesChangedEvent {
|
||||
console.log(`[IssuesBus] Emitting event: ${kind} for ${projectRoot} (${changedPath})`);
|
||||
const canonicalProjectRoot = canonicalizeWindowsPath(projectRoot);
|
||||
const projectKey = windowsPathKey(canonicalProjectRoot);
|
||||
const event: IssuesChangedEvent = {
|
||||
|
|
@ -73,11 +85,111 @@ export class IssuesEventBus {
|
|||
}
|
||||
}
|
||||
|
||||
export const issuesEventBus = new IssuesEventBus();
|
||||
import { loadActivityHistory, saveActivityHistory } from './activity-persistence';
|
||||
|
||||
export class ActivityEventBus {
|
||||
private nextEventId = 1;
|
||||
|
||||
private readonly subscribers = new Map<number, ActivitySubscriber>();
|
||||
private readonly history: ActivityEvent[] = [];
|
||||
private readonly MAX_HISTORY = 100;
|
||||
private initialized = false;
|
||||
|
||||
private nextSubscriberId = 1;
|
||||
|
||||
constructor() {
|
||||
this.init();
|
||||
}
|
||||
|
||||
private async init() {
|
||||
const history = await loadActivityHistory();
|
||||
this.history.push(...history);
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
emit(activity: ActivityEvent): ActivityDispatchedEvent {
|
||||
const projectKey = windowsPathKey(activity.projectId);
|
||||
const event: ActivityDispatchedEvent = {
|
||||
id: this.nextEventId,
|
||||
event: activity,
|
||||
};
|
||||
this.nextEventId += 1;
|
||||
|
||||
// Buffer history
|
||||
this.history.unshift(activity);
|
||||
if (this.history.length > this.MAX_HISTORY) {
|
||||
this.history.pop();
|
||||
}
|
||||
|
||||
// Persist async
|
||||
void saveActivityHistory(this.history);
|
||||
|
||||
for (const subscriber of this.subscribers.values()) {
|
||||
if (!subscriber.projectKey || subscriber.projectKey === projectKey) {
|
||||
subscriber.listener(event);
|
||||
}
|
||||
}
|
||||
|
||||
return event;
|
||||
}
|
||||
|
||||
getHistory(projectRoot?: string): ActivityEvent[] {
|
||||
if (!projectRoot) {
|
||||
return [...this.history];
|
||||
}
|
||||
const key = windowsPathKey(canonicalizeWindowsPath(projectRoot));
|
||||
return this.history.filter(e => windowsPathKey(e.projectId) === key);
|
||||
}
|
||||
|
||||
subscribe(listener: (event: ActivityDispatchedEvent) => void, options: SubscribeOptions = {}): () => void {
|
||||
const id = this.nextSubscriberId;
|
||||
this.nextSubscriberId += 1;
|
||||
const projectKey = options.projectRoot ? windowsPathKey(canonicalizeWindowsPath(options.projectRoot)) : undefined;
|
||||
|
||||
this.subscribers.set(id, {
|
||||
listener,
|
||||
projectKey,
|
||||
});
|
||||
|
||||
return () => {
|
||||
this.subscribers.delete(id);
|
||||
};
|
||||
}
|
||||
|
||||
getSubscriberCount(): number {
|
||||
return this.subscribers.size;
|
||||
}
|
||||
|
||||
resetForTests(): void {
|
||||
this.subscribers.clear();
|
||||
this.history.length = 0;
|
||||
this.nextSubscriberId = 1;
|
||||
this.nextEventId = 1;
|
||||
}
|
||||
}
|
||||
|
||||
const globalRegistry = globalThis as typeof globalThis & {
|
||||
__beadboardIssuesEventBus?: IssuesEventBus;
|
||||
__beadboardActivityEventBus?: ActivityEventBus;
|
||||
};
|
||||
|
||||
export const issuesEventBus = globalRegistry.__beadboardIssuesEventBus ?? new IssuesEventBus();
|
||||
if (!globalRegistry.__beadboardIssuesEventBus) {
|
||||
globalRegistry.__beadboardIssuesEventBus = issuesEventBus;
|
||||
}
|
||||
|
||||
export const activityEventBus = globalRegistry.__beadboardActivityEventBus ?? new ActivityEventBus();
|
||||
if (!globalRegistry.__beadboardActivityEventBus) {
|
||||
globalRegistry.__beadboardActivityEventBus = activityEventBus;
|
||||
}
|
||||
|
||||
export function toSseFrame(event: IssuesChangedEvent): string {
|
||||
return `id: ${event.id}\nevent: issues\ndata: ${JSON.stringify(event)}\n\n`;
|
||||
}
|
||||
|
||||
export function toActivitySseFrame(event: ActivityDispatchedEvent): string {
|
||||
return `id: ${event.id}\nevent: activity\ndata: ${JSON.stringify(event.event)}\n\n`;
|
||||
}
|
||||
|
||||
export const SSE_HEARTBEAT_FRAME = ': heartbeat\n\n';
|
||||
export const SSE_CONNECTED_FRAME = ': connected\n\n';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue