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();
|
||||
|
||||
try {
|
||||
const issues = await readIssuesFromDisk({ projectRoot });
|
||||
const issues = await readIssuesFromDisk({ projectRoot, preferBd: true });
|
||||
return NextResponse.json({ ok: true, issues });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
|
|
|
|||
|
|
@ -1,26 +1,30 @@
|
|||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
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';
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
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> {
|
||||
const url = new URL(request.url);
|
||||
const projectRootSearchParam = url.searchParams.get('projectRoot');
|
||||
if (!projectRootSearchParam) {
|
||||
return Response.json(
|
||||
{
|
||||
ok: false,
|
||||
error: {
|
||||
classification: 'bad_args',
|
||||
message: 'The `projectRoot` query parameter is required.',
|
||||
},
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const projectRoot = canonicalizeWindowsPath(projectRootSearchParam);
|
||||
const projectRoot = canonicalizeWindowsPath(projectRootSearchParam || process.cwd());
|
||||
|
||||
try {
|
||||
getIssuesWatchManager().startWatch(projectRoot);
|
||||
|
|
@ -51,16 +55,45 @@ export async function GET(request: Request): Promise<Response> {
|
|||
|
||||
write(SSE_CONNECTED_FRAME);
|
||||
|
||||
const unsubscribe = issuesEventBus.subscribe(
|
||||
const unsubscribeIssues = issuesEventBus.subscribe(
|
||||
(event) => {
|
||||
write(toSseFrame(event));
|
||||
},
|
||||
{ projectRoot },
|
||||
);
|
||||
|
||||
const unsubscribeActivity = activityEventBus.subscribe(
|
||||
(event) => {
|
||||
write(toActivitySseFrame(event));
|
||||
},
|
||||
{ projectRoot },
|
||||
);
|
||||
|
||||
const heartbeat = setInterval(() => {
|
||||
write(SSE_HEARTBEAT_FRAME);
|
||||
}, 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 = () => {
|
||||
if (closed) {
|
||||
|
|
@ -69,7 +102,9 @@ export async function GET(request: Request): Promise<Response> {
|
|||
|
||||
closed = true;
|
||||
clearInterval(heartbeat);
|
||||
unsubscribe();
|
||||
clearInterval(touchedPoll);
|
||||
unsubscribeIssues();
|
||||
unsubscribeActivity();
|
||||
request.signal.removeEventListener('abort', close);
|
||||
try {
|
||||
controller.close();
|
||||
|
|
@ -96,4 +131,4 @@ export async function GET(request: Request): Promise<Response> {
|
|||
Connection: 'keep-alive',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue