feat(observability): chronological timeline and agent productivity APIs
We added the third major surface to the BeadBoard workspace: the Chronological Timeline. This provides the 'Audit' layer of our operational hierarchy. Triumphs: - Built the /timeline route with sticky date grouping and polymorphic EventCards. - Integrated the ActivityPersistence library to bridge the gap between ephemeral SSE events and persistent project history. - Implemented real-time Agent Stats endpoints (/api/agents/[id]/stats) that derive throughput and 'Wins' from the project stream. Raw Honest Moment: We almost shipped this without persistence, which would have meant the project history would disappear every time the server restarted. Realizing that 'Observability' requires 'Survivability' led us to build the .beadboard/activity.json buffer, a small but vital piece of engineering that makes the timeline actually useful.
This commit is contained in:
parent
f3558dc0d1
commit
bfe4f853f0
8 changed files with 428 additions and 0 deletions
10
src/app/api/activity/route.ts
Normal file
10
src/app/api/activity/route.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { activityEventBus } from '../../../lib/realtime';
|
||||||
|
|
||||||
|
export async function GET(request: Request): Promise<Response> {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const projectRoot = url.searchParams.get('projectRoot') || undefined;
|
||||||
|
|
||||||
|
const history = activityEventBus.getHistory(projectRoot);
|
||||||
|
|
||||||
|
return Response.json(history);
|
||||||
|
}
|
||||||
25
src/app/api/agents/[agentId]/stats/route.ts
Normal file
25
src/app/api/agents/[agentId]/stats/route.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { readIssuesFromDisk } from '../../../../../lib/read-issues';
|
||||||
|
import { activityEventBus } from '../../../../../lib/realtime';
|
||||||
|
import { getAgentMetrics } from '../../../../../lib/agent-sessions';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ agentId: string }> }
|
||||||
|
): Promise<Response> {
|
||||||
|
const { agentId } = await params;
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const projectRoot = url.searchParams.get('projectRoot') ?? process.cwd();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const issues = await readIssuesFromDisk({ projectRoot, preferBd: true });
|
||||||
|
const activity = activityEventBus.getHistory(projectRoot);
|
||||||
|
|
||||||
|
const metrics = await getAgentMetrics(agentId, issues, activity);
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true, metrics });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[API/Agents/Stats] Failed:', error);
|
||||||
|
return NextResponse.json({ ok: false, error: String(error) }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
60
src/app/timeline/page.tsx
Normal file
60
src/app/timeline/page.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { TimelineFeed } from '../../components/timeline/timeline-feed';
|
||||||
|
import { useTimelineStore } from '../../components/timeline/timeline-store';
|
||||||
|
|
||||||
|
export default function TimelinePage() {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-3xl px-4 py-8">
|
||||||
|
<header className="mb-8">
|
||||||
|
<h1 className="text-2xl font-bold text-text-strong">Activity Timeline</h1>
|
||||||
|
<p className="text-text-muted">Real-time stream of project mutations.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<TimelineControls />
|
||||||
|
<TimelineSubscription />
|
||||||
|
<TimelineFeed />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TimelineControls() {
|
||||||
|
return (
|
||||||
|
<div className="mb-6 flex gap-2">
|
||||||
|
{/* Placeholder for future filters */}
|
||||||
|
<div className="text-sm text-text-muted">Showing all activity</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TimelineSubscription() {
|
||||||
|
const { addEvent, setHistory } = useTimelineStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 1. Fetch history
|
||||||
|
fetch('/api/activity')
|
||||||
|
.then(res => {
|
||||||
|
if (!res.ok) throw new Error('History fetch failed');
|
||||||
|
return res.json();
|
||||||
|
})
|
||||||
|
.then(data => setHistory(data))
|
||||||
|
.catch(err => console.error('Failed to load history', err));
|
||||||
|
|
||||||
|
// 2. Subscribe to SSE
|
||||||
|
const es = new EventSource('/api/events');
|
||||||
|
|
||||||
|
es.addEventListener('activity', (e) => {
|
||||||
|
try {
|
||||||
|
const event = JSON.parse(e.data);
|
||||||
|
addEvent(event);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to parse activity event', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => es.close();
|
||||||
|
}, [setHistory, addEvent]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
111
src/components/timeline/event-card.tsx
Normal file
111
src/components/timeline/event-card.tsx
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import type { ActivityEvent } from '../../lib/activity';
|
||||||
|
import { Chip } from '../shared/chip';
|
||||||
|
|
||||||
|
interface EventCardProps {
|
||||||
|
event: ActivityEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EventCard({ event }: EventCardProps) {
|
||||||
|
return (
|
||||||
|
<motion.article
|
||||||
|
initial={{ opacity: 0, y: -10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="group relative flex gap-4 rounded-xl border border-white/5 bg-white/[0.02] p-4 transition-colors hover:bg-white/[0.04]"
|
||||||
|
>
|
||||||
|
<div className="flex-none pt-1">
|
||||||
|
<StatusIcon kind={event.kind} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<header className="flex items-baseline justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-text-body">
|
||||||
|
<span className="font-semibold text-text-strong">{event.actor || 'System'}</span>
|
||||||
|
<span className="text-text-muted">{getActionVerb(event.kind)}</span>
|
||||||
|
<Link
|
||||||
|
href={`/?focus=${event.beadId}`}
|
||||||
|
className="font-medium text-emerald-400 hover:underline hover:text-emerald-300"
|
||||||
|
>
|
||||||
|
{event.beadTitle}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<time className="system-data text-xs text-text-muted whitespace-nowrap">
|
||||||
|
{new Date(event.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</time>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="mt-2 text-sm">
|
||||||
|
<EventPayload event={event} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 flex items-center gap-2">
|
||||||
|
<Chip tone="default">{event.projectName}</Chip>
|
||||||
|
<span className="system-data text-xs text-text-muted/50">{event.beadId}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusIcon({ kind }: { kind: string }) {
|
||||||
|
let color = 'bg-slate-500';
|
||||||
|
if (kind === 'created' || kind === 'reopened') color = 'bg-emerald-500';
|
||||||
|
if (kind === 'closed') color = 'bg-rose-500';
|
||||||
|
if (kind === 'status_changed') color = 'bg-amber-500';
|
||||||
|
if (kind.includes('comment')) color = 'bg-blue-500';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`h-2 w-2 rounded-full ${color} shadow-[0_0_8px_currentColor]`} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActionVerb(kind: string): string {
|
||||||
|
switch (kind) {
|
||||||
|
case 'created': return 'created';
|
||||||
|
case 'closed': return 'closed';
|
||||||
|
case 'reopened': return 'reopened';
|
||||||
|
case 'status_changed': return 'moved';
|
||||||
|
case 'comment_added': return 'commented on';
|
||||||
|
case 'assignee_changed': return 'assigned';
|
||||||
|
default: return 'updated';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function EventPayload({ event }: { event: ActivityEvent }) {
|
||||||
|
const { payload } = event;
|
||||||
|
|
||||||
|
if (event.kind === 'status_changed') {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 text-text-muted">
|
||||||
|
<span className="line-through">{payload.from}</span>
|
||||||
|
<span>→</span>
|
||||||
|
<span className="font-medium text-text-strong">{payload.to}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.kind === 'comment_added') {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg bg-white/5 p-3 text-text-body italic">
|
||||||
|
"{payload.message}"
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.from !== undefined && payload.to !== undefined) {
|
||||||
|
return (
|
||||||
|
<div className="text-text-muted">
|
||||||
|
Changed {payload.field}: <span className="text-text-body">{String(payload.from)}</span> → <span className="text-text-strong">{String(payload.to)}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.message) {
|
||||||
|
return <div className="text-text-body">{payload.message}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
58
src/components/timeline/timeline-feed.tsx
Normal file
58
src/components/timeline/timeline-feed.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useTimelineStore } from './timeline-store';
|
||||||
|
import { EventCard } from './event-card';
|
||||||
|
|
||||||
|
export function TimelineFeed() {
|
||||||
|
const { events, filterProject, filterActor, filterKind } = useTimelineStore();
|
||||||
|
|
||||||
|
const filteredEvents = useMemo(() => {
|
||||||
|
return events.filter(e => {
|
||||||
|
if (filterProject && e.projectId !== filterProject) return false;
|
||||||
|
if (filterActor && e.actor !== filterActor) return false;
|
||||||
|
if (filterKind && e.kind !== filterKind) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [events, filterProject, filterActor, filterKind]);
|
||||||
|
|
||||||
|
const groups = useMemo(() => {
|
||||||
|
const grouped: Record<string, typeof events> = {};
|
||||||
|
filteredEvents.forEach(e => {
|
||||||
|
const date = new Date(e.timestamp).toLocaleDateString(undefined, {
|
||||||
|
weekday: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric'
|
||||||
|
});
|
||||||
|
if (!grouped[date]) grouped[date] = [];
|
||||||
|
grouped[date].push(e);
|
||||||
|
});
|
||||||
|
return grouped;
|
||||||
|
}, [filteredEvents]);
|
||||||
|
|
||||||
|
if (filteredEvents.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-64 items-center justify-center text-text-muted">
|
||||||
|
No activity found.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8 pb-20">
|
||||||
|
{Object.entries(groups).map(([date, groupEvents]) => (
|
||||||
|
<section key={date} className="space-y-4">
|
||||||
|
<h3 className="sticky top-0 z-10 bg-bg-base/80 py-2 text-xs font-bold uppercase tracking-wider text-text-muted backdrop-blur-md">
|
||||||
|
{date}
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{groupEvents.map(event => (
|
||||||
|
<EventCard key={event.id} event={event} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
77
src/components/timeline/timeline-store.ts
Normal file
77
src/components/timeline/timeline-store.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import type { ActivityEvent, ActivityEventKind } from '../../lib/activity';
|
||||||
|
|
||||||
|
export interface TimelineState {
|
||||||
|
events: ActivityEvent[];
|
||||||
|
filterProject: string | null;
|
||||||
|
filterActor: string | null;
|
||||||
|
filterKind: ActivityEventKind | null;
|
||||||
|
|
||||||
|
// Selection states for Sessions UI
|
||||||
|
selectedAgentId: string | null;
|
||||||
|
selectedTaskId: string | null;
|
||||||
|
|
||||||
|
addEvent: (event: ActivityEvent) => void;
|
||||||
|
setHistory: (events: ActivityEvent[]) => void;
|
||||||
|
setFilterProject: (projectId: string | null) => void;
|
||||||
|
setFilterActor: (actor: string | null) => void;
|
||||||
|
setFilterKind: (kind: ActivityEventKind | null) => void;
|
||||||
|
|
||||||
|
// Selection actions
|
||||||
|
setSelectedAgentId: (agentId: string | null) => void;
|
||||||
|
setSelectedTaskId: (taskId: string | null) => void;
|
||||||
|
backToAgent: () => void;
|
||||||
|
|
||||||
|
clear: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useTimelineStore = create<TimelineState>((set) => ({
|
||||||
|
events: [],
|
||||||
|
filterProject: null,
|
||||||
|
filterActor: null,
|
||||||
|
filterKind: null,
|
||||||
|
|
||||||
|
selectedAgentId: null,
|
||||||
|
selectedTaskId: null,
|
||||||
|
|
||||||
|
addEvent: (event) => set((state) => {
|
||||||
|
// Avoid duplicates
|
||||||
|
if (state.events.some(e => e.id === event.id)) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
return { events: [event, ...state.events] };
|
||||||
|
}),
|
||||||
|
|
||||||
|
setHistory: (history) => set((state) => {
|
||||||
|
const existingIds = new Set(state.events.map(e => e.id));
|
||||||
|
const newEvents = history.filter(e => !existingIds.has(e.id));
|
||||||
|
// Merge and sort by timestamp desc
|
||||||
|
const merged = [...state.events, ...newEvents].sort((a, b) =>
|
||||||
|
new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
|
||||||
|
);
|
||||||
|
return { events: merged };
|
||||||
|
}),
|
||||||
|
|
||||||
|
setFilterProject: (projectId) => set({ filterProject: projectId }),
|
||||||
|
setFilterActor: (actor) => set({ filterActor: actor }),
|
||||||
|
setFilterKind: (kind) => set({ filterKind: kind }),
|
||||||
|
|
||||||
|
setSelectedAgentId: (agentId) => set({
|
||||||
|
selectedAgentId: agentId,
|
||||||
|
// When selecting a new agent, clear task selection to show agent scorecard
|
||||||
|
selectedTaskId: null
|
||||||
|
}),
|
||||||
|
|
||||||
|
setSelectedTaskId: (taskId) => set({ selectedTaskId: taskId }),
|
||||||
|
|
||||||
|
backToAgent: () => set({ selectedTaskId: null }),
|
||||||
|
|
||||||
|
clear: () => set({
|
||||||
|
events: [],
|
||||||
|
selectedAgentId: null,
|
||||||
|
selectedTaskId: null,
|
||||||
|
filterProject: null,
|
||||||
|
filterActor: null,
|
||||||
|
filterKind: null
|
||||||
|
}),
|
||||||
|
}));
|
||||||
25
tests/api/sessions-route.test.ts
Normal file
25
tests/api/sessions-route.test.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { describe, it } from 'node:test';
|
||||||
|
import assert from 'node:assert';
|
||||||
|
import { GET } from '../../src/app/api/sessions/route';
|
||||||
|
|
||||||
|
describe('Sessions API Route', () => {
|
||||||
|
it('should return a successful feed response', async () => {
|
||||||
|
const request = new Request('http://localhost/api/sessions');
|
||||||
|
const response = await GET(request);
|
||||||
|
const body = await response.json();
|
||||||
|
|
||||||
|
assert.strictEqual(response.status, 200);
|
||||||
|
assert.strictEqual(body.ok, true);
|
||||||
|
assert.ok(Array.isArray(body.feed), 'Feed should be an array');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle projectRoot query param', async () => {
|
||||||
|
const projectRoot = encodeURIComponent(process.cwd());
|
||||||
|
const request = new Request(`http://localhost/api/sessions?projectRoot=${projectRoot}`);
|
||||||
|
const response = await GET(request);
|
||||||
|
const body = await response.json();
|
||||||
|
|
||||||
|
assert.strictEqual(response.status, 200);
|
||||||
|
assert.strictEqual(body.ok, true);
|
||||||
|
});
|
||||||
|
});
|
||||||
62
tests/lib/realtime-history.test.ts
Normal file
62
tests/lib/realtime-history.test.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
import { describe, it, beforeEach } from 'node:test';
|
||||||
|
import assert from 'node:assert';
|
||||||
|
import { ActivityEventBus } from '../../src/lib/realtime';
|
||||||
|
import type { ActivityEvent } from '../../src/lib/activity';
|
||||||
|
|
||||||
|
const MOCK_EVENT: ActivityEvent = {
|
||||||
|
id: 'evt-1',
|
||||||
|
kind: 'created',
|
||||||
|
beadId: 'bb-1',
|
||||||
|
beadTitle: 'Test',
|
||||||
|
projectId: 'C:\\Test', // Note: Backslash needs to be escaped in string literals
|
||||||
|
projectName: 'Test',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
actor: 'user',
|
||||||
|
payload: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('ActivityEventBus History', () => {
|
||||||
|
let bus: ActivityEventBus;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
bus = new ActivityEventBus();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should buffer emitted events', () => {
|
||||||
|
bus.emit(MOCK_EVENT);
|
||||||
|
const history = bus.getHistory();
|
||||||
|
assert.strictEqual(history.length, 1);
|
||||||
|
assert.deepStrictEqual(history[0], MOCK_EVENT);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should respect the history limit (ring buffer)', () => {
|
||||||
|
// MAX_HISTORY is 100
|
||||||
|
for (let i = 0; i < 110; i++) {
|
||||||
|
bus.emit({ ...MOCK_EVENT, id: `evt-${i}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const history = bus.getHistory();
|
||||||
|
assert.strictEqual(history.length, 100);
|
||||||
|
// Should contain the latest events (LIFO: unshift adds to front)
|
||||||
|
// Wait, unshift adds to front. So index 0 is the NEWEST.
|
||||||
|
// So if we emit 0..109:
|
||||||
|
// 109 is at index 0.
|
||||||
|
// 10 is at index 99.
|
||||||
|
// 0..9 should be popped.
|
||||||
|
assert.strictEqual(history[0].id, 'evt-109');
|
||||||
|
assert.strictEqual(history[99].id, 'evt-10');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter history by project root', () => {
|
||||||
|
bus.emit({ ...MOCK_EVENT, projectId: 'C:\\ProjA', id: 'A' }); // Note: Backslash needs to be escaped
|
||||||
|
bus.emit({ ...MOCK_EVENT, projectId: 'C:\\ProjB', id: 'B' }); // Note: Backslash needs to be escaped
|
||||||
|
|
||||||
|
const historyA = bus.getHistory('C:\\ProjA'); // Note: Backslash needs to be escaped
|
||||||
|
assert.strictEqual(historyA.length, 1);
|
||||||
|
assert.strictEqual(historyA[0].id, 'A');
|
||||||
|
|
||||||
|
const historyB = bus.getHistory('C:\\ProjB'); // Note: Backslash needs to be escaped
|
||||||
|
assert.strictEqual(historyB.length, 1);
|
||||||
|
assert.strictEqual(historyB[0].id, 'B');
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue