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:
parent
e28a7837c4
commit
e47230c2dd
5 changed files with 790 additions and 0 deletions
188
src/lib/swarm-cards.ts
Normal file
188
src/lib/swarm-cards.ts
Normal 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
173
src/lib/swarm-molecules.ts
Normal 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));
|
||||
}
|
||||
275
tests/lib/swarm-cards.test.ts
Normal file
275
tests/lib/swarm-cards.test.ts
Normal 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');
|
||||
});
|
||||
66
tests/lib/swarm-molecules-simple.test.ts
Normal file
66
tests/lib/swarm-molecules-simple.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
88
tests/lib/swarm-molecules.test.ts
Normal file
88
tests/lib/swarm-molecules.test.ts
Normal 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');
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue