From e47230c2dd1402cb8ea09fe626cb76368e1cae6b Mon Sep 17 00:00:00 2001 From: zenchantlive Date: Sun, 15 Feb 2026 21:18:13 -0800 Subject: [PATCH] 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 --- src/lib/swarm-cards.ts | 188 ++++++++++++++++ src/lib/swarm-molecules.ts | 173 ++++++++++++++ tests/lib/swarm-cards.test.ts | 275 +++++++++++++++++++++++ tests/lib/swarm-molecules-simple.test.ts | 66 ++++++ tests/lib/swarm-molecules.test.ts | 88 ++++++++ 5 files changed, 790 insertions(+) create mode 100644 src/lib/swarm-cards.ts create mode 100644 src/lib/swarm-molecules.ts create mode 100644 tests/lib/swarm-cards.test.ts create mode 100644 tests/lib/swarm-molecules-simple.test.ts create mode 100644 tests/lib/swarm-molecules.test.ts diff --git a/src/lib/swarm-cards.ts b/src/lib/swarm-cards.ts new file mode 100644 index 0000000..c3d3318 --- /dev/null +++ b/src/lib/swarm-cards.ts @@ -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(); + const beadsBySwarm = new Map(); + const agentsBySwarm = new Map(); + + 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, + }; +} diff --git a/src/lib/swarm-molecules.ts b/src/lib/swarm-molecules.ts new file mode 100644 index 0000000..100d163 --- /dev/null +++ b/src/lib/swarm-molecules.ts @@ -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 { + 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 { + return { ok: false, command, data: null, error: { code, message } }; +} + +function success(command: SwarmCommandName, data: T): SwarmCommandResponse { + 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 { + const result = await runBd({ projectRoot, args: ['show', issueId, '--json'] }); + return result.success; +} + +async function getSwarmLabels(beadId: string, projectRoot: string): Promise { + 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> { + 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> { + 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 { + 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)); +} diff --git a/tests/lib/swarm-cards.test.ts b/tests/lib/swarm-cards.test.ts new file mode 100644 index 0000000..d1982c7 --- /dev/null +++ b/tests/lib/swarm-cards.test.ts @@ -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 { + 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'); +}); \ No newline at end of file diff --git a/tests/lib/swarm-molecules-simple.test.ts b/tests/lib/swarm-molecules-simple.test.ts new file mode 100644 index 0000000..2feda51 --- /dev/null +++ b/tests/lib/swarm-molecules-simple.test.ts @@ -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): Promise { + 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'); + }); +}); diff --git a/tests/lib/swarm-molecules.test.ts b/tests/lib/swarm-molecules.test.ts new file mode 100644 index 0000000..f59cb49 --- /dev/null +++ b/tests/lib/swarm-molecules.test.ts @@ -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'); +});