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

View file

@ -0,0 +1,275 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { buildSwarmCards, getSwarmCardSummary, type SwarmCard } from '../../src/lib/swarm-cards';
import type { BeadIssue } from '../../src/lib/types';
function makeBead(overrides: Partial<BeadIssue>): BeadIssue {
return {
id: overrides.id || 'bb-test',
title: overrides.title || 'Test bead',
description: null,
status: overrides.status || 'open',
priority: overrides.priority || 0,
issue_type: overrides.issue_type || 'task',
assignee: null,
owner: null,
labels: overrides.labels || [],
dependencies: [],
created_at: overrides.created_at || new Date().toISOString(),
updated_at: overrides.updated_at || new Date().toISOString(),
closed_at: null,
close_reason: null,
closed_by_session: null,
created_by: null,
due_at: null,
estimated_minutes: null,
external_ref: null,
metadata: {},
};
}
function makeAgentBead(id: string, swarmId: string, updatedAt: string): BeadIssue {
return makeBead({
id,
title: `Agent: ${id}`,
issue_type: 'agent',
labels: ['gt:agent', `swarm:${swarmId}`],
updated_at: updatedAt,
});
}
function makeEpicBead(id: string, title: string): BeadIssue {
return makeBead({
id,
title,
issue_type: 'epic',
});
}
function makeTaskBead(id: string, swarmId: string, status: string, updatedAt: string): BeadIssue {
return makeBead({
id,
title: `Task ${id}`,
status: status as any,
labels: [`swarm:${swarmId}`],
updated_at: updatedAt,
});
}
test('buildSwarmCards returns empty array for no beads', () => {
const cards = buildSwarmCards([]);
assert.equal(cards.length, 0);
});
test('buildSwarmCards groups beads by swarm label', () => {
const now = new Date();
const beads: BeadIssue[] = [
makeEpicBead('bb-epic1', 'Epic One'),
makeTaskBead('bb-task1', 'bb-epic1', 'open', now.toISOString()),
makeTaskBead('bb-task2', 'bb-epic1', 'open', now.toISOString()),
makeTaskBead('bb-task3', 'bb-epic2', 'open', now.toISOString()),
];
const cards = buildSwarmCards(beads, now);
assert.equal(cards.length, 2);
const epic1Card = cards.find((c) => c.swarmId === 'bb-epic1');
assert.ok(epic1Card);
assert.equal(epic1Card!.title, 'Epic One');
assert.equal(epic1Card!.progress, 0);
});
test('buildSwarmCards calculates progress correctly', () => {
const now = new Date();
const beads: BeadIssue[] = [
makeEpicBead('bb-epic1', 'Epic'),
makeTaskBead('bb-t1', 'bb-epic1', 'closed', now.toISOString()),
makeTaskBead('bb-t2', 'bb-epic1', 'closed', now.toISOString()),
makeTaskBead('bb-t3', 'bb-epic1', 'open', now.toISOString()),
makeTaskBead('bb-t4', 'bb-epic1', 'open', now.toISOString()),
];
const cards = buildSwarmCards(beads, now);
const card = cards.find((c) => c.swarmId === 'bb-epic1');
assert.ok(card);
assert.equal(card!.progress, 50);
});
test('buildSwarmCards extracts agents from swarm', () => {
const now = new Date();
const recentActivity = new Date(now.getTime() - 5 * 60 * 1000).toISOString();
const beads: BeadIssue[] = [
makeEpicBead('bb-epic1', 'Epic'),
makeAgentBead('bb-agent1', 'bb-epic1', recentActivity),
makeAgentBead('bb-agent2', 'bb-epic1', recentActivity),
makeTaskBead('bb-task1', 'bb-epic1', 'open', now.toISOString()),
];
const cards = buildSwarmCards(beads, now);
const card = cards.find((c) => c.swarmId === 'bb-epic1');
assert.ok(card);
assert.equal(card!.agents.length, 2);
assert.ok(card!.agents.some((a) => a.name === 'agent1'));
assert.ok(card!.agents.some((a) => a.name === 'agent2'));
});
test('buildSwarmCards derives agent status from last activity', () => {
const now = new Date();
const activeTime = new Date(now.getTime() - 5 * 60 * 1000).toISOString();
const staleTime = new Date(now.getTime() - 20 * 60 * 1000).toISOString();
const stuckTime = new Date(now.getTime() - 40 * 60 * 1000).toISOString();
const deadTime = new Date(now.getTime() - 70 * 60 * 1000).toISOString();
const beads: BeadIssue[] = [
makeEpicBead('bb-epic1', 'Epic'),
makeAgentBead('bb-active', 'bb-epic1', activeTime),
makeAgentBead('bb-stale', 'bb-epic1', staleTime),
makeAgentBead('bb-stuck', 'bb-epic1', stuckTime),
makeAgentBead('bb-dead', 'bb-epic1', deadTime),
];
const cards = buildSwarmCards(beads, now);
const card = cards.find((c) => c.swarmId === 'bb-epic1');
assert.ok(card);
const activeAgent = card!.agents.find((a) => a.name === 'active');
const staleAgent = card!.agents.find((a) => a.name === 'stale');
const stuckAgent = card!.agents.find((a) => a.name === 'stuck');
const deadAgent = card!.agents.find((a) => a.name === 'dead');
assert.equal(activeAgent?.status, 'active');
assert.equal(staleAgent?.status, 'stale');
assert.equal(stuckAgent?.status, 'stuck');
assert.equal(deadAgent?.status, 'dead');
});
test('buildSwarmCards derives swarm health from agents', () => {
const now = new Date();
const activeTime = new Date(now.getTime() - 5 * 60 * 1000).toISOString();
const staleTime = new Date(now.getTime() - 20 * 60 * 1000).toISOString();
const deadTime = new Date(now.getTime() - 70 * 60 * 1000).toISOString();
const activeBeads: BeadIssue[] = [
makeEpicBead('bb-epic-active', 'Active'),
makeAgentBead('bb-a1', 'bb-epic-active', activeTime),
makeAgentBead('bb-a2', 'bb-epic-active', activeTime),
];
const staleBeads: BeadIssue[] = [
makeEpicBead('bb-epic-stale', 'Stale'),
makeAgentBead('bb-s1', 'bb-epic-stale', staleTime),
makeAgentBead('bb-s2', 'bb-epic-stale', staleTime),
];
const deadBeads: BeadIssue[] = [
makeEpicBead('bb-epic-dead', 'Dead'),
makeAgentBead('bb-d1', 'bb-epic-dead', deadTime),
makeAgentBead('bb-d2', 'bb-epic-dead', deadTime),
];
const activeCards = buildSwarmCards(activeBeads, now);
const staleCards = buildSwarmCards(staleBeads, now);
const deadCards = buildSwarmCards(deadBeads, now);
const activeCard = activeCards.find((c) => c.swarmId === 'bb-epic-active');
const staleCard = staleCards.find((c) => c.swarmId === 'bb-epic-stale');
const deadCard = deadCards.find((c) => c.swarmId === 'bb-epic-dead');
assert.equal(activeCard?.health, 'active');
assert.equal(staleCard?.health, 'stale');
assert.equal(deadCard?.health, 'dead');
});
test('buildSwarmCards extracts attention items for blocked/in_progress tasks', () => {
const now = new Date();
const beads: BeadIssue[] = [
makeEpicBead('bb-epic1', 'Epic'),
{
...makeTaskBead('bb-blocked', 'bb-epic1', 'blocked', now.toISOString()),
title: 'Blocked task',
priority: 10,
},
{
...makeTaskBead('bb-wip', 'bb-epic1', 'in_progress', now.toISOString()),
title: 'Work in progress',
priority: 5,
},
makeTaskBead('bb-open', 'bb-epic1', 'open', now.toISOString()),
makeTaskBead('bb-closed', 'bb-epic1', 'closed', now.toISOString()),
];
const cards = buildSwarmCards(beads, now);
const card = cards.find((c) => c.swarmId === 'bb-epic1');
assert.ok(card);
assert.equal(card!.attentionItems.length, 2);
assert.ok(card!.attentionItems[0].includes('bb-blocked'));
assert.ok(card!.attentionItems[1].includes('bb-wip'));
});
test('buildSwarmCards sorts cards by lastActivity descending', () => {
const now = new Date();
const older = new Date(now.getTime() - 60 * 60 * 1000).toISOString();
const newer = new Date(now.getTime() - 5 * 60 * 1000).toISOString();
const beads: BeadIssue[] = [
makeEpicBead('bb-epic-old', 'Old Epic'),
makeEpicBead('bb-epic-new', 'New Epic'),
makeTaskBead('bb-t1', 'bb-epic-old', 'open', older),
makeTaskBead('bb-t2', 'bb-epic-new', 'open', newer),
];
const cards = buildSwarmCards(beads, now);
assert.equal(cards.length, 2);
assert.equal(cards[0].swarmId, 'bb-epic-new');
assert.equal(cards[1].swarmId, 'bb-epic-old');
});
test('getSwarmCardSummary returns correct counts', () => {
const cards: SwarmCard[] = [
{ swarmId: '1', title: 'A', agents: [], attentionItems: [], progress: 0, lastActivity: new Date(), health: 'active' },
{ swarmId: '2', title: 'B', agents: [], attentionItems: [], progress: 0, lastActivity: new Date(), health: 'active' },
{ swarmId: '3', title: 'C', agents: [], attentionItems: [], progress: 0, lastActivity: new Date(), health: 'stale' },
{ swarmId: '4', title: 'D', agents: [], attentionItems: [], progress: 0, lastActivity: new Date(), health: 'stuck' },
{ swarmId: '5', title: 'E', agents: [], attentionItems: [], progress: 0, lastActivity: new Date(), health: 'dead' },
];
const summary = getSwarmCardSummary(cards);
assert.equal(summary.total, 5);
assert.equal(summary.active, 2);
assert.equal(summary.stale, 1);
assert.equal(summary.stuck, 1);
assert.equal(summary.dead, 1);
});
test('buildSwarmCards handles swarm without epic', () => {
const now = new Date();
const beads: BeadIssue[] = [
makeTaskBead('bb-task1', 'bb-orphan-swarm', 'open', now.toISOString()),
makeAgentBead('bb-agent1', 'bb-orphan-swarm', now.toISOString()),
];
const cards = buildSwarmCards(beads, now);
const card = cards.find((c) => c.swarmId === 'bb-orphan-swarm');
assert.ok(card);
assert.equal(card!.title, 'Swarm bb-orphan-swarm');
});
test('buildSwarmCards ignores non-agent beads with swarm label when building agent roster', () => {
const now = new Date();
const beads: BeadIssue[] = [
makeEpicBead('bb-epic1', 'Epic'),
makeAgentBead('bb-agent1', 'bb-epic1', now.toISOString()),
makeTaskBead('bb-task1', 'bb-epic1', 'open', now.toISOString()),
];
const cards = buildSwarmCards(beads, now);
const card = cards.find((c) => c.swarmId === 'bb-epic1');
assert.ok(card);
assert.equal(card!.agents.length, 1);
assert.equal(card!.agents[0].name, 'agent1');
});

View file

@ -0,0 +1,66 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { execSync } from 'node:child_process';
async function withTempProject(run: (projectRoot: string) => Promise<void>): Promise<void> {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'swarm-simple-'));
execSync('git init', { cwd: tempDir, stdio: 'ignore' });
await fs.writeFile(path.join(tempDir, 'dummy'), 'data');
execSync('git add . && git commit -m "initial"', { cwd: tempDir, stdio: 'ignore' });
execSync('bd init --prefix bb- --force', { cwd: tempDir, stdio: 'ignore' });
execSync('bd migrate --update-repo-id', { cwd: tempDir, stdio: 'ignore' });
try {
await run(tempDir);
} finally {
for (let i = 0; i < 5; i++) {
try {
await fs.rm(tempDir, { recursive: true, force: true });
break;
} catch {
await new Promise(r => setTimeout(r, 500));
}
}
}
}
test('basic bd update label works', async () => {
await withTempProject(async (projectRoot) => {
// Create a simple issue
execSync('bd create --title "Test Issue" --id bb-test-issue', { cwd: projectRoot, stdio: 'ignore' });
// Update with label
execSync('bd update bb-test-issue --add-label swarm:test-123', { cwd: projectRoot, stdio: 'ignore' });
execSync('bd admin flush', { cwd: projectRoot, stdio: 'ignore' });
// Verify
const showOut = execSync('bd show bb-test-issue --json', { cwd: projectRoot, encoding: 'utf8' });
const issue = JSON.parse(showOut);
const swarmLabel = issue.labels?.find((l: string) => l.startsWith('swarm:'));
assert.ok(swarmLabel, 'Should have swarm label');
assert.equal(swarmLabel, 'swarm:test-123');
});
});
test('registerAgent and add label via bd update', async () => {
await withTempProject(async (projectRoot) => {
const { registerAgent } = await import('../../src/lib/agent-registry');
const regResult = await registerAgent({ name: 'label-test-agent', role: 'tester' }, { projectRoot });
assert.equal(regResult.ok, true, 'Register should succeed');
// Add label directly via bd
execSync('bd update bb-label-test-agent --add-label swarm:direct-test', { cwd: projectRoot, stdio: 'ignore' });
execSync('bd admin flush', { cwd: projectRoot, stdio: 'ignore' });
// Verify
const showOut = execSync('bd show bb-label-test-agent --json', { cwd: projectRoot, encoding: 'utf8' });
const agent = JSON.parse(showOut);
console.log('Agent labels:', agent.labels);
const swarmLabel = agent.labels?.find((l: string) => l.startsWith('swarm:'));
assert.ok(swarmLabel, 'Agent should have swarm label');
});
});

View file

@ -0,0 +1,88 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { execSync } from 'node:child_process';
import { joinSwarm, leaveSwarm, getSwarmMembers } from '../../src/lib/swarm-molecules';
import { registerAgent } from '../../src/lib/agent-registry';
const projectRoot = process.cwd();
const runId = Date.now().toString(36);
// Helper: bd show returns an ARRAY, take first element
function bdShow(beadId: string): any {
const out = execSync(`bd --allow-stale show ${beadId} --json`, { cwd: projectRoot, encoding: 'utf8', timeout: 30000 });
const arr = JSON.parse(out);
return arr[0] || arr;
}
test('joinSwarm creates swarm membership', async () => {
const agentId = `join-${runId}`;
await registerAgent({ name: agentId, role: 'tester' }, { projectRoot });
const result = await joinSwarm({ agent: agentId, epicId: 'bb-buff' }, { projectRoot });
assert.equal(result.ok, true, `joinSwarm failed: ${result.error?.message}`);
const agent = bdShow(`bb-${agentId}`);
const hasSwarm = agent.labels?.some((l: string) => l.startsWith('swarm:'));
assert.ok(hasSwarm, 'Agent should have swarm label');
await leaveSwarm({ agent: agentId }, { projectRoot });
});
test('joinSwarm switches membership', async () => {
const agentId = `switch-${runId}`;
await registerAgent({ name: agentId, role: 'tester' }, { projectRoot });
await joinSwarm({ agent: agentId, epicId: 'bb-buff' }, { projectRoot });
await joinSwarm({ agent: agentId, epicId: 'bb-buff.2' }, { projectRoot });
const agent = bdShow(`bb-${agentId}`);
const swarmLabels = agent.labels?.filter((l: string) => l.startsWith('swarm:')) || [];
assert.equal(swarmLabels.length, 1, 'Should have exactly one swarm label');
await leaveSwarm({ agent: agentId }, { projectRoot });
});
test('leaveSwarm removes membership', async () => {
const agentId = `leave-${runId}`;
await registerAgent({ name: agentId, role: 'tester' }, { projectRoot });
await joinSwarm({ agent: agentId, epicId: 'bb-buff' }, { projectRoot });
await leaveSwarm({ agent: agentId }, { projectRoot });
const agent = bdShow(`bb-${agentId}`);
const swarmLabels = agent.labels?.filter((l: string) => l.startsWith('swarm:')) || [];
assert.equal(swarmLabels.length, 0, 'Should have no swarm labels');
});
test('getSwarmMembers returns members', async () => {
const agent1 = `m1-${runId}`;
const agent2 = `m2-${runId}`;
await registerAgent({ name: agent1, role: 'tester' }, { projectRoot });
await registerAgent({ name: agent2, role: 'tester' }, { projectRoot });
await joinSwarm({ agent: agent1, epicId: 'bb-buff' }, { projectRoot });
await joinSwarm({ agent: agent2, epicId: 'bb-buff' }, { projectRoot });
const members = await getSwarmMembers({ swarmId: 'bb-buff' }, { projectRoot });
assert.ok(members.includes(agent1), `Should include ${agent1}`);
assert.ok(members.includes(agent2), `Should include ${agent2}`);
await leaveSwarm({ agent: agent1 }, { projectRoot });
await leaveSwarm({ agent: agent2 }, { projectRoot });
});
test('joinSwarm rejects invalid agent', async () => {
const result = await joinSwarm({ agent: 'nonexistent', epicId: 'bb-buff' }, { projectRoot });
assert.equal(result.ok, false);
assert.equal(result.error?.code, 'AGENT_NOT_FOUND');
});
test('joinSwarm rejects invalid epic', async () => {
const agentId = `invepic-${runId}`;
await registerAgent({ name: agentId, role: 'tester' }, { projectRoot });
const result = await joinSwarm({ agent: agentId, epicId: 'bb-nonexistent' }, { projectRoot });
assert.equal(result.ok, false);
assert.equal(result.error?.code, 'EPIC_NOT_FOUND');
});