feat(server): emit SSE for CLI/file writes via filesystem watcher (TSK-0006)
Closes the documented /events limitation: mutations made through the
local CLI (direct file + SQLite writes, no --server) bypassed the
in-process event bus and were invisible to SSE subscribers. A watcher
(e.g. a Windows client) was therefore NOT triggered when another agent
created a task via plain `agenthub task create`.
- src/server/fsWatch.ts: watch the entity dirs (tasks/handoffs/
decisions/memory); on any .md change, parse the entity and emit the
matching AgentHubEvent. Debounced per-file; self-creates dirs so it
attaches even before the first write.
- src/server/events.ts: shared dedup cache (signature = type🆔stamp)
+ emitChange() so the REST path and the watcher deliver each change
exactly once (no echo when REST writes the file the watcher sees).
- src/server/routes.ts: route emits go through emitChange(); update the
stale limitation comment.
- src/server/index.ts: start/stop the watcher with the server lifecycle.
- tests/sse.test.ts: CLI-write -> SSE, CLI-update -> SSE, and REST
dedup (exactly-once). 102/102 green.
Effect: a watcher is auto-triggered the instant any agent creates a
task, with or without --server. Bump 0.1.0 -> 0.1.1.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
9f3dc28030
commit
722388cb00
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"description": "Local coordination layer for AI coding agents",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@ -31,3 +31,51 @@ export const eventBus = new AgentHubEventBus();
|
||||
// Allow an arbitrary number of SSE clients without triggering the
|
||||
// default-listener-count warning.
|
||||
eventBus.setMaxListeners(0);
|
||||
|
||||
// ─── De-duplication between the REST and filesystem-watch emit paths ────────
|
||||
// Both mutating REST routes AND the filesystem watcher (fsWatch.ts) can observe
|
||||
// the same change: a REST POST/PATCH writes the entity file, then the watcher
|
||||
// sees that very write. Without coordination the subscriber would receive the
|
||||
// event twice. We solve it with a short-lived signature cache keyed by
|
||||
// `${type}:${id}:${updatedAt|createdAt}`. Whichever path emits first records the
|
||||
// signature; the other path sees it via `seenRecently()` and stays silent.
|
||||
const DEDUP_TTL_MS = 15_000;
|
||||
const recentlyEmitted = new Map<string, number>();
|
||||
|
||||
/** Stable key for a single logical mutation of one entity revision. */
|
||||
export function signatureOf(type: string, id: string, stamp: string | undefined): string {
|
||||
return `${type}:${id}:${stamp ?? ''}`;
|
||||
}
|
||||
|
||||
function markEmitted(signature: string): void {
|
||||
const now = Date.now();
|
||||
recentlyEmitted.set(signature, now);
|
||||
// Opportunistic GC so the map can't grow unbounded under heavy churn.
|
||||
if (recentlyEmitted.size > 500) {
|
||||
for (const [key, ts] of recentlyEmitted) {
|
||||
if (now - ts > DEDUP_TTL_MS) recentlyEmitted.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** True if a change with this signature was emitted within the dedup window. */
|
||||
export function seenRecently(signature: string): boolean {
|
||||
const ts = recentlyEmitted.get(signature);
|
||||
if (ts === undefined) return false;
|
||||
if (Date.now() - ts > DEDUP_TTL_MS) {
|
||||
recentlyEmitted.delete(signature);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish a change and record its signature for cross-path de-duplication.
|
||||
* Used by both the REST routes and the filesystem watcher. `stamp` is the
|
||||
* entity's `updatedAt` (or `createdAt` for handoffs) and must match what the
|
||||
* other path derives from the same file revision.
|
||||
*/
|
||||
export function emitChange(event: AgentHubEvent, stamp: string | undefined): void {
|
||||
markEmitted(signatureOf(event.type, event.id, stamp));
|
||||
eventBus.publish(event);
|
||||
}
|
||||
|
||||
139
src/server/fsWatch.ts
Normal file
139
src/server/fsWatch.ts
Normal file
@ -0,0 +1,139 @@
|
||||
import { watch, mkdirSync, type FSWatcher } from 'node:fs';
|
||||
import { basename } from 'node:path';
|
||||
import { readEntity } from '../core/files.js';
|
||||
import { getEntityDir, type EntityType } from '../core/paths.js';
|
||||
import { emitChange, seenRecently, signatureOf } from './events.js';
|
||||
import type { AgentHubEvent, AgentHubEventType } from './events.js';
|
||||
|
||||
/**
|
||||
* Filesystem-watch layer.
|
||||
*
|
||||
* Closes the gap documented on the /events route: mutations made via the local
|
||||
* CLI (direct file + SQLite writes, no --server) bypass the in-process event
|
||||
* bus and were therefore invisible to SSE subscribers. This watcher observes
|
||||
* the entity directories and emits the same AgentHubEvent for any change,
|
||||
* regardless of which path produced it — so a watcher (e.g. a Windows client)
|
||||
* is triggered automatically the instant a task is created, even from a plain
|
||||
* `agenthub task create`.
|
||||
*
|
||||
* REST mutations also touch these files, so the watcher would double-emit; the
|
||||
* dedup cache in events.ts (signature = type:id:stamp) suppresses the echo.
|
||||
*/
|
||||
|
||||
const WATCHED: { dir: EntityType; type: AgentHubEventType }[] = [
|
||||
{ dir: 'tasks', type: 'task' },
|
||||
{ dir: 'handoffs', type: 'handoff' },
|
||||
{ dir: 'decisions', type: 'decision' },
|
||||
{ dir: 'memory', type: 'memory' },
|
||||
];
|
||||
|
||||
// fs.watch can fire several events (rename + change) for a single write, and a
|
||||
// file may be observed mid-write. Coalesce per-path bursts before reading.
|
||||
const DEBOUNCE_MS = 40;
|
||||
|
||||
function str(value: unknown): string | undefined {
|
||||
return value === undefined || value === null ? undefined : String(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the SSE event + dedup stamp from parsed frontmatter. Returns undefined
|
||||
* if the file isn't a recognizable entity yet (e.g. caught mid-write).
|
||||
*/
|
||||
function toEvent(
|
||||
type: AgentHubEventType,
|
||||
fm: Record<string, unknown>,
|
||||
): { event: AgentHubEvent; stamp: string | undefined } | undefined {
|
||||
const id = str(fm.id);
|
||||
if (!id) return undefined;
|
||||
|
||||
const createdAt = str(fm.createdAt);
|
||||
const updatedAt = str(fm.updatedAt);
|
||||
// Handoffs have only createdAt; for the rest, an updatedAt that differs from
|
||||
// createdAt means a mutation of an existing entity -> 'updated'. This mirrors
|
||||
// the POST=created / PATCH=updated semantics of the REST routes.
|
||||
const stamp = updatedAt ?? createdAt;
|
||||
const action: AgentHubEvent['action'] =
|
||||
updatedAt && createdAt && updatedAt !== createdAt ? 'updated' : 'created';
|
||||
|
||||
switch (type) {
|
||||
case 'task':
|
||||
return {
|
||||
stamp,
|
||||
event: { type, action, id, title: str(fm.title), status: str(fm.status), role: str(fm.role), assignedTo: str(fm.assignedTo) },
|
||||
};
|
||||
case 'handoff':
|
||||
return {
|
||||
stamp,
|
||||
event: { type, action: 'created', id, title: str(fm.summary), role: str(fm.toRole), assignedTo: str(fm.toAgent) },
|
||||
};
|
||||
case 'decision':
|
||||
return { stamp, event: { type, action, id, title: str(fm.title) } };
|
||||
case 'memory':
|
||||
return { stamp, event: { type, action, id, title: str(fm.title) } };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start watching the entity directories. Returns a stop function that tears
|
||||
* down every watcher and pending timer. Safe to call in a project that hasn't
|
||||
* created all entity directories yet — missing ones are simply skipped.
|
||||
*/
|
||||
export function startEntityWatcher(cwd: string): () => void {
|
||||
const watchers: FSWatcher[] = [];
|
||||
const timers = new Map<string, NodeJS.Timeout>();
|
||||
|
||||
for (const { dir, type } of WATCHED) {
|
||||
const path = getEntityDir(cwd, dir);
|
||||
|
||||
try {
|
||||
// Ensure the directory exists so the watcher attaches even before the
|
||||
// first entity of this type is written (e.g. a fresh project's decisions).
|
||||
mkdirSync(path, { recursive: true });
|
||||
const watcher = watch(path, (_eventType, filename) => {
|
||||
if (!filename) return; // some platforms omit the name; nothing to read
|
||||
const name = basename(filename.toString());
|
||||
if (!name.endsWith('.md')) return;
|
||||
|
||||
// Debounce per file so a rename+change burst reads the settled file once.
|
||||
const key = `${type}:${name}`;
|
||||
const existing = timers.get(key);
|
||||
if (existing) clearTimeout(existing);
|
||||
timers.set(
|
||||
key,
|
||||
setTimeout(() => {
|
||||
timers.delete(key);
|
||||
processFile(getEntityDir(cwd, dir) + '/' + name, type);
|
||||
}, DEBOUNCE_MS),
|
||||
);
|
||||
});
|
||||
watchers.push(watcher);
|
||||
} catch {
|
||||
// A directory that can't be watched (permissions, transient) shouldn't
|
||||
// crash the server; the REST emit path still works for that entity.
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const t of timers.values()) clearTimeout(t);
|
||||
timers.clear();
|
||||
for (const w of watchers) w.close();
|
||||
};
|
||||
}
|
||||
|
||||
function processFile(filePath: string, type: AgentHubEventType): void {
|
||||
let fm: Record<string, unknown>;
|
||||
try {
|
||||
fm = readEntity(filePath).frontmatter;
|
||||
} catch {
|
||||
// Deleted again, or read mid-write -> skip; a later settled write re-fires.
|
||||
return;
|
||||
}
|
||||
|
||||
const built = toEvent(type, fm);
|
||||
if (!built) return;
|
||||
|
||||
// If the REST path already emitted this exact revision, stay silent.
|
||||
if (seenRecently(signatureOf(built.event.type, built.event.id, built.stamp))) return;
|
||||
|
||||
emitChange(built.event, built.stamp);
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
import Fastify from 'fastify';
|
||||
import { registerRoutes } from './routes.js';
|
||||
import { resolveAdvertiseUrl, startDiscoveryBroadcaster } from '../discovery.js';
|
||||
import { startEntityWatcher } from './fsWatch.js';
|
||||
|
||||
export function buildApp(cwd: string) {
|
||||
const app = Fastify({ logger: false });
|
||||
@ -14,8 +15,11 @@ export async function startServer(cwd: string, options: { port?: number; host?:
|
||||
const host = options.host ?? '127.0.0.1';
|
||||
|
||||
let broadcaster: ReturnType<typeof startDiscoveryBroadcaster> | undefined;
|
||||
// Emit SSE events for CLI/file writes too, not just REST mutations.
|
||||
const stopWatcher = startEntityWatcher(cwd);
|
||||
app.addHook('onClose', async () => {
|
||||
broadcaster?.stop();
|
||||
stopWatcher();
|
||||
});
|
||||
|
||||
try {
|
||||
|
||||
@ -8,7 +8,7 @@ import { getStatus, updateStatus } from '../core/services/statusService.js';
|
||||
import { autoDelegate, suggestDelegation } from '../core/services/delegateService.js';
|
||||
import { loadConfig } from '../core/config.js';
|
||||
import { renderBoardHtml } from './board.js';
|
||||
import { eventBus } from './events.js';
|
||||
import { eventBus, emitChange } from './events.js';
|
||||
import type { AgentHubEvent } from './events.js';
|
||||
import type { Task, Handoff, Decision, Memory } from '../core/schema.js';
|
||||
|
||||
@ -37,10 +37,10 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
|
||||
// Optional ?role= filter: tasks whose role doesn't match are dropped
|
||||
// server-side. All handoff / decision / memory events are always forwarded.
|
||||
//
|
||||
// Limitation: only mutations performed through this server emit events.
|
||||
// Direct local-CLI writes (file + SQLite) bypass the event bus and are
|
||||
// therefore invisible to subscribers. A filesystem-watch layer can be
|
||||
// added in a later increment.
|
||||
// Both REST mutations and direct local-CLI writes emit events: REST routes
|
||||
// publish via emitChange(), and the filesystem watcher (fsWatch.ts) observes
|
||||
// the entity directories and emits for any other write. A shared dedup cache
|
||||
// (events.ts) ensures each change is delivered exactly once.
|
||||
app.get('/events', async (request, reply) => {
|
||||
const { role } = request.query as { role?: string };
|
||||
|
||||
@ -94,15 +94,18 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
|
||||
} catch (err) {
|
||||
return badRequest(reply, err instanceof Error ? err.message : 'Invalid task');
|
||||
}
|
||||
eventBus.publish({
|
||||
type: 'task',
|
||||
action: 'created',
|
||||
id: task.id,
|
||||
title: task.title,
|
||||
status: task.status,
|
||||
role: task.role,
|
||||
assignedTo: task.assignedTo,
|
||||
});
|
||||
emitChange(
|
||||
{
|
||||
type: 'task',
|
||||
action: 'created',
|
||||
id: task.id,
|
||||
title: task.title,
|
||||
status: task.status,
|
||||
role: task.role,
|
||||
assignedTo: task.assignedTo,
|
||||
},
|
||||
task.updatedAt,
|
||||
);
|
||||
return task;
|
||||
});
|
||||
|
||||
@ -155,15 +158,18 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
|
||||
return badRequest(reply, 'Unsupported patch: status must be one of open, in_progress, review, done, cancelled');
|
||||
}
|
||||
|
||||
eventBus.publish({
|
||||
type: 'task',
|
||||
action: 'updated',
|
||||
id: task.id,
|
||||
title: task.title,
|
||||
status: task.status,
|
||||
role: task.role,
|
||||
assignedTo: task.assignedTo,
|
||||
});
|
||||
emitChange(
|
||||
{
|
||||
type: 'task',
|
||||
action: 'updated',
|
||||
id: task.id,
|
||||
title: task.title,
|
||||
status: task.status,
|
||||
role: task.role,
|
||||
assignedTo: task.assignedTo,
|
||||
},
|
||||
task.updatedAt,
|
||||
);
|
||||
return task;
|
||||
});
|
||||
|
||||
@ -176,14 +182,17 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
|
||||
} catch (err) {
|
||||
return badRequest(reply, err instanceof Error ? err.message : 'Invalid handoff');
|
||||
}
|
||||
eventBus.publish({
|
||||
type: 'handoff',
|
||||
action: 'created',
|
||||
id: handoff.id,
|
||||
title: handoff.summary,
|
||||
role: handoff.toRole,
|
||||
assignedTo: handoff.toAgent,
|
||||
});
|
||||
emitChange(
|
||||
{
|
||||
type: 'handoff',
|
||||
action: 'created',
|
||||
id: handoff.id,
|
||||
title: handoff.summary,
|
||||
role: handoff.toRole,
|
||||
assignedTo: handoff.toAgent,
|
||||
},
|
||||
handoff.createdAt,
|
||||
);
|
||||
return handoff;
|
||||
});
|
||||
app.get('/handoffs/:id', async (request, reply) => {
|
||||
@ -205,12 +214,15 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
|
||||
} catch (err) {
|
||||
return badRequest(reply, err instanceof Error ? err.message : 'Invalid decision');
|
||||
}
|
||||
eventBus.publish({
|
||||
type: 'decision',
|
||||
action: 'created',
|
||||
id: decision.id,
|
||||
title: decision.title,
|
||||
});
|
||||
emitChange(
|
||||
{
|
||||
type: 'decision',
|
||||
action: 'created',
|
||||
id: decision.id,
|
||||
title: decision.title,
|
||||
},
|
||||
decision.updatedAt,
|
||||
);
|
||||
return decision;
|
||||
});
|
||||
|
||||
@ -223,12 +235,15 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
|
||||
} catch (err) {
|
||||
return badRequest(reply, err instanceof Error ? err.message : 'Invalid memory');
|
||||
}
|
||||
eventBus.publish({
|
||||
type: 'memory',
|
||||
action: 'created',
|
||||
id: memory.id,
|
||||
title: memory.title,
|
||||
});
|
||||
emitChange(
|
||||
{
|
||||
type: 'memory',
|
||||
action: 'created',
|
||||
id: memory.id,
|
||||
title: memory.title,
|
||||
},
|
||||
memory.updatedAt,
|
||||
);
|
||||
return memory;
|
||||
});
|
||||
app.get('/memory/search', async (request) => {
|
||||
|
||||
@ -17,6 +17,8 @@ import { eventBus } from '../src/server/events.js';
|
||||
import type { AgentHubEvent } from '../src/server/events.js';
|
||||
import { parseSSEBuffer, formatEvent } from '../src/cli/commands/watch.js';
|
||||
import { init } from '../src/cli/commands/init.js';
|
||||
import { startEntityWatcher } from '../src/server/fsWatch.js';
|
||||
import { createTask, claimTask } from '../src/core/services/taskService.js';
|
||||
|
||||
// ─── 1. Unit: SSE buffer parser ──────────────────────────────────────────────
|
||||
|
||||
@ -320,3 +322,112 @@ describe('SSE stream e2e', () => {
|
||||
expect(received).toHaveLength(0);
|
||||
}, 5000);
|
||||
});
|
||||
|
||||
// ─── 5. fsWatch: CLI / direct file writes also emit (TSK-0006 part 2) ─────────
|
||||
// The whole point of the watcher: a `agenthub task create` (no --server) writes
|
||||
// the entity file directly, bypassing the REST emit path — yet a connected
|
||||
// watcher must still be triggered. Here we drive the services directly (exactly
|
||||
// what the CLI does) and assert the eventBus fires.
|
||||
|
||||
async function waitFor(
|
||||
collected: AgentHubEvent[],
|
||||
pred: (e: AgentHubEvent) => boolean,
|
||||
ms = 2000,
|
||||
): Promise<AgentHubEvent | undefined> {
|
||||
const deadline = Date.now() + ms;
|
||||
while (Date.now() < deadline) {
|
||||
const hit = collected.find(pred);
|
||||
if (hit) return hit;
|
||||
await new Promise((r) => setTimeout(r, 25));
|
||||
}
|
||||
return collected.find(pred);
|
||||
}
|
||||
|
||||
describe('fsWatch emits for non-REST (CLI/file) writes', () => {
|
||||
let cwd: string;
|
||||
let stop: () => void;
|
||||
const collected: AgentHubEvent[] = [];
|
||||
const onChange = (e: AgentHubEvent) => collected.push(e);
|
||||
|
||||
beforeEach(async () => {
|
||||
cwd = mkdtempSync(join(tmpdir(), 'ah-fswatch-'));
|
||||
init(cwd, { projectName: 'fswatch-test', yes: true });
|
||||
collected.length = 0;
|
||||
eventBus.on('change', onChange);
|
||||
stop = startEntityWatcher(cwd);
|
||||
// fs.watch (FSEvents on macOS) needs a brief moment after watch() before it
|
||||
// reliably delivers; a write fired in that gap is missed. The server starts
|
||||
// its watcher long before any write, so this only matters for the test.
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stop();
|
||||
eventBus.off('change', onChange);
|
||||
collected.length = 0;
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('emits task/created when a task is written via the service (CLI path)', async () => {
|
||||
const task = createTask(cwd, { title: 'CLI-created task', role: 'implementer' });
|
||||
const ev = await waitFor(collected, (e) => e.type === 'task' && e.id === task.id);
|
||||
expect(ev).toBeDefined();
|
||||
expect(ev).toMatchObject({
|
||||
type: 'task',
|
||||
action: 'created',
|
||||
id: task.id,
|
||||
title: 'CLI-created task',
|
||||
role: 'implementer',
|
||||
});
|
||||
}, 4000);
|
||||
|
||||
it('emits task/updated when a task file is mutated via the service (claim)', async () => {
|
||||
const task = createTask(cwd, { title: 'T', role: 'implementer' });
|
||||
await waitFor(collected, (e) => e.id === task.id); // drain the created event
|
||||
collected.length = 0;
|
||||
|
||||
claimTask(cwd, task.id, 'windows-claude');
|
||||
const ev = await waitFor(collected, (e) => e.id === task.id && e.action === 'updated');
|
||||
expect(ev).toBeDefined();
|
||||
expect(ev).toMatchObject({ type: 'task', action: 'updated', status: 'in_progress', assignedTo: 'windows-claude' });
|
||||
}, 4000);
|
||||
});
|
||||
|
||||
// ─── 6. fsWatch + REST dedup: each change is delivered exactly once ────────────
|
||||
|
||||
describe('fsWatch dedup with the REST emit path', () => {
|
||||
let cwd: string;
|
||||
let server: Awaited<ReturnType<typeof startServer>>;
|
||||
const collected: AgentHubEvent[] = [];
|
||||
const onChange = (e: AgentHubEvent) => collected.push(e);
|
||||
|
||||
beforeEach(async () => {
|
||||
cwd = mkdtempSync(join(tmpdir(), 'ah-fswatch-dedup-'));
|
||||
init(cwd, { projectName: 'fswatch-dedup', yes: true });
|
||||
collected.length = 0;
|
||||
eventBus.on('change', onChange);
|
||||
server = await startServer(cwd, { host: '127.0.0.1', port: 0 });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
eventBus.off('change', onChange);
|
||||
collected.length = 0;
|
||||
await server.app.close();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('REST POST /tasks emits exactly once (watcher echo suppressed)', async () => {
|
||||
await fetch(`${server.url}/tasks`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title: 'dedup task', role: 'implementer' }),
|
||||
});
|
||||
|
||||
// Wait well past the watcher debounce so any duplicate would have landed.
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
|
||||
const forTask = collected.filter((e) => e.type === 'task' && e.id === 'TSK-0001');
|
||||
expect(forTask).toHaveLength(1);
|
||||
expect(forTask[0].action).toBe('created');
|
||||
}, 4000);
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user