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
|
|
@ -7,7 +7,7 @@ export async function GET(request: Request): Promise<Response> {
|
||||||
const projectRoot = url.searchParams.get('projectRoot') ?? process.cwd();
|
const projectRoot = url.searchParams.get('projectRoot') ?? process.cwd();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const issues = await readIssuesFromDisk({ projectRoot });
|
const issues = await readIssuesFromDisk({ projectRoot, preferBd: true });
|
||||||
return NextResponse.json({ ok: true, issues });
|
return NextResponse.json({ ok: true, issues });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,30 @@
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
import { canonicalizeWindowsPath } from '../../../lib/pathing';
|
import { canonicalizeWindowsPath } from '../../../lib/pathing';
|
||||||
import { issuesEventBus, SSE_CONNECTED_FRAME, SSE_HEARTBEAT_FRAME, toSseFrame } from '../../../lib/realtime';
|
import { issuesEventBus, activityEventBus, SSE_CONNECTED_FRAME, SSE_HEARTBEAT_FRAME, toSseFrame, toActivitySseFrame } from '../../../lib/realtime';
|
||||||
import { getIssuesWatchManager } from '../../../lib/watcher';
|
import { getIssuesWatchManager } from '../../../lib/watcher';
|
||||||
|
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
const HEARTBEAT_MS = 15_000;
|
const HEARTBEAT_MS = 15_000;
|
||||||
|
const LAST_TOUCHED_POLL_MS = 1_000;
|
||||||
|
|
||||||
|
async function readLastTouchedVersion(filePath: string): Promise<number | null> {
|
||||||
|
try {
|
||||||
|
const stat = await fs.stat(filePath);
|
||||||
|
return stat.mtimeMs;
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET(request: Request): Promise<Response> {
|
export async function GET(request: Request): Promise<Response> {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const projectRootSearchParam = url.searchParams.get('projectRoot');
|
const projectRootSearchParam = url.searchParams.get('projectRoot');
|
||||||
if (!projectRootSearchParam) {
|
const projectRoot = canonicalizeWindowsPath(projectRootSearchParam || process.cwd());
|
||||||
return Response.json(
|
|
||||||
{
|
|
||||||
ok: false,
|
|
||||||
error: {
|
|
||||||
classification: 'bad_args',
|
|
||||||
message: 'The `projectRoot` query parameter is required.',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ status: 400 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const projectRoot = canonicalizeWindowsPath(projectRootSearchParam);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
getIssuesWatchManager().startWatch(projectRoot);
|
getIssuesWatchManager().startWatch(projectRoot);
|
||||||
|
|
@ -51,16 +55,45 @@ export async function GET(request: Request): Promise<Response> {
|
||||||
|
|
||||||
write(SSE_CONNECTED_FRAME);
|
write(SSE_CONNECTED_FRAME);
|
||||||
|
|
||||||
const unsubscribe = issuesEventBus.subscribe(
|
const unsubscribeIssues = issuesEventBus.subscribe(
|
||||||
(event) => {
|
(event) => {
|
||||||
write(toSseFrame(event));
|
write(toSseFrame(event));
|
||||||
},
|
},
|
||||||
{ projectRoot },
|
{ projectRoot },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const unsubscribeActivity = activityEventBus.subscribe(
|
||||||
|
(event) => {
|
||||||
|
write(toActivitySseFrame(event));
|
||||||
|
},
|
||||||
|
{ projectRoot },
|
||||||
|
);
|
||||||
|
|
||||||
const heartbeat = setInterval(() => {
|
const heartbeat = setInterval(() => {
|
||||||
write(SSE_HEARTBEAT_FRAME);
|
write(SSE_HEARTBEAT_FRAME);
|
||||||
}, HEARTBEAT_MS);
|
}, HEARTBEAT_MS);
|
||||||
|
const lastTouchedPath = path.join(projectRoot, '.beads', 'last-touched');
|
||||||
|
let lastTouchedVersion: number | null = null;
|
||||||
|
|
||||||
|
const pollLastTouched = async () => {
|
||||||
|
const nextVersion = await readLastTouchedVersion(lastTouchedPath);
|
||||||
|
if (nextVersion === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (lastTouchedVersion === null) {
|
||||||
|
lastTouchedVersion = nextVersion;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (nextVersion !== lastTouchedVersion) {
|
||||||
|
lastTouchedVersion = nextVersion;
|
||||||
|
write(toSseFrame(issuesEventBus.emit(projectRoot, lastTouchedPath, 'changed')));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const touchedPoll = setInterval(() => {
|
||||||
|
void pollLastTouched();
|
||||||
|
}, LAST_TOUCHED_POLL_MS);
|
||||||
|
void pollLastTouched();
|
||||||
|
|
||||||
const close = () => {
|
const close = () => {
|
||||||
if (closed) {
|
if (closed) {
|
||||||
|
|
@ -69,7 +102,9 @@ export async function GET(request: Request): Promise<Response> {
|
||||||
|
|
||||||
closed = true;
|
closed = true;
|
||||||
clearInterval(heartbeat);
|
clearInterval(heartbeat);
|
||||||
unsubscribe();
|
clearInterval(touchedPoll);
|
||||||
|
unsubscribeIssues();
|
||||||
|
unsubscribeActivity();
|
||||||
request.signal.removeEventListener('abort', close);
|
request.signal.removeEventListener('abort', close);
|
||||||
try {
|
try {
|
||||||
controller.close();
|
controller.close();
|
||||||
|
|
|
||||||
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 };
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { runBdCommand } from './bridge';
|
||||||
import { parseIssuesJsonl } from './parser';
|
import { parseIssuesJsonl } from './parser';
|
||||||
import { canonicalizeWindowsPath } from './pathing';
|
import { canonicalizeWindowsPath } from './pathing';
|
||||||
import { readTextFileWithRetry } from './read-text-retry';
|
import { readTextFileWithRetry } from './read-text-retry';
|
||||||
import { buildProjectContext } from './project-context';
|
import { buildProjectContext } from './project-context';
|
||||||
|
import type { BeadDependency, BeadIssue } from './types';
|
||||||
import type { BeadIssueWithProject, ProjectSource } from './types';
|
import type { BeadIssueWithProject, ProjectSource } from './types';
|
||||||
|
|
||||||
export interface ReadIssuesOptions {
|
export interface ReadIssuesOptions {
|
||||||
|
|
@ -11,6 +13,7 @@ export interface ReadIssuesOptions {
|
||||||
includeTombstones?: boolean;
|
includeTombstones?: boolean;
|
||||||
projectSource?: ProjectSource;
|
projectSource?: ProjectSource;
|
||||||
projectAddedAt?: string | null;
|
projectAddedAt?: string | null;
|
||||||
|
preferBd?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveIssuesJsonlPathCandidates(projectRoot: string = process.cwd()): string[] {
|
export function resolveIssuesJsonlPathCandidates(projectRoot: string = process.cwd()): string[] {
|
||||||
|
|
@ -24,6 +27,94 @@ export function resolveIssuesJsonlPath(projectRoot: string = process.cwd()): str
|
||||||
return resolveIssuesJsonlPathCandidates(projectRoot)[0];
|
return resolveIssuesJsonlPathCandidates(projectRoot)[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeDependencies(value: unknown): BeadDependency[] {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
.map((item) => {
|
||||||
|
if (!item || typeof item !== 'object') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const dep = item as { type?: unknown; target?: unknown; depends_on_id?: unknown };
|
||||||
|
if (typeof dep.type !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const target = typeof dep.target === 'string' ? dep.target : typeof dep.depends_on_id === 'string' ? dep.depends_on_id : null;
|
||||||
|
if (!target) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: dep.type === 'parent-child' ? 'parent' : (dep.type as BeadDependency['type']),
|
||||||
|
target,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((dep): dep is BeadDependency => dep !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBdIssue(raw: unknown): BeadIssue | null {
|
||||||
|
if (!raw || typeof raw !== 'object') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const data = raw as Record<string, unknown>;
|
||||||
|
if (typeof data.id !== 'string' || typeof data.title !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: data.id,
|
||||||
|
title: data.title,
|
||||||
|
description: typeof data.description === 'string' ? data.description : null,
|
||||||
|
status: typeof data.status === 'string' ? (data.status as BeadIssue['status']) : 'open',
|
||||||
|
priority: typeof data.priority === 'number' ? data.priority : 2,
|
||||||
|
issue_type: typeof data.issue_type === 'string' ? data.issue_type : 'task',
|
||||||
|
assignee: typeof data.assignee === 'string' ? data.assignee : null,
|
||||||
|
owner: typeof data.owner === 'string' ? data.owner : null,
|
||||||
|
labels: Array.isArray(data.labels) ? data.labels.filter((x): x is string => typeof x === 'string') : [],
|
||||||
|
dependencies: normalizeDependencies(data.dependencies),
|
||||||
|
created_at: typeof data.created_at === 'string' ? data.created_at : '',
|
||||||
|
updated_at: typeof data.updated_at === 'string' ? data.updated_at : '',
|
||||||
|
closed_at: typeof data.closed_at === 'string' ? data.closed_at : null,
|
||||||
|
close_reason: typeof data.close_reason === 'string' ? data.close_reason : null,
|
||||||
|
closed_by_session: typeof data.closed_by_session === 'string' ? data.closed_by_session : null,
|
||||||
|
created_by: typeof data.created_by === 'string' ? data.created_by : null,
|
||||||
|
due_at: typeof data.due_at === 'string' ? data.due_at : null,
|
||||||
|
estimated_minutes: typeof data.estimated_minutes === 'number' ? data.estimated_minutes : null,
|
||||||
|
external_ref: typeof data.external_ref === 'string' ? data.external_ref : null,
|
||||||
|
metadata: typeof data.metadata === 'object' && data.metadata !== null ? (data.metadata as Record<string, unknown>) : {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readIssuesViaBd(options: ReadIssuesOptions, project: ReturnType<typeof buildProjectContext>): Promise<BeadIssueWithProject[] | null> {
|
||||||
|
const projectRoot = options.projectRoot ?? process.cwd();
|
||||||
|
const command = await runBdCommand({
|
||||||
|
projectRoot,
|
||||||
|
args: ['list', '--all', '--limit', '0', '--json'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!command.success) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(command.stdout) as unknown;
|
||||||
|
if (!Array.isArray(parsed)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed
|
||||||
|
.map((issue) => normalizeBdIssue(issue))
|
||||||
|
.filter((issue): issue is BeadIssue => issue !== null)
|
||||||
|
.filter((issue) => (options.includeTombstones ?? false ? true : issue.status !== 'tombstone'))
|
||||||
|
.map((issue) => ({
|
||||||
|
...issue,
|
||||||
|
project,
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function readIssuesFromDisk(options: ReadIssuesOptions = {}): Promise<BeadIssueWithProject[]> {
|
export async function readIssuesFromDisk(options: ReadIssuesOptions = {}): Promise<BeadIssueWithProject[]> {
|
||||||
const projectRoot = options.projectRoot ?? process.cwd();
|
const projectRoot = options.projectRoot ?? process.cwd();
|
||||||
const candidates = resolveIssuesJsonlPathCandidates(projectRoot);
|
const candidates = resolveIssuesJsonlPathCandidates(projectRoot);
|
||||||
|
|
@ -32,6 +123,13 @@ export async function readIssuesFromDisk(options: ReadIssuesOptions = {}): Promi
|
||||||
addedAt: options.projectAddedAt ?? null,
|
addedAt: options.projectAddedAt ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (options.preferBd ?? false) {
|
||||||
|
const viaBd = await readIssuesViaBd(options, project);
|
||||||
|
if (viaBd) {
|
||||||
|
return viaBd;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const issuesPath of candidates) {
|
for (const issuesPath of candidates) {
|
||||||
try {
|
try {
|
||||||
const jsonl = await readTextFileWithRetry(issuesPath);
|
const jsonl = await readTextFileWithRetry(issuesPath);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { canonicalizeWindowsPath, windowsPathKey } from './pathing';
|
import { canonicalizeWindowsPath, windowsPathKey } from './pathing';
|
||||||
|
import type { ActivityEvent } from './activity';
|
||||||
|
|
||||||
export type IssuesChangeKind = 'changed' | 'renamed';
|
export type IssuesChangeKind = 'changed' | 'renamed';
|
||||||
|
|
||||||
|
|
@ -10,11 +11,21 @@ export interface IssuesChangedEvent {
|
||||||
at: string;
|
at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ActivityDispatchedEvent {
|
||||||
|
id: number;
|
||||||
|
event: ActivityEvent;
|
||||||
|
}
|
||||||
|
|
||||||
interface Subscriber {
|
interface Subscriber {
|
||||||
projectKey?: string;
|
projectKey?: string;
|
||||||
listener: (event: IssuesChangedEvent) => void;
|
listener: (event: IssuesChangedEvent) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ActivitySubscriber {
|
||||||
|
projectKey?: string;
|
||||||
|
listener: (event: ActivityDispatchedEvent) => void;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SubscribeOptions {
|
export interface SubscribeOptions {
|
||||||
projectRoot?: string;
|
projectRoot?: string;
|
||||||
}
|
}
|
||||||
|
|
@ -27,6 +38,7 @@ export class IssuesEventBus {
|
||||||
private nextSubscriberId = 1;
|
private nextSubscriberId = 1;
|
||||||
|
|
||||||
emit(projectRoot: string, changedPath?: string, kind: IssuesChangeKind = 'changed'): IssuesChangedEvent {
|
emit(projectRoot: string, changedPath?: string, kind: IssuesChangeKind = 'changed'): IssuesChangedEvent {
|
||||||
|
console.log(`[IssuesBus] Emitting event: ${kind} for ${projectRoot} (${changedPath})`);
|
||||||
const canonicalProjectRoot = canonicalizeWindowsPath(projectRoot);
|
const canonicalProjectRoot = canonicalizeWindowsPath(projectRoot);
|
||||||
const projectKey = windowsPathKey(canonicalProjectRoot);
|
const projectKey = windowsPathKey(canonicalProjectRoot);
|
||||||
const event: IssuesChangedEvent = {
|
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 {
|
export function toSseFrame(event: IssuesChangedEvent): string {
|
||||||
return `id: ${event.id}\nevent: issues\ndata: ${JSON.stringify(event)}\n\n`;
|
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_HEARTBEAT_FRAME = ': heartbeat\n\n';
|
||||||
export const SSE_CONNECTED_FRAME = ': connected\n\n';
|
export const SSE_CONNECTED_FRAME = ': connected\n\n';
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,21 @@
|
||||||
import chokidar, { type FSWatcher } from 'chokidar';
|
import chokidar, { type FSWatcher } from 'chokidar';
|
||||||
|
import path from 'node:path';
|
||||||
|
import os from 'node:os';
|
||||||
|
|
||||||
import { ProjectEventCoalescer } from './coalescer';
|
import { ProjectEventCoalescer } from './coalescer';
|
||||||
import { windowsPathKey } from './pathing';
|
import { windowsPathKey } from './pathing';
|
||||||
import { issuesEventBus, type IssuesChangeKind, type IssuesEventBus } from './realtime';
|
import { issuesEventBus, activityEventBus, type IssuesChangeKind, type IssuesEventBus, type ActivityEventBus } from './realtime';
|
||||||
import { resolveIssuesJsonlPathCandidates } from './read-issues';
|
import { readIssuesFromDisk, resolveIssuesJsonlPathCandidates } from './read-issues';
|
||||||
|
import { diffSnapshots } from './snapshot-differ';
|
||||||
|
import type { BeadIssueWithProject } from './types';
|
||||||
|
|
||||||
type FileEventName = 'add' | 'change' | 'unlink';
|
type FileEventName = 'add' | 'change' | 'unlink';
|
||||||
|
|
||||||
|
function getGlobalAgentMessagesPath(): string {
|
||||||
|
const userProfile = process.env.USERPROFILE?.trim() || os.homedir();
|
||||||
|
return path.join(userProfile, '.beadboard', 'agent', 'messages');
|
||||||
|
}
|
||||||
|
|
||||||
interface WatchRegistration {
|
interface WatchRegistration {
|
||||||
projectRoot: string;
|
projectRoot: string;
|
||||||
watcher: FSWatcher;
|
watcher: FSWatcher;
|
||||||
|
|
@ -15,12 +24,16 @@ interface WatchRegistration {
|
||||||
export interface WatchManagerOptions {
|
export interface WatchManagerOptions {
|
||||||
debounceMs?: number;
|
debounceMs?: number;
|
||||||
eventBus?: IssuesEventBus;
|
eventBus?: IssuesEventBus;
|
||||||
|
activityBus?: ActivityEventBus;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class IssuesWatchManager {
|
export class IssuesWatchManager {
|
||||||
private readonly registrations = new Map<string, WatchRegistration>();
|
private readonly registrations = new Map<string, WatchRegistration>();
|
||||||
|
|
||||||
|
private readonly snapshots = new Map<string, BeadIssueWithProject[]>();
|
||||||
|
|
||||||
private readonly eventBus: IssuesEventBus;
|
private readonly eventBus: IssuesEventBus;
|
||||||
|
private readonly activityBus: ActivityEventBus;
|
||||||
|
|
||||||
private readonly coalescer: ProjectEventCoalescer<{
|
private readonly coalescer: ProjectEventCoalescer<{
|
||||||
changedPath?: string;
|
changedPath?: string;
|
||||||
|
|
@ -30,18 +43,69 @@ export class IssuesWatchManager {
|
||||||
constructor(options: WatchManagerOptions = {}) {
|
constructor(options: WatchManagerOptions = {}) {
|
||||||
const debounceMs = options.debounceMs ?? 150;
|
const debounceMs = options.debounceMs ?? 150;
|
||||||
this.eventBus = options.eventBus ?? issuesEventBus;
|
this.eventBus = options.eventBus ?? issuesEventBus;
|
||||||
this.coalescer = new ProjectEventCoalescer(debounceMs, ({ projectRoot, payload }) => {
|
this.activityBus = options.activityBus ?? activityEventBus;
|
||||||
|
this.coalescer = new ProjectEventCoalescer(debounceMs, async ({ projectRoot, payload }) => {
|
||||||
|
console.log(`[Watcher] Processing event for ${projectRoot}: ${payload.kind} (${payload.changedPath})`);
|
||||||
|
|
||||||
|
// 1. Emit basic file change event
|
||||||
this.eventBus.emit(projectRoot, payload.changedPath, payload.kind);
|
this.eventBus.emit(projectRoot, payload.changedPath, payload.kind);
|
||||||
|
|
||||||
|
// 2. Perform snapshot diffing if issues.jsonl changed
|
||||||
|
const changedPath = payload.changedPath || '';
|
||||||
|
const isIssuesJsonl = changedPath.endsWith('issues.jsonl') || changedPath.endsWith('issues.jsonl.new');
|
||||||
|
const isGlobalMessages = changedPath.includes('.beadboard') && changedPath.includes('messages');
|
||||||
|
|
||||||
|
if (isIssuesJsonl) {
|
||||||
|
console.log(`[Watcher] Issues changed. Syncing activity for ${projectRoot}...`);
|
||||||
|
await this.syncActivity(projectRoot);
|
||||||
|
} else if (isGlobalMessages) {
|
||||||
|
console.log(`[Watcher] Global agent messages changed. Triggering refresh for ${projectRoot}.`);
|
||||||
|
// No need to syncActivity (diff issues) if only messages changed,
|
||||||
|
// the 'issues' event emitted above will trigger client refresh.
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
startWatch(projectRoot: string): void {
|
private async syncActivity(projectRoot: string): Promise<void> {
|
||||||
|
const projectKey = windowsPathKey(projectRoot);
|
||||||
|
const previous = this.snapshots.get(projectKey) ?? null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const current = await readIssuesFromDisk({ projectRoot });
|
||||||
|
const events = diffSnapshots(previous, current);
|
||||||
|
|
||||||
|
this.snapshots.set(projectKey, current);
|
||||||
|
|
||||||
|
events.forEach(event => {
|
||||||
|
this.activityBus.emit(event);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[Watcher] Failed to sync activity for ${projectRoot}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async startWatch(projectRoot: string): Promise<void> {
|
||||||
const projectKey = windowsPathKey(projectRoot);
|
const projectKey = windowsPathKey(projectRoot);
|
||||||
if (this.registrations.has(projectKey)) {
|
if (this.registrations.has(projectKey)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pre-populate snapshot to avoid "all created" burst on first change
|
||||||
|
try {
|
||||||
|
const initial = await readIssuesFromDisk({ projectRoot });
|
||||||
|
this.snapshots.set(projectKey, initial);
|
||||||
|
} catch {
|
||||||
|
// Ignore initial read failure, will retry on first change
|
||||||
|
}
|
||||||
|
|
||||||
const watchedPaths = resolveIssuesJsonlPathCandidates(projectRoot);
|
const watchedPaths = resolveIssuesJsonlPathCandidates(projectRoot);
|
||||||
|
watchedPaths.push(path.join(projectRoot, '.beads', 'beads.db'));
|
||||||
|
watchedPaths.push(path.join(projectRoot, '.beads', 'beads.db-wal'));
|
||||||
|
watchedPaths.push(path.join(projectRoot, '.beads', 'last-touched'));
|
||||||
|
|
||||||
|
// Add global agent messages to enable cross-project communication real-time updates
|
||||||
|
watchedPaths.push(getGlobalAgentMessagesPath());
|
||||||
|
|
||||||
const watcher = chokidar.watch(watchedPaths, {
|
const watcher = chokidar.watch(watchedPaths, {
|
||||||
ignoreInitial: true,
|
ignoreInitial: true,
|
||||||
awaitWriteFinish: {
|
awaitWriteFinish: {
|
||||||
|
|
@ -101,13 +165,23 @@ export class IssuesWatchManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const WATCHER_VERSION = 3; // Bump this to force re-creation on HMR
|
||||||
|
|
||||||
const globalRegistry = globalThis as typeof globalThis & {
|
const globalRegistry = globalThis as typeof globalThis & {
|
||||||
__beadboardWatchManager?: IssuesWatchManager;
|
__beadboardWatchManager?: IssuesWatchManager;
|
||||||
|
__beadboardWatcherVersion?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getIssuesWatchManager(): IssuesWatchManager {
|
export function getIssuesWatchManager(): IssuesWatchManager {
|
||||||
if (!globalRegistry.__beadboardWatchManager) {
|
if (!globalRegistry.__beadboardWatchManager || globalRegistry.__beadboardWatcherVersion !== WATCHER_VERSION) {
|
||||||
|
if (globalRegistry.__beadboardWatchManager) {
|
||||||
|
console.log('[Watcher] Stopping stale watcher instance...');
|
||||||
|
// Best effort stop of old instance
|
||||||
|
void globalRegistry.__beadboardWatchManager.stopAll();
|
||||||
|
}
|
||||||
|
console.log(`[Watcher] Initializing new manager (v${WATCHER_VERSION})...`);
|
||||||
globalRegistry.__beadboardWatchManager = new IssuesWatchManager();
|
globalRegistry.__beadboardWatchManager = new IssuesWatchManager();
|
||||||
|
globalRegistry.__beadboardWatcherVersion = WATCHER_VERSION;
|
||||||
}
|
}
|
||||||
|
|
||||||
return globalRegistry.__beadboardWatchManager;
|
return globalRegistry.__beadboardWatchManager;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue