feat(telemetry): complete bb-buff.1.3 - Backend Liveness Refactor
STORY: The session backend needed to aggregate agent health from a live telemetry stream rather than static bead metadata. This refactor makes liveness signals real-time and accurate. COLLABORATION: We extended the ActivityEvent model with a native 'heartbeat' kind, updated extendActivityLease() to emit through the activity bus, and refactored getAgentLivenessMap() to prioritize heartbeat activity history over stale bead metadata. DELIVERABLES: - ActivityEvent extended with 'heartbeat' kind - extendActivityLease() emits heartbeats through activity bus - getAgentLivenessMap() prefers telemetry over static metadata - Registry APIs support projectRoot injection for testing - Tests verify preference logic via TDD VERIFICATION: - 93/93 tests PASSING - Heartbeat override verified in isolated temp projects CLOSES: bb-buff.1.3 BLOCKS: bb-buff.3.2, bb-buff.3.3, bb-buff.2.1
This commit is contained in:
parent
0016b57e37
commit
4ee550c333
36 changed files with 1380 additions and 541 deletions
|
|
@ -9,19 +9,39 @@ interface SessionFeedCardProps {
|
|||
onSelect: (id: string) => void;
|
||||
isHighlighted?: boolean;
|
||||
incursion?: Incursion;
|
||||
highlightSource?: 'task' | 'agent';
|
||||
}
|
||||
|
||||
export function SessionFeedCard({ card, onSelect, isHighlighted, incursion }: SessionFeedCardProps) {
|
||||
export function SessionFeedCard({ card, onSelect, isHighlighted, incursion, highlightSource }: SessionFeedCardProps) {
|
||||
return (
|
||||
<motion.article
|
||||
layout
|
||||
onClick={() => onSelect(card.id)}
|
||||
className={`relative w-full cursor-pointer rounded-[1.25rem] border p-[1rem] text-left transition-all duration-200 ${
|
||||
isHighlighted
|
||||
? 'border-sky-500 bg-sky-500/10 ring-1 ring-sky-500/50 scale-[1.02] shadow-[0_0_20px_rgba(56,189,248,0.15)]'
|
||||
? highlightSource === 'agent'
|
||||
? 'border-emerald-500/50 bg-emerald-500/10 ring-1 ring-emerald-500/30 scale-[1.01] shadow-[0_0_16px_rgba(16,185,129,0.15)]'
|
||||
: 'border-sky-500 bg-sky-500/10 ring-1 ring-sky-500/50 scale-[1.02] shadow-[0_0_20px_rgba(56,189,248,0.15)]'
|
||||
: `${statusBorder(card.status)} ${statusGradient(card.status)} hover:bg-white/[0.04]`
|
||||
} ${sessionStateGlow(card.sessionState)} ${incursion ? 'ring-1 ring-rose-500/30' : ''}`}
|
||||
>
|
||||
{/* Critical state badges */}
|
||||
{card.sessionState === 'stuck' && (
|
||||
<div className="absolute -top-2 right-4 z-10">
|
||||
<span className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[0.55rem] font-black uppercase tracking-[0.1em] bg-red-500/80 text-white border border-red-400 shadow-lg animate-pulse">
|
||||
<span aria-hidden="true">⚠</span>
|
||||
STUCK
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{card.sessionState === 'dead' && (
|
||||
<div className="absolute -top-2 right-4 z-10">
|
||||
<span className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[0.55rem] font-black uppercase tracking-[0.1em] bg-zinc-700/80 text-zinc-300 border border-zinc-600 shadow-lg">
|
||||
<span aria-hidden="true">✕</span>
|
||||
OFFLINE
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{incursion && (
|
||||
<div className="absolute -top-2 right-4 z-10">
|
||||
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[0.55rem] font-black uppercase tracking-[0.1em] border shadow-lg animate-pulse ${
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ interface SessionTaskFeedProps {
|
|||
selectedEpicId: string | null;
|
||||
onSelectTask: (id: string) => void;
|
||||
highlightTaskId?: string | null;
|
||||
highlightingAgentId?: string | null;
|
||||
}
|
||||
|
||||
export function IncursionTicker({ incursions }: { incursions: Incursion[] }) {
|
||||
|
|
@ -35,7 +36,7 @@ export function IncursionTicker({ incursions }: { incursions: Incursion[] }) {
|
|||
);
|
||||
}
|
||||
|
||||
export function SessionTaskFeed({ feed, incursions = [], selectedEpicId, onSelectTask, highlightTaskId }: SessionTaskFeedProps) {
|
||||
export function SessionTaskFeed({ feed, incursions = [], selectedEpicId, onSelectTask, highlightTaskId, highlightingAgentId }: SessionTaskFeedProps) {
|
||||
const filteredFeed = useMemo(() => {
|
||||
if (!selectedEpicId) return feed;
|
||||
return feed.filter(b => b.epic.id === selectedEpicId);
|
||||
|
|
@ -88,13 +89,15 @@ export function SessionTaskFeed({ feed, incursions = [], selectedEpicId, onSelec
|
|||
task.owner && inc.agents.includes(task.owner)
|
||||
);
|
||||
|
||||
const isAgentMission = highlightingAgentId ? task.owner === highlightingAgentId : false;
|
||||
return (
|
||||
<SessionFeedCard
|
||||
key={task.id}
|
||||
card={task}
|
||||
onSelect={onSelectTask}
|
||||
isHighlighted={highlightTaskId === task.id}
|
||||
isHighlighted={highlightTaskId === task.id || isAgentMission}
|
||||
incursion={taskIncursion}
|
||||
highlightSource={isAgentMission ? 'agent' : undefined}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,16 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { AgentRecord, AgentLiveness } from '../../lib/agent-registry';
|
||||
import { ProjectScopeControls } from '../shared/project-scope-controls';
|
||||
import type { ProjectScopeOption } from '../../lib/project-scope';
|
||||
import { AgentStation } from './agent-station';
|
||||
import { getSwarmHealth } from './sessions-header-logic';
|
||||
|
||||
export interface SwarmGroup {
|
||||
swarmId: string;
|
||||
swarmLabel: string;
|
||||
members: AgentRecord[];
|
||||
}
|
||||
|
||||
interface SessionsHeaderProps {
|
||||
agents: AgentRecord[];
|
||||
|
|
@ -18,6 +25,9 @@ interface SessionsHeaderProps {
|
|||
completed: number;
|
||||
};
|
||||
livenessMap?: Record<string, string>;
|
||||
swarmGroups?: SwarmGroup[];
|
||||
unassignedAgents?: AgentRecord[];
|
||||
missionCounts?: Record<string, number>;
|
||||
}
|
||||
|
||||
export function SessionsHeader({
|
||||
|
|
@ -29,6 +39,9 @@ export function SessionsHeader({
|
|||
projectScopeOptions,
|
||||
stats,
|
||||
livenessMap = {},
|
||||
swarmGroups = [],
|
||||
unassignedAgents = [],
|
||||
missionCounts = {},
|
||||
}: SessionsHeaderProps) {
|
||||
return (
|
||||
<header className="sticky top-0 z-50 flex flex-col border-b border-white/5 bg-[#0b0c10]/60 backdrop-blur-3xl shadow-2xl">
|
||||
|
|
@ -40,15 +53,68 @@ export function SessionsHeader({
|
|||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 overflow-x-auto no-scrollbar py-1">
|
||||
{agents.map((agent) => (
|
||||
<AgentStation
|
||||
key={agent.agent_id}
|
||||
agent={agent}
|
||||
isSelected={activeAgentId === agent.agent_id}
|
||||
onSelect={onSelectAgent}
|
||||
liveness={(livenessMap[agent.agent_id] as AgentLiveness) || 'active'}
|
||||
/>
|
||||
))}
|
||||
{swarmGroups.length > 0 || unassignedAgents.length > 0 ? (
|
||||
<div className="flex items-center gap-4">
|
||||
{swarmGroups.map((group) => {
|
||||
const health = getSwarmHealth(group.members, livenessMap);
|
||||
return (
|
||||
<div key={group.swarmId} className="swarm-container flex items-center gap-2">
|
||||
<div className="flex flex-col items-end">
|
||||
<span className="ui-text text-[0.55rem] font-black uppercase tracking-[0.15em] text-sky-400/50 whitespace-nowrap">
|
||||
{group.swarmLabel}
|
||||
</span>
|
||||
<span className={`ui-text text-[0.45rem] font-bold uppercase tracking-wider ${health.color} flex items-center gap-0.5`}>
|
||||
<span className="text-xs">●</span>
|
||||
{health.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{group.members.map((agent) => (
|
||||
<AgentStation
|
||||
key={agent.agent_id}
|
||||
agent={agent}
|
||||
isSelected={activeAgentId === agent.agent_id}
|
||||
onSelect={onSelectAgent}
|
||||
liveness={(livenessMap[agent.agent_id] as AgentLiveness) || 'active'}
|
||||
missionCount={missionCounts[agent.agent_id]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{unassignedAgents.length > 0 && (
|
||||
<div className="swarm-container flex items-center gap-2">
|
||||
<span className="ui-text text-[0.55rem] font-black uppercase tracking-[0.15em] text-zinc-500/40 whitespace-nowrap">
|
||||
No Swarm
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{unassignedAgents.map((agent) => (
|
||||
<AgentStation
|
||||
key={agent.agent_id}
|
||||
agent={agent}
|
||||
isSelected={activeAgentId === agent.agent_id}
|
||||
onSelect={onSelectAgent}
|
||||
liveness={(livenessMap[agent.agent_id] as AgentLiveness) || 'active'}
|
||||
missionCount={missionCounts[agent.agent_id]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
agents.map((agent) => (
|
||||
<AgentStation
|
||||
key={agent.agent_id}
|
||||
agent={agent}
|
||||
isSelected={activeAgentId === agent.agent_id}
|
||||
onSelect={onSelectAgent}
|
||||
liveness={(livenessMap[agent.agent_id] as AgentLiveness) || 'active'}
|
||||
missionCount={missionCounts[agent.agent_id]}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -78,95 +144,6 @@ export function SessionsHeader({
|
|||
);
|
||||
}
|
||||
|
||||
function useTimeAgo(isoTimestamp: string) {
|
||||
const [timeAgo, setTimeAgo] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const update = () => {
|
||||
const seconds = Math.floor((new Date().getTime() - new Date(isoTimestamp).getTime()) / 1000);
|
||||
if (seconds < 60) setTimeAgo(`${seconds}s`);
|
||||
else if (seconds < 3600) setTimeAgo(`${Math.floor(seconds / 60)}m`);
|
||||
else setTimeAgo(`${Math.floor(seconds / 3600)}h`);
|
||||
};
|
||||
update();
|
||||
const interval = setInterval(update, 10000);
|
||||
return () => clearInterval(interval);
|
||||
}, [isoTimestamp]);
|
||||
|
||||
return timeAgo;
|
||||
}
|
||||
|
||||
function AgentStation({
|
||||
agent,
|
||||
isSelected,
|
||||
onSelect,
|
||||
liveness
|
||||
}: {
|
||||
agent: AgentRecord,
|
||||
isSelected: boolean,
|
||||
onSelect: (id: string | null) => void,
|
||||
liveness: AgentLiveness
|
||||
}) {
|
||||
const timeAgo = useTimeAgo(agent.last_seen_at);
|
||||
|
||||
const statusStyles = {
|
||||
active: {
|
||||
dot: 'bg-emerald-500 animate-pulse shadow-[0_0_10px_rgba(16,185,129,0.8)]',
|
||||
label: 'On Mission',
|
||||
color: 'text-emerald-400/60'
|
||||
},
|
||||
stale: {
|
||||
dot: 'bg-amber-500 shadow-[0_0_8px_rgba(245,158,11,0.5)]',
|
||||
label: 'Lease Expiring',
|
||||
color: 'text-amber-400/60'
|
||||
},
|
||||
evicted: {
|
||||
dot: 'bg-rose-500/50 shadow-none',
|
||||
label: 'Disconnected',
|
||||
color: 'text-rose-400/40'
|
||||
},
|
||||
idle: {
|
||||
dot: 'bg-zinc-700 shadow-none',
|
||||
label: 'Idle',
|
||||
color: 'text-zinc-500/30'
|
||||
}
|
||||
}[liveness];
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => onSelect(isSelected ? null : agent.agent_id)}
|
||||
className={`flex-none group flex w-[10rem] items-center gap-2 rounded-lg border px-2 py-1.5 transition-all duration-300 ${
|
||||
isSelected
|
||||
? 'border-sky-500/50 bg-sky-500/10 shadow-[0_0_10px_rgba(14,165,233,0.1)]'
|
||||
: 'border-white/5 bg-white/[0.01] hover:bg-white/5'
|
||||
} ${liveness === 'idle' ? 'opacity-40 grayscale-[0.5]' : ''}`}
|
||||
>
|
||||
<div className="relative flex-none">
|
||||
<div className={`h-7 w-7 rounded-md bg-gradient-to-br from-zinc-700 to-zinc-900 flex items-center justify-center border border-white/10 shadow-inner transition-transform duration-300 ${isSelected ? 'scale-90' : 'group-hover:scale-105'}`}>
|
||||
<span className="ui-text text-[0.6rem] font-black text-zinc-400">
|
||||
{agent.agent_id.slice(0, 2).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<span className={`absolute -bottom-0.5 -right-0.5 h-2 w-2 rounded-full border-2 border-[#0b0c10] ${statusStyles.dot}`} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-start min-w-0 text-left">
|
||||
<div className="flex items-center gap-1 w-full justify-between pr-1">
|
||||
<span className={`ui-text text-[0.65rem] font-black truncate transition-colors ${isSelected ? 'text-sky-300' : 'text-text-body'}`}>
|
||||
{agent.agent_id}
|
||||
</span>
|
||||
<span className="system-data text-[0.5rem] font-bold text-text-muted/40">
|
||||
{timeAgo}
|
||||
</span>
|
||||
</div>
|
||||
<span className={`system-data text-[0.5rem] font-bold uppercase tracking-tighter ${statusStyles.color}`}>
|
||||
{statusStyles.label}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function StatPill({ label, value, color }: { label: string, value: number, color: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1 rounded-full border border-white/5 bg-white/5 px-1.5 py-0.5">
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@ import type { AgentRecord } from '../../lib/agent-registry';
|
|||
import { EpicChipStrip } from '../shared/epic-chip-strip';
|
||||
import { SessionTaskFeed } from './session-task-feed';
|
||||
import { ConversationDrawer } from './conversation-drawer';
|
||||
import { SessionsHeader } from './sessions-header';
|
||||
import { SessionsHeader, type SwarmGroup } from './sessions-header';
|
||||
import { getMissionsByAgent } from '../../lib/agent-sessions';
|
||||
|
||||
interface SessionsPageProps {
|
||||
issues: BeadIssue[];
|
||||
|
|
@ -19,6 +20,8 @@ interface SessionsPageProps {
|
|||
projectScopeKey: string;
|
||||
projectScopeOptions: ProjectScopeOption[];
|
||||
projectScopeMode: 'single' | 'aggregate';
|
||||
swarmGroups?: SwarmGroup[];
|
||||
unassignedAgents?: AgentRecord[];
|
||||
}
|
||||
|
||||
export function SessionsPage({
|
||||
|
|
@ -28,10 +31,21 @@ export function SessionsPage({
|
|||
projectScopeKey,
|
||||
projectScopeOptions,
|
||||
projectScopeMode,
|
||||
swarmGroups = [],
|
||||
unassignedAgents = [],
|
||||
}: SessionsPageProps) {
|
||||
// 2. Session-specific feed
|
||||
const { feed, incursions, livenessMap, loading, refresh: refreshFeed, stats } = useSessionFeed(projectRoot);
|
||||
|
||||
// Compute mission counts for agent header badges
|
||||
const missionCounts = useMemo(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
const missionsByAgent = getMissionsByAgent(feed);
|
||||
for (const [agentId, missions] of Object.entries(missionsByAgent)) {
|
||||
counts[agentId] = missions.length;
|
||||
}
|
||||
return counts;
|
||||
}, [feed]);
|
||||
|
||||
const {
|
||||
selectedAgentId,
|
||||
selectedTaskId,
|
||||
|
|
@ -43,11 +57,11 @@ export function SessionsPage({
|
|||
const [selectedEpicId, setSelectedEpicId] = useState<string | null>(null);
|
||||
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
||||
|
||||
// 1. Basic subscription for SSE invalidation
|
||||
const { refresh: refreshIssues, issues: localIssues } = useBeadsSubscription(initialIssues, projectRoot, {
|
||||
onUpdate: () => {
|
||||
console.log('[Sessions] SSE update detected. Scheduling silent refresh...');
|
||||
// Small delay to ensure backend files are flushed
|
||||
onUpdate: (kind) => {
|
||||
if (kind === 'telemetry') return;
|
||||
|
||||
console.log(`[Sessions] ${kind} update detected. Scheduling silent refresh...`);
|
||||
setTimeout(() => {
|
||||
void refreshFeed({ silent: true });
|
||||
setRefreshTrigger(prev => prev + 1);
|
||||
|
|
@ -74,6 +88,9 @@ export function SessionsPage({
|
|||
projectScopeOptions={projectScopeOptions}
|
||||
stats={stats}
|
||||
livenessMap={livenessMap}
|
||||
swarmGroups={swarmGroups}
|
||||
unassignedAgents={unassignedAgents}
|
||||
missionCounts={missionCounts}
|
||||
/>
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
|
|
@ -102,6 +119,7 @@ export function SessionsPage({
|
|||
selectedEpicId={selectedEpicId}
|
||||
onSelectTask={setSelectedTaskId}
|
||||
highlightTaskId={selectedTaskId}
|
||||
highlightingAgentId={selectedAgentId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -50,6 +50,9 @@ export function sessionStateGlow(state: string): string {
|
|||
switch (state) {
|
||||
case 'active': return 'shadow-[0_0_12px_rgba(74,222,128,0.3)] border-emerald-500/30';
|
||||
case 'needs_input': return 'shadow-[0_0_12px_rgba(248,113,113,0.3)] border-rose-500/30';
|
||||
case 'stuck': return 'ring-2 ring-red-500 animate-pulse shadow-[0_0_16px_rgba(239,68,68,0.5)]';
|
||||
case 'dead': return 'opacity-40 grayscale';
|
||||
case 'evicted': return 'opacity-60 grayscale-[0.5]';
|
||||
case 'stale': return 'opacity-60 grayscale-[0.5]';
|
||||
case 'completed': return 'opacity-80';
|
||||
default: return '';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue