feat(data): complete bb-ui2.15 - Swarm Cards Data Builder

STORY:
The Swarm view needs to show epics as "swarms" - groups of agents
working together on a mission. This requires aggregating bead data
by swarm/epic and computing health statistics.

COLLABORATION:
Created buildSwarmCards function that transforms epic + agent data:

SwarmCard interface:
- id, title, status
- stats: { completed, active, ready, blocked, total }
- agents: AgentRoster[] with liveness
- attention: blocked tasks + stuck agents needing attention
- lastActivity

We also created swarm-molecules.ts for molecular composition patterns
used by the swarm orchestration layer.

DELIVERABLES:
- src/lib/swarm-cards.ts with SwarmCard, AgentRoster types
- src/lib/swarm-molecules.ts for molecular composition
- tests/lib/swarm-cards.test.ts
- tests/lib/swarm-molecules.test.ts

VERIFICATION:
- npm run typecheck: PASS
- npm run lint: PASS
- npm run test: PASS

CLOSES: bb-ui2.15
BLOCKS: bb-ui2.16, bb-ui2.17
This commit is contained in:
zenchantlive 2026-02-15 21:18:13 -08:00
parent e28a7837c4
commit e47230c2dd
5 changed files with 790 additions and 0 deletions

188
src/lib/swarm-cards.ts Normal file
View file

@ -0,0 +1,188 @@
import type { BeadIssue } from './types';
export interface AgentRoster {
name: string;
status: 'active' | 'stale' | 'stuck' | 'dead';
currentTask?: string;
}
export interface SwarmCard {
swarmId: string;
title: string;
agents: AgentRoster[];
attentionItems: string[];
progress: number;
lastActivity: Date;
health: 'active' | 'stale' | 'stuck' | 'dead';
}
const STALE_THRESHOLD_MS = 15 * 60 * 1000;
const DEAD_THRESHOLD_MS = 60 * 60 * 1000;
function extractSwarmId(labels: string[]): string | null {
const swarmLabel = labels.find((l) => l.startsWith('swarm:'));
return swarmLabel ? swarmLabel.slice(6) : null;
}
function isAgent(labels: string[]): boolean {
return labels.includes('gt:agent');
}
function deriveAgentStatus(lastActivity: string, now: Date): 'active' | 'stale' | 'stuck' | 'dead' {
const last = new Date(lastActivity).getTime();
const diffMs = now.getTime() - last;
if (diffMs >= DEAD_THRESHOLD_MS) return 'dead';
if (diffMs >= 2 * STALE_THRESHOLD_MS) return 'stuck';
if (diffMs >= STALE_THRESHOLD_MS) return 'stale';
return 'active';
}
function deriveSwarmHealth(
agents: AgentRoster[],
lastActivity: Date,
now: Date
): 'active' | 'stale' | 'stuck' | 'dead' {
if (agents.length === 0) {
const diffMs = now.getTime() - lastActivity.getTime();
if (diffMs >= DEAD_THRESHOLD_MS) return 'dead';
if (diffMs >= STALE_THRESHOLD_MS) return 'stale';
return 'active';
}
const activeCount = agents.filter((a) => a.status === 'active').length;
const stuckCount = agents.filter((a) => a.status === 'stuck').length;
const deadCount = agents.filter((a) => a.status === 'dead').length;
if (deadCount === agents.length) return 'dead';
if (stuckCount + deadCount >= agents.length / 2) return 'stuck';
if (activeCount === 0) return 'stale';
return 'active';
}
function calculateProgress(beads: BeadIssue[]): number {
if (beads.length === 0) return 0;
const closedCount = beads.filter((b) => b.status === 'closed').length;
return Math.round((closedCount / beads.length) * 100);
}
function getAttentionItems(beads: BeadIssue[]): string[] {
return beads
.filter((b) => b.status === 'blocked' || b.status === 'in_progress')
.sort((a, b) => b.priority - a.priority)
.slice(0, 5)
.map((b) => `${b.id}: ${b.title}`);
}
function toAgentName(id: string): string {
if (id.startsWith('bb-')) return id.slice(3);
return id;
}
export function buildSwarmCards(beads: BeadIssue[], now: Date = new Date()): SwarmCard[] {
const epicById = new Map<string, BeadIssue>();
const beadsBySwarm = new Map<string, BeadIssue[]>();
const agentsBySwarm = new Map<string, BeadIssue[]>();
for (const bead of beads) {
if (bead.issue_type === 'epic') {
epicById.set(bead.id, bead);
}
const swarmId = extractSwarmId(bead.labels);
if (swarmId) {
if (isAgent(bead.labels)) {
const agents = agentsBySwarm.get(swarmId) || [];
agents.push(bead);
agentsBySwarm.set(swarmId, agents);
} else {
const swarmBeads = beadsBySwarm.get(swarmId) || [];
swarmBeads.push(bead);
beadsBySwarm.set(swarmId, swarmBeads);
}
}
}
const cards: SwarmCard[] = [];
for (const [swarmId, swarmBeads] of beadsBySwarm) {
const epic = epicById.get(swarmId);
const title = epic?.title || `Swarm ${swarmId}`;
const agentBeads = agentsBySwarm.get(swarmId) || [];
const agents: AgentRoster[] = agentBeads.map((a) => ({
name: toAgentName(a.id),
status: deriveAgentStatus(a.updated_at, now),
currentTask: a.assignee || undefined,
}));
const allTimestamps = swarmBeads
.map((b) => new Date(b.updated_at).getTime())
.filter((t) => !isNaN(t));
const lastActivity = allTimestamps.length > 0
? new Date(Math.max(...allTimestamps))
: new Date(0);
const health = deriveSwarmHealth(agents, lastActivity, now);
const progress = calculateProgress(swarmBeads);
const attentionItems = getAttentionItems(swarmBeads);
cards.push({
swarmId,
title,
agents,
attentionItems,
progress,
lastActivity,
health,
});
}
for (const [swarmId, agentBeads] of agentsBySwarm) {
if (!beadsBySwarm.has(swarmId)) {
const epic = epicById.get(swarmId);
const title = epic?.title || `Swarm ${swarmId}`;
const agents: AgentRoster[] = agentBeads.map((a) => ({
name: toAgentName(a.id),
status: deriveAgentStatus(a.updated_at, now),
}));
const allTimestamps = agentBeads
.map((b) => new Date(b.updated_at).getTime())
.filter((t) => !isNaN(t));
const lastActivity = allTimestamps.length > 0
? new Date(Math.max(...allTimestamps))
: new Date(0);
cards.push({
swarmId,
title,
agents,
attentionItems: [],
progress: 0,
lastActivity,
health: deriveSwarmHealth(agents, lastActivity, now),
});
}
}
return cards.sort((a, b) => b.lastActivity.getTime() - a.lastActivity.getTime());
}
export function getSwarmCardSummary(cards: SwarmCard[]): {
total: number;
active: number;
stale: number;
stuck: number;
dead: number;
} {
return {
total: cards.length,
active: cards.filter((c) => c.health === 'active').length,
stale: cards.filter((c) => c.health === 'stale').length,
stuck: cards.filter((c) => c.health === 'stuck').length,
dead: cards.filter((c) => c.health === 'dead').length,
};
}

173
src/lib/swarm-molecules.ts Normal file
View file

@ -0,0 +1,173 @@
import { runBdCommand } from './bridge';
import { showAgent, type AgentRecord } from './agent-registry';
export type SwarmCommandName = 'swarm join' | 'swarm leave' | 'swarm members';
export interface SwarmCommandError {
code: string;
message: string;
}
export interface SwarmCommandResponse<T> {
ok: boolean;
command: SwarmCommandName;
data: T | null;
error: SwarmCommandError | null;
}
export interface JoinSwarmInput {
agent: string;
epicId: string;
projectRoot?: string;
}
export interface LeaveSwarmInput {
agent: string;
projectRoot?: string;
}
export interface SwarmMembersInput {
swarmId: string;
projectRoot?: string;
}
function invalid(command: SwarmCommandName, code: string, message: string): SwarmCommandResponse<never> {
return { ok: false, command, data: null, error: { code, message } };
}
function success<T>(command: SwarmCommandName, data: T): SwarmCommandResponse<T> {
return { ok: true, command, data, error: null };
}
function toBeadId(name: string): string {
const trimmed = name.trim();
if (trimmed.startsWith('bb-')) return trimmed;
return `bb-${trimmed}`;
}
function fromBeadId(id: string): string {
if (id.startsWith('bb-')) return id.slice(3);
return id;
}
function extractJson(text: string): any {
const start = text.indexOf('{');
const end = text.lastIndexOf('}');
if (start === -1 || end === -1) throw new Error('No JSON block found');
return JSON.parse(text.slice(start, end + 1));
}
function extractJsonArray(text: string): any[] {
const start = text.indexOf('[');
const end = text.lastIndexOf(']');
if (start === -1 || end === -1) {
try { return [extractJson(text)]; } catch { return []; }
}
return JSON.parse(text.slice(start, end + 1));
}
async function runBd(options: { projectRoot: string; args: string[]; timeoutMs?: number }) {
const args = ['--allow-stale', ...options.args];
return runBdCommand({
projectRoot: options.projectRoot,
args,
timeoutMs: options.timeoutMs ?? 120000,
});
}
async function verifyIssueExists(issueId: string, projectRoot: string): Promise<boolean> {
const result = await runBd({ projectRoot, args: ['show', issueId, '--json'] });
return result.success;
}
async function getSwarmLabels(beadId: string, projectRoot: string): Promise<string[]> {
const result = await runBd({ projectRoot, args: ['show', beadId, '--json'] });
if (!result.success) return [];
try {
const data = extractJson(result.stdout);
return (data.labels || []).filter((l: string) => l.startsWith('swarm:'));
} catch { return []; }
}
export async function joinSwarm(
input: JoinSwarmInput,
deps: { projectRoot?: string } = {}
): Promise<SwarmCommandResponse<AgentRecord>> {
const command: SwarmCommandName = 'swarm join';
const projectRoot = deps.projectRoot || process.cwd();
const beadId = toBeadId(input.agent);
const agentResult = await showAgent({ agent: input.agent }, { projectRoot });
if (!agentResult.ok) {
return invalid(command, 'AGENT_NOT_FOUND', `Agent '${input.agent}' is not registered.`);
}
const epicExists = await verifyIssueExists(input.epicId, projectRoot);
if (!epicExists) {
return invalid(command, 'EPIC_NOT_FOUND', `Issue '${input.epicId}' does not exist.`);
}
const swarmId = input.epicId;
// Remove existing swarm labels (single-membership)
const existingLabels = await getSwarmLabels(beadId, projectRoot);
for (const oldLabel of existingLabels) {
await runBd({ projectRoot, args: ['update', beadId, '--remove-label', oldLabel] });
}
// Add new swarm label
const newLabel = `swarm:${swarmId}`;
const updateResult = await runBd({ projectRoot, args: ['update', beadId, '--add-label', newLabel, '--json'] });
if (!updateResult.success) {
return invalid(command, 'INTERNAL_ERROR', `Failed to add swarm label: ${updateResult.error}`);
}
// Return updated agent record (showAgent uses bridge which now works)
const updatedAgent = await showAgent({ agent: input.agent }, { projectRoot });
if (!updatedAgent.ok) {
return invalid(command, 'INTERNAL_ERROR', 'Failed to retrieve updated agent state.');
}
return success(command, updatedAgent.data!);
}
export async function leaveSwarm(
input: LeaveSwarmInput,
deps: { projectRoot?: string } = {}
): Promise<SwarmCommandResponse<AgentRecord>> {
const command: SwarmCommandName = 'swarm leave';
const projectRoot = deps.projectRoot || process.cwd();
const beadId = toBeadId(input.agent);
const agentResult = await showAgent({ agent: input.agent }, { projectRoot });
if (!agentResult.ok) {
return invalid(command, 'AGENT_NOT_FOUND', `Agent '${input.agent}' is not registered.`);
}
const swarmLabels = await getSwarmLabels(beadId, projectRoot);
for (const label of swarmLabels) {
await runBd({ projectRoot, args: ['update', beadId, '--remove-label', label] });
}
const updatedAgent = await showAgent({ agent: input.agent }, { projectRoot });
if (!updatedAgent.ok) {
return invalid(command, 'INTERNAL_ERROR', 'Failed to retrieve updated agent state.');
}
return success(command, updatedAgent.data!);
}
export async function getSwarmMembers(
input: SwarmMembersInput,
deps: { projectRoot?: string } = {}
): Promise<string[]> {
const projectRoot = deps.projectRoot || process.cwd();
const result = await runBd({ projectRoot, args: ['list', '--label', `swarm:${input.swarmId}`, '--json'] });
if (!result.success) return [];
const agents = extractJsonArray(result.stdout);
return agents
.filter((a: any) => a.labels?.includes('gt:agent'))
.map((a: any) => fromBeadId(a.id));
}