docs(beads): etch project history into memory bank and finalize skill-bb
We completed the 'Deep Metadata Etch' today, transforming our Beads issues from simple trackers into a permanent narrative of our collaboration. Triumphs: - Exhaustively updated all epic and sub-task descriptions with technical implementation reports and 'Execution Tales'. - Finalized the 'bb' agent CLI skill (bb.ps1), providing a reliable, path-safe interface for cross-agent communication. - Published ADR-001 and RFC-001 to document our coordination protocols. - Fixed the 'missing closed issues' bug across all pages by enforcing --all and --limit 0 in read-issues.ts. Raw Honest Moment: We realized our 'Memory Bank' was initially too shallow. We went back and re-wrote descriptions for over 15 beads to ensure that future AI agents (and human maintainers) understand not just *what* we built, but *why* we chose specific architectural trade-offs. This commit represents our commitment to documentation as a first-class citizen of engineering.
This commit is contained in:
parent
bfe4f853f0
commit
c7c3a25457
27 changed files with 2376 additions and 137 deletions
42
tests/components/sessions/sessions-store.test.ts
Normal file
42
tests/components/sessions/sessions-store.test.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert';
|
||||
import { useTimelineStore } from '../../../src/components/timeline/timeline-store';
|
||||
|
||||
describe('Sessions Store (bb-u6f.3.7)', () => {
|
||||
it('should manage agent and task selection', () => {
|
||||
const store = useTimelineStore.getState();
|
||||
|
||||
// Initial state
|
||||
assert.strictEqual(store.selectedAgentId, null);
|
||||
assert.strictEqual(store.selectedTaskId, null);
|
||||
|
||||
// Select agent
|
||||
store.setSelectedAgentId('agent-1');
|
||||
assert.strictEqual(useTimelineStore.getState().selectedAgentId, 'agent-1');
|
||||
|
||||
// Select task
|
||||
store.setSelectedTaskId('task-1');
|
||||
assert.strictEqual(useTimelineStore.getState().selectedTaskId, 'task-1');
|
||||
});
|
||||
|
||||
it('should handle navigation back to agent', () => {
|
||||
const store = useTimelineStore.getState();
|
||||
store.setSelectedAgentId('agent-1');
|
||||
store.setSelectedTaskId('task-1');
|
||||
|
||||
// Back to agent
|
||||
store.backToAgent();
|
||||
assert.strictEqual(useTimelineStore.getState().selectedTaskId, null);
|
||||
assert.strictEqual(useTimelineStore.getState().selectedAgentId, 'agent-1');
|
||||
});
|
||||
|
||||
it('should clear all selections on clear', () => {
|
||||
const store = useTimelineStore.getState();
|
||||
store.setSelectedAgentId('agent-1');
|
||||
store.setSelectedTaskId('task-1');
|
||||
|
||||
store.clear();
|
||||
assert.strictEqual(useTimelineStore.getState().selectedAgentId, null);
|
||||
assert.strictEqual(useTimelineStore.getState().selectedTaskId, null);
|
||||
});
|
||||
});
|
||||
16
tests/hooks/use-beads-subscription.test.ts
Normal file
16
tests/hooks/use-beads-subscription.test.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert';
|
||||
// We need a DOM environment to test hooks that use EventSource/fetch
|
||||
// Since we are running in Node, we can't easily test the hook's effect logic without a heavy setup (JSDOM).
|
||||
// But we can verify the module loads.
|
||||
|
||||
describe('useBeadsSubscription', () => {
|
||||
it('should load the module without error', async () => {
|
||||
try {
|
||||
await import('../../src/hooks/use-beads-subscription');
|
||||
assert.ok(true, 'Module loaded');
|
||||
} catch (err) {
|
||||
assert.fail(err as Error);
|
||||
}
|
||||
});
|
||||
});
|
||||
167
tests/lib/agent-mail.test.ts
Normal file
167
tests/lib/agent-mail.test.ts
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
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 { registerAgent } from '../../src/lib/agent-registry';
|
||||
import { ackAgentMessage, inboxAgentMessages, readAgentMessage, sendAgentMessage } from '../../src/lib/agent-mail';
|
||||
|
||||
async function withTempUserProfile(run: () => Promise<void>): Promise<void> {
|
||||
const previous = process.env.USERPROFILE;
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-agent-mail-'));
|
||||
process.env.USERPROFILE = tempDir;
|
||||
|
||||
try {
|
||||
await run();
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env.USERPROFILE;
|
||||
} else {
|
||||
process.env.USERPROFILE = previous;
|
||||
}
|
||||
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function seedAgents(): Promise<void> {
|
||||
const now = '2026-02-14T00:00:00.000Z';
|
||||
await registerAgent({ name: 'agent-ui-1', role: 'ui' }, { now: () => now });
|
||||
await registerAgent({ name: 'agent-graph-1', role: 'graph' }, { now: () => now });
|
||||
}
|
||||
|
||||
test('sendAgentMessage rejects unknown sender and recipient', async () => {
|
||||
await withTempUserProfile(async () => {
|
||||
const unknownSender = await sendAgentMessage({
|
||||
from: 'agent-ui-1',
|
||||
to: 'agent-graph-1',
|
||||
bead: 'bb-dcv.6',
|
||||
category: 'HANDOFF',
|
||||
subject: 'subject',
|
||||
body: 'body',
|
||||
});
|
||||
|
||||
assert.equal(unknownSender.ok, false);
|
||||
assert.equal(unknownSender.error?.code, 'UNKNOWN_SENDER');
|
||||
|
||||
await registerAgent({ name: 'agent-ui-1', role: 'ui' }, { now: () => '2026-02-14T00:00:00.000Z' });
|
||||
|
||||
const unknownRecipient = await sendAgentMessage({
|
||||
from: 'agent-ui-1',
|
||||
to: 'agent-graph-1',
|
||||
bead: 'bb-dcv.6',
|
||||
category: 'HANDOFF',
|
||||
subject: 'subject',
|
||||
body: 'body',
|
||||
});
|
||||
|
||||
assert.equal(unknownRecipient.ok, false);
|
||||
assert.equal(unknownRecipient.error?.code, 'UNKNOWN_RECIPIENT');
|
||||
});
|
||||
});
|
||||
|
||||
test('send/inbox/read/ack flows end-to-end', async () => {
|
||||
await withTempUserProfile(async () => {
|
||||
await seedAgents();
|
||||
|
||||
const sent = await sendAgentMessage(
|
||||
{
|
||||
from: 'agent-ui-1',
|
||||
to: 'agent-graph-1',
|
||||
bead: 'bb-dcv.6',
|
||||
category: 'HANDOFF',
|
||||
subject: 'Edge direction patch ready',
|
||||
body: 'Please validate graph screenshots.',
|
||||
},
|
||||
{
|
||||
now: () => '2026-02-14T00:01:00.000Z',
|
||||
idGenerator: () => 'msg_20260214_000100_test',
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(sent.ok, true);
|
||||
assert.equal(sent.data?.requires_ack, true);
|
||||
assert.equal(sent.data?.state, 'unread');
|
||||
|
||||
const inboxUnread = await inboxAgentMessages({ agent: 'agent-graph-1', state: 'unread' });
|
||||
assert.equal(inboxUnread.ok, true);
|
||||
assert.equal(inboxUnread.data?.length, 1);
|
||||
|
||||
const read = await readAgentMessage(
|
||||
{ agent: 'agent-graph-1', message: 'msg_20260214_000100_test' },
|
||||
{ now: () => '2026-02-14T00:02:00.000Z' },
|
||||
);
|
||||
assert.equal(read.ok, true);
|
||||
assert.equal(read.data?.state, 'read');
|
||||
|
||||
const ack = await ackAgentMessage(
|
||||
{ agent: 'agent-graph-1', message: 'msg_20260214_000100_test' },
|
||||
{ now: () => '2026-02-14T00:03:00.000Z' },
|
||||
);
|
||||
assert.equal(ack.ok, true);
|
||||
assert.equal(ack.data?.state, 'acked');
|
||||
assert.equal(ack.data?.acked_at, '2026-02-14T00:03:00.000Z');
|
||||
|
||||
const inboxAcked = await inboxAgentMessages({ agent: 'agent-graph-1', state: 'acked' });
|
||||
assert.equal(inboxAcked.ok, true);
|
||||
assert.equal(inboxAcked.data?.length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
test('ackAgentMessage forbids non-recipient agent', async () => {
|
||||
await withTempUserProfile(async () => {
|
||||
await seedAgents();
|
||||
|
||||
await sendAgentMessage(
|
||||
{
|
||||
from: 'agent-ui-1',
|
||||
to: 'agent-graph-1',
|
||||
bead: 'bb-dcv.6',
|
||||
category: 'HANDOFF',
|
||||
subject: 'subject',
|
||||
body: 'body',
|
||||
},
|
||||
{
|
||||
now: () => '2026-02-14T00:01:00.000Z',
|
||||
idGenerator: () => 'msg_20260214_000100_forbidden',
|
||||
},
|
||||
);
|
||||
|
||||
const forbidden = await ackAgentMessage(
|
||||
{ agent: 'agent-ui-1', message: 'msg_20260214_000100_forbidden' },
|
||||
{ now: () => '2026-02-14T00:02:00.000Z' },
|
||||
);
|
||||
|
||||
assert.equal(forbidden.ok, false);
|
||||
assert.equal(forbidden.error?.code, 'ACK_FORBIDDEN');
|
||||
});
|
||||
});
|
||||
|
||||
test('sendAgentMessage validates category and bead id', async () => {
|
||||
await withTempUserProfile(async () => {
|
||||
await seedAgents();
|
||||
|
||||
const invalidCategory = await sendAgentMessage({
|
||||
from: 'agent-ui-1',
|
||||
to: 'agent-graph-1',
|
||||
bead: 'bb-dcv.6',
|
||||
category: 'NOPE' as never,
|
||||
subject: 'subject',
|
||||
body: 'body',
|
||||
});
|
||||
assert.equal(invalidCategory.ok, false);
|
||||
assert.equal(invalidCategory.error?.code, 'INVALID_CATEGORY');
|
||||
|
||||
const missingBead = await sendAgentMessage({
|
||||
from: 'agent-ui-1',
|
||||
to: 'agent-graph-1',
|
||||
bead: ' ',
|
||||
category: 'INFO',
|
||||
subject: 'subject',
|
||||
body: 'body',
|
||||
});
|
||||
assert.equal(missingBead.ok, false);
|
||||
assert.equal(missingBead.error?.code, 'MISSING_BEAD_ID');
|
||||
});
|
||||
});
|
||||
139
tests/lib/agent-registry.test.ts
Normal file
139
tests/lib/agent-registry.test.ts
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
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 {
|
||||
agentFilePath,
|
||||
listAgents,
|
||||
registerAgent,
|
||||
showAgent,
|
||||
type AgentRecord,
|
||||
} from '../../src/lib/agent-registry';
|
||||
|
||||
async function withTempUserProfile(run: () => Promise<void>): Promise<void> {
|
||||
const previous = process.env.USERPROFILE;
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-agent-reg-'));
|
||||
process.env.USERPROFILE = tempDir;
|
||||
|
||||
try {
|
||||
await run();
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env.USERPROFILE;
|
||||
} else {
|
||||
process.env.USERPROFILE = previous;
|
||||
}
|
||||
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test('registerAgent creates stable metadata file with idle status', async () => {
|
||||
await withTempUserProfile(async () => {
|
||||
const now = '2026-02-13T23:55:00.000Z';
|
||||
const result = await registerAgent(
|
||||
{
|
||||
name: 'agent-ui-1',
|
||||
display: 'UI Agent 1',
|
||||
role: 'ui',
|
||||
},
|
||||
{ now: () => now },
|
||||
);
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.command, 'agent register');
|
||||
assert.equal(result.data?.agent_id, 'agent-ui-1');
|
||||
assert.equal(result.data?.status, 'idle');
|
||||
assert.equal(result.data?.created_at, now);
|
||||
assert.equal(result.data?.last_seen_at, now);
|
||||
assert.equal(result.data?.version, 1);
|
||||
|
||||
const file = await fs.readFile(agentFilePath('agent-ui-1'), 'utf8');
|
||||
const parsed = JSON.parse(file) as AgentRecord;
|
||||
assert.equal(parsed.agent_id, 'agent-ui-1');
|
||||
assert.equal(parsed.display_name, 'UI Agent 1');
|
||||
});
|
||||
});
|
||||
|
||||
test('registerAgent rejects duplicate id without --force-update', async () => {
|
||||
await withTempUserProfile(async () => {
|
||||
await registerAgent({ name: 'agent-ui-1', role: 'ui' }, { now: () => '2026-02-13T23:55:00.000Z' });
|
||||
|
||||
const duplicate = await registerAgent({ name: 'agent-ui-1', role: 'ui' }, { now: () => '2026-02-13T23:56:00.000Z' });
|
||||
|
||||
assert.equal(duplicate.ok, false);
|
||||
assert.equal(duplicate.error?.code, 'DUPLICATE_AGENT_ID');
|
||||
});
|
||||
});
|
||||
|
||||
test('registerAgent force update mutates display/role but keeps created_at', async () => {
|
||||
await withTempUserProfile(async () => {
|
||||
const first = await registerAgent(
|
||||
{ name: 'agent-ui-1', display: 'UI Agent', role: 'ui' },
|
||||
{ now: () => '2026-02-13T23:55:00.000Z' },
|
||||
);
|
||||
assert.equal(first.ok, true);
|
||||
|
||||
const updated = await registerAgent(
|
||||
{ name: 'agent-ui-1', display: 'Frontend Agent', role: 'frontend', forceUpdate: true },
|
||||
{ now: () => '2026-02-13T23:56:00.000Z' },
|
||||
);
|
||||
|
||||
assert.equal(updated.ok, true);
|
||||
assert.equal(updated.data?.display_name, 'Frontend Agent');
|
||||
assert.equal(updated.data?.role, 'frontend');
|
||||
assert.equal(updated.data?.created_at, '2026-02-13T23:55:00.000Z');
|
||||
assert.equal(updated.data?.last_seen_at, '2026-02-13T23:56:00.000Z');
|
||||
});
|
||||
});
|
||||
|
||||
test('listAgents sorts and filters by role/status', async () => {
|
||||
await withTempUserProfile(async () => {
|
||||
await registerAgent({ name: 'agent-b', role: 'backend' }, { now: () => '2026-02-13T23:55:00.000Z' });
|
||||
await registerAgent({ name: 'agent-a', role: 'ui' }, { now: () => '2026-02-13T23:55:00.000Z' });
|
||||
|
||||
await registerAgent(
|
||||
{ name: 'agent-b', role: 'backend', forceUpdate: true },
|
||||
{ now: () => '2026-02-13T23:56:00.000Z' },
|
||||
);
|
||||
|
||||
const all = await listAgents({});
|
||||
assert.equal(all.ok, true);
|
||||
assert.deepEqual(
|
||||
all.data?.map((agent) => agent.agent_id),
|
||||
['agent-a', 'agent-b'],
|
||||
);
|
||||
|
||||
const byRole = await listAgents({ role: 'ui' });
|
||||
assert.deepEqual(
|
||||
byRole.data?.map((agent) => agent.agent_id),
|
||||
['agent-a'],
|
||||
);
|
||||
|
||||
const byStatus = await listAgents({ status: 'idle' });
|
||||
assert.equal(byStatus.ok, true);
|
||||
assert.equal(byStatus.data?.length, 2);
|
||||
});
|
||||
});
|
||||
|
||||
test('showAgent returns AGENT_NOT_FOUND for unknown id', async () => {
|
||||
await withTempUserProfile(async () => {
|
||||
const result = await showAgent({ agent: 'agent-missing' });
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.error?.code, 'AGENT_NOT_FOUND');
|
||||
});
|
||||
});
|
||||
|
||||
test('registerAgent validates id pattern and role', async () => {
|
||||
await withTempUserProfile(async () => {
|
||||
const badName = await registerAgent({ name: 'Agent_Upper', role: 'ui' });
|
||||
assert.equal(badName.ok, false);
|
||||
assert.equal(badName.error?.code, 'INVALID_AGENT_ID');
|
||||
|
||||
const badRole = await registerAgent({ name: 'agent-ok-1', role: ' ' });
|
||||
assert.equal(badRole.ok, false);
|
||||
assert.equal(badRole.error?.code, 'INVALID_ROLE');
|
||||
});
|
||||
});
|
||||
178
tests/lib/agent-reservations.test.ts
Normal file
178
tests/lib/agent-reservations.test.ts
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
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 { registerAgent } from '../../src/lib/agent-registry';
|
||||
import { sendAgentMessage } from '../../src/lib/agent-mail';
|
||||
import { releaseAgentReservation, reserveAgentScope, statusAgentReservations } from '../../src/lib/agent-reservations';
|
||||
|
||||
async function withTempUserProfile(run: () => Promise<void>): Promise<void> {
|
||||
const previous = process.env.USERPROFILE;
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-agent-reservations-'));
|
||||
process.env.USERPROFILE = tempDir;
|
||||
|
||||
try {
|
||||
await run();
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env.USERPROFILE;
|
||||
} else {
|
||||
process.env.USERPROFILE = previous;
|
||||
}
|
||||
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function seedAgents(): Promise<void> {
|
||||
const now = '2026-02-14T00:00:00.000Z';
|
||||
await registerAgent({ name: 'agent-ui-1', role: 'ui' }, { now: () => now });
|
||||
await registerAgent({ name: 'agent-graph-1', role: 'graph' }, { now: () => now });
|
||||
}
|
||||
|
||||
test('reserve/release/status flows with required-ack status visibility', async () => {
|
||||
await withTempUserProfile(async () => {
|
||||
await seedAgents();
|
||||
|
||||
const reserved = await reserveAgentScope(
|
||||
{
|
||||
agent: 'agent-ui-1',
|
||||
scope: 'src/components/graph/*',
|
||||
bead: 'bb-dcv.4',
|
||||
},
|
||||
{
|
||||
now: () => '2026-02-14T00:01:00.000Z',
|
||||
idGenerator: () => 'res_20260214_000100_flow',
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(reserved.ok, true);
|
||||
assert.equal(reserved.data?.reservation_id, 'res_20260214_000100_flow');
|
||||
|
||||
await sendAgentMessage(
|
||||
{
|
||||
from: 'agent-ui-1',
|
||||
to: 'agent-graph-1',
|
||||
bead: 'bb-dcv.4',
|
||||
category: 'HANDOFF',
|
||||
subject: 'handoff',
|
||||
body: 'please review',
|
||||
},
|
||||
{
|
||||
now: () => '2026-02-14T00:02:00.000Z',
|
||||
idGenerator: () => 'msg_20260214_000200_flow',
|
||||
},
|
||||
);
|
||||
|
||||
const statusBeforeRelease = await statusAgentReservations({ bead: 'bb-dcv.4' }, { now: () => '2026-02-14T00:03:00.000Z' });
|
||||
assert.equal(statusBeforeRelease.ok, true);
|
||||
assert.equal(statusBeforeRelease.data?.reservations.length, 1);
|
||||
assert.equal(statusBeforeRelease.data?.unacked_required_messages.length, 1);
|
||||
|
||||
const released = await releaseAgentReservation(
|
||||
{
|
||||
agent: 'agent-ui-1',
|
||||
scope: 'src/components/graph/*',
|
||||
},
|
||||
{ now: () => '2026-02-14T00:04:00.000Z' },
|
||||
);
|
||||
|
||||
assert.equal(released.ok, true);
|
||||
assert.equal(released.data?.state, 'released');
|
||||
|
||||
const statusAfterRelease = await statusAgentReservations({ bead: 'bb-dcv.4' }, { now: () => '2026-02-14T00:05:00.000Z' });
|
||||
assert.equal(statusAfterRelease.ok, true);
|
||||
assert.equal(statusAfterRelease.data?.reservations.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
test('status clears expired reservations after TTL elapses', async () => {
|
||||
await withTempUserProfile(async () => {
|
||||
await seedAgents();
|
||||
|
||||
const reserved = await reserveAgentScope(
|
||||
{
|
||||
agent: 'agent-ui-1',
|
||||
scope: 'src/components/kanban/*',
|
||||
bead: 'bb-dcv.4',
|
||||
ttl: 5,
|
||||
},
|
||||
{
|
||||
now: () => '2026-02-14T00:00:00.000Z',
|
||||
idGenerator: () => 'res_20260214_000000_expire',
|
||||
},
|
||||
);
|
||||
assert.equal(reserved.ok, true);
|
||||
|
||||
const status = await statusAgentReservations({}, { now: () => '2026-02-14T00:06:00.000Z' });
|
||||
assert.equal(status.ok, true);
|
||||
assert.equal(status.data?.reservations.length, 0);
|
||||
assert.equal(status.data?.summary.expired, 1);
|
||||
});
|
||||
});
|
||||
|
||||
test('stale reservation conflict and takeover behavior', async () => {
|
||||
await withTempUserProfile(async () => {
|
||||
await seedAgents();
|
||||
|
||||
const initial = await reserveAgentScope(
|
||||
{
|
||||
agent: 'agent-ui-1',
|
||||
scope: 'src/components/workspace/*',
|
||||
bead: 'bb-dcv.4',
|
||||
ttl: 5,
|
||||
},
|
||||
{
|
||||
now: () => '2026-02-14T00:00:00.000Z',
|
||||
idGenerator: () => 'res_20260214_000000_stale',
|
||||
},
|
||||
);
|
||||
assert.equal(initial.ok, true);
|
||||
|
||||
const staleConflict = await reserveAgentScope(
|
||||
{
|
||||
agent: 'agent-graph-1',
|
||||
scope: 'src/components/workspace/*',
|
||||
bead: 'bb-dcv.4',
|
||||
ttl: 5,
|
||||
},
|
||||
{
|
||||
now: () => '2026-02-14T00:06:00.000Z',
|
||||
idGenerator: () => 'res_20260214_000600_takeover',
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(staleConflict.ok, false);
|
||||
assert.equal(staleConflict.error?.code, 'RESERVATION_STALE_FOUND');
|
||||
|
||||
const takeover = await reserveAgentScope(
|
||||
{
|
||||
agent: 'agent-graph-1',
|
||||
scope: 'src/components/workspace/*',
|
||||
bead: 'bb-dcv.4',
|
||||
ttl: 5,
|
||||
takeoverStale: true,
|
||||
},
|
||||
{
|
||||
now: () => '2026-02-14T00:06:00.000Z',
|
||||
idGenerator: () => 'res_20260214_000600_takeover',
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(takeover.ok, true);
|
||||
assert.equal(takeover.data?.agent_id, 'agent-graph-1');
|
||||
|
||||
const wrongRelease = await releaseAgentReservation(
|
||||
{
|
||||
agent: 'agent-ui-1',
|
||||
scope: 'src/components/workspace/*',
|
||||
},
|
||||
{ now: () => '2026-02-14T00:07:00.000Z' },
|
||||
);
|
||||
|
||||
assert.equal(wrongRelease.ok, false);
|
||||
assert.equal(wrongRelease.error?.code, 'RELEASE_FORBIDDEN');
|
||||
});
|
||||
});
|
||||
|
|
@ -4,15 +4,15 @@ import fs from 'node:fs/promises';
|
|||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { IssuesEventBus } from '../../src/lib/realtime';
|
||||
import { IssuesEventBus, ActivityEventBus } from '../../src/lib/realtime';
|
||||
import { IssuesWatchManager } from '../../src/lib/watcher';
|
||||
|
||||
test('IssuesWatchManager startWatch is idempotent per project', async () => {
|
||||
const bus = new IssuesEventBus();
|
||||
const manager = new IssuesWatchManager({ eventBus: bus, debounceMs: 20 });
|
||||
|
||||
manager.startWatch('C:/Repo/One');
|
||||
manager.startWatch('c:\\repo\\one');
|
||||
await manager.startWatch('C:/Repo/One');
|
||||
await manager.startWatch('c:\\repo\\one');
|
||||
|
||||
assert.equal(manager.getWatchedProjectCount(), 1);
|
||||
await manager.stopAll();
|
||||
|
|
@ -33,7 +33,7 @@ test('IssuesWatchManager emits event after file change in watched .beads path',
|
|||
events.push(event.projectRoot);
|
||||
});
|
||||
|
||||
manager.startWatch(root);
|
||||
await manager.startWatch(root);
|
||||
|
||||
await fs.writeFile(issuesPath, `${JSON.stringify({ id: 'bb-1', title: 'watch' })}\n`, 'utf8');
|
||||
await new Promise((resolve) => setTimeout(resolve, 220));
|
||||
|
|
@ -43,3 +43,99 @@ test('IssuesWatchManager emits event after file change in watched .beads path',
|
|||
|
||||
assert.equal(events.length >= 1, true);
|
||||
});
|
||||
|
||||
test('IssuesWatchManager emits event after beads.db change', async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-watch-db-'));
|
||||
const beadsDir = path.join(root, '.beads');
|
||||
const dbPath = path.join(beadsDir, 'beads.db');
|
||||
await fs.mkdir(beadsDir, { recursive: true });
|
||||
await fs.writeFile(dbPath, 'seed', 'utf8');
|
||||
|
||||
const bus = new IssuesEventBus();
|
||||
const manager = new IssuesWatchManager({ eventBus: bus, debounceMs: 40 });
|
||||
|
||||
const events: string[] = [];
|
||||
const stop = bus.subscribe((event) => {
|
||||
events.push(event.projectRoot);
|
||||
});
|
||||
|
||||
await manager.startWatch(root);
|
||||
|
||||
await fs.writeFile(dbPath, `seed-${Date.now()}`, 'utf8');
|
||||
await new Promise((resolve) => setTimeout(resolve, 220));
|
||||
|
||||
stop();
|
||||
await manager.stopAll();
|
||||
|
||||
assert.equal(events.length >= 1, true);
|
||||
});
|
||||
|
||||
test('IssuesWatchManager emits event after beads.db-wal change', async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-watch-wal-'));
|
||||
const beadsDir = path.join(root, '.beads');
|
||||
const walPath = path.join(beadsDir, 'beads.db-wal');
|
||||
await fs.mkdir(beadsDir, { recursive: true });
|
||||
await fs.writeFile(walPath, 'seed', 'utf8');
|
||||
|
||||
const bus = new IssuesEventBus();
|
||||
const manager = new IssuesWatchManager({ eventBus: bus, debounceMs: 40 });
|
||||
|
||||
const events: string[] = [];
|
||||
const stop = bus.subscribe((event) => {
|
||||
events.push(event.projectRoot);
|
||||
});
|
||||
|
||||
await manager.startWatch(root);
|
||||
|
||||
await fs.writeFile(walPath, `seed-${Date.now()}`, 'utf8');
|
||||
await new Promise((resolve) => setTimeout(resolve, 220));
|
||||
|
||||
stop();
|
||||
await manager.stopAll();
|
||||
|
||||
assert.equal(events.length >= 1, true);
|
||||
});
|
||||
|
||||
test('IssuesWatchManager emits ActivityEvent on issue change', async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-watch-activity-'));
|
||||
const beadsDir = path.join(root, '.beads');
|
||||
const issuesPath = path.join(beadsDir, 'issues.jsonl');
|
||||
|
||||
await fs.mkdir(beadsDir, { recursive: true });
|
||||
|
||||
// Initial state: 1 issue
|
||||
const issuev1 = { id: 'bb-1', title: 'Task A', status: 'open' };
|
||||
await fs.writeFile(issuesPath, JSON.stringify(issuev1) + '\n', 'utf8');
|
||||
|
||||
const issuesBus = new IssuesEventBus();
|
||||
const activityBus = new ActivityEventBus();
|
||||
const manager = new IssuesWatchManager({
|
||||
eventBus: issuesBus,
|
||||
activityBus,
|
||||
debounceMs: 50
|
||||
});
|
||||
|
||||
const activities: string[] = [];
|
||||
const stop = activityBus.subscribe((e) => {
|
||||
activities.push(`${e.event.kind}:${e.event.beadId}`);
|
||||
});
|
||||
|
||||
// Start watching (should load initial snapshot silently)
|
||||
await manager.startWatch(root);
|
||||
|
||||
// Wait for initial read to settle
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
// Modify issue: status change
|
||||
const issuev2 = { ...issuev1, status: 'in_progress' };
|
||||
await fs.writeFile(issuesPath, JSON.stringify(issuev2) + '\n', 'utf8');
|
||||
|
||||
// Wait for debounce + processing
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
stop();
|
||||
await manager.stopAll();
|
||||
|
||||
// Expect status_changed for bb-1
|
||||
assert.ok(activities.includes('status_changed:bb-1'), `Expected status_changed event. Got: ${activities.join(', ')}`);
|
||||
});
|
||||
|
|
|
|||
79
tests/skills/beadboard-driver/generate-agent-name.test.ts
Normal file
79
tests/skills/beadboard-driver/generate-agent-name.test.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
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 { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const scriptPath = path.resolve('skills/beadboard-driver/scripts/generate-agent-name.mjs');
|
||||
|
||||
async function runName(env: Record<string, string | undefined> = {}) {
|
||||
const { stdout } = await execFileAsync('node', [scriptPath], {
|
||||
env: { ...process.env, ...env },
|
||||
});
|
||||
return JSON.parse(stdout);
|
||||
}
|
||||
|
||||
async function withTempDir(run: (root: string) => Promise<void>) {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'bb-skill-name-'));
|
||||
try {
|
||||
await run(root);
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test('generate-agent-name returns adjective-noun format', async () => {
|
||||
const result = await runName({
|
||||
BB_NAME_ADJECTIVES: 'green',
|
||||
BB_NAME_NOUNS: 'castle',
|
||||
BB_NAME_MAX_RETRIES: '1',
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.agent_name, 'green-castle');
|
||||
assert.match(result.agent_name, /^[a-z0-9]+(?:-[a-z0-9]+)*$/);
|
||||
});
|
||||
|
||||
test('generate-agent-name retries on collisions', async () => {
|
||||
await withTempDir(async (root) => {
|
||||
const registryDir = path.join(root, 'agents');
|
||||
await fs.mkdir(registryDir, { recursive: true });
|
||||
await fs.writeFile(path.join(registryDir, 'green-castle.json'), '{}', 'utf8');
|
||||
|
||||
const result = await runName({
|
||||
BB_AGENT_REGISTRY_DIR: registryDir,
|
||||
BB_NAME_ADJECTIVES: 'green,blue',
|
||||
BB_NAME_NOUNS: 'castle',
|
||||
BB_NAME_MAX_RETRIES: '3',
|
||||
BB_NAME_SEED_SEQUENCE: '0,0,0.9,0',
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.agent_name, 'blue-castle');
|
||||
assert.equal(result.collisions, 2);
|
||||
assert.equal(result.attempts, 3);
|
||||
});
|
||||
});
|
||||
|
||||
test('generate-agent-name fails after retry exhaustion', async () => {
|
||||
await withTempDir(async (root) => {
|
||||
const registryDir = path.join(root, 'agents');
|
||||
await fs.mkdir(registryDir, { recursive: true });
|
||||
await fs.writeFile(path.join(registryDir, 'green-castle.json'), '{}', 'utf8');
|
||||
|
||||
const result = await runName({
|
||||
BB_AGENT_REGISTRY_DIR: registryDir,
|
||||
BB_NAME_ADJECTIVES: 'green',
|
||||
BB_NAME_NOUNS: 'castle',
|
||||
BB_NAME_MAX_RETRIES: '2',
|
||||
BB_NAME_SEED_SEQUENCE: '0,0,0,0',
|
||||
});
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.error_code, 'NAME_GENERATION_EXHAUSTED');
|
||||
assert.equal(result.attempts, 2);
|
||||
});
|
||||
});
|
||||
57
tests/skills/beadboard-driver/readiness-report.test.ts
Normal file
57
tests/skills/beadboard-driver/readiness-report.test.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
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 { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const scriptPath = path.resolve('skills/beadboard-driver/scripts/readiness-report.mjs');
|
||||
|
||||
async function runReport(args: string[]) {
|
||||
const { stdout } = await execFileAsync('node', [scriptPath, ...args], {
|
||||
env: process.env,
|
||||
});
|
||||
return JSON.parse(stdout);
|
||||
}
|
||||
|
||||
async function withTempDir(run: (root: string) => Promise<void>) {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'bb-skill-report-'));
|
||||
try {
|
||||
await run(root);
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test('readiness-report outputs stable schema', async () => {
|
||||
await withTempDir(async (root) => {
|
||||
const artifact = path.join(root, 'artifact.txt');
|
||||
await fs.writeFile(artifact, 'ok', 'utf8');
|
||||
|
||||
const checks = JSON.stringify([
|
||||
{ name: 'typecheck', ok: true, details: 'pass' },
|
||||
{ name: 'test', ok: true, details: 'pass' },
|
||||
]);
|
||||
const artifacts = JSON.stringify([{ path: artifact, required: true }]);
|
||||
|
||||
const result = await runReport(['--checks', checks, '--artifacts', artifacts, '--dependency-note', 'acyclic']);
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.summary.ready, true);
|
||||
assert.equal(result.checks.length, 2);
|
||||
assert.equal(result.artifacts[0].exists, true);
|
||||
assert.equal(result.dependency_sanity, 'acyclic');
|
||||
});
|
||||
});
|
||||
|
||||
test('readiness-report flags missing required artifact', async () => {
|
||||
const checks = JSON.stringify([{ name: 'lint', ok: true, details: 'pass' }]);
|
||||
const artifacts = JSON.stringify([{ path: 'missing.png', required: true }]);
|
||||
|
||||
const result = await runReport(['--checks', checks, '--artifacts', artifacts]);
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.summary.ready, false);
|
||||
assert.equal(result.artifacts[0].exists, false);
|
||||
});
|
||||
137
tests/skills/beadboard-driver/resolve-bb.test.ts
Normal file
137
tests/skills/beadboard-driver/resolve-bb.test.ts
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
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 { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const scriptPath = path.resolve('skills/beadboard-driver/scripts/resolve-bb.mjs');
|
||||
|
||||
async function runResolve(env: Record<string, string | undefined> = {}) {
|
||||
const { stdout } = await execFileAsync(process.execPath, [scriptPath], {
|
||||
env: { ...process.env, ...env },
|
||||
});
|
||||
return JSON.parse(stdout);
|
||||
}
|
||||
|
||||
async function withTempDir(run: (root: string) => Promise<void>) {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'bb-skill-resolve-'));
|
||||
try {
|
||||
await run(root);
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test('resolve-bb uses BB_REPO and returns env source', async () => {
|
||||
await withTempDir(async (root) => {
|
||||
const repo = path.join(root, 'beadboard');
|
||||
await fs.mkdir(path.join(repo, 'tools'), { recursive: true });
|
||||
await fs.writeFile(path.join(repo, 'bb.ps1'), 'echo ok', 'utf8');
|
||||
|
||||
const result = await runResolve({
|
||||
BB_REPO: repo,
|
||||
BB_SKILL_HOME: path.join(root, 'home'),
|
||||
PATH: '',
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.source, 'env');
|
||||
assert.equal(result.resolved_path, path.join(repo, 'bb.ps1'));
|
||||
});
|
||||
});
|
||||
|
||||
test('resolve-bb fails with remediation when BB_REPO is invalid', async () => {
|
||||
await withTempDir(async (root) => {
|
||||
const result = await runResolve({
|
||||
BB_REPO: path.join(root, 'missing'),
|
||||
BB_SKILL_HOME: path.join(root, 'home'),
|
||||
PATH: '',
|
||||
});
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.source, 'env');
|
||||
assert.match(result.reason, /BB_REPO/i);
|
||||
assert.match(result.remediation, /Set BB_REPO/i);
|
||||
});
|
||||
});
|
||||
|
||||
test('resolve-bb uses cache when env and global are unavailable', async () => {
|
||||
await withTempDir(async (root) => {
|
||||
const repo = path.join(root, 'beadboard');
|
||||
const home = path.join(root, 'home');
|
||||
await fs.mkdir(path.join(repo, 'tools'), { recursive: true });
|
||||
await fs.writeFile(path.join(repo, 'bb.ps1'), 'echo ok', 'utf8');
|
||||
await fs.mkdir(path.join(home, '.beadboard'), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(home, '.beadboard', 'skill-config.json'),
|
||||
JSON.stringify({ bb_path: path.join(repo, 'bb.ps1') }, null, 2),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const result = await runResolve({
|
||||
BB_SKILL_HOME: home,
|
||||
PATH: '',
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.source, 'cache');
|
||||
});
|
||||
});
|
||||
|
||||
test('resolve-bb discovers repo and self-updates cache', async () => {
|
||||
await withTempDir(async (root) => {
|
||||
const repo = path.join(root, 'workspace', 'beadboard');
|
||||
const home = path.join(root, 'home');
|
||||
await fs.mkdir(path.join(repo, 'tools'), { recursive: true });
|
||||
await fs.writeFile(path.join(repo, 'bb.ps1'), 'echo ok', 'utf8');
|
||||
|
||||
const result = await runResolve({
|
||||
BB_SKILL_HOME: home,
|
||||
BB_SEARCH_ROOTS: path.join(root, 'workspace'),
|
||||
PATH: '',
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.source, 'discovery');
|
||||
|
||||
const cacheRaw = await fs.readFile(path.join(home, '.beadboard', 'skill-config.json'), 'utf8');
|
||||
const cache = JSON.parse(cacheRaw);
|
||||
assert.equal(cache.bb_path, path.join(repo, 'bb.ps1'));
|
||||
});
|
||||
});
|
||||
|
||||
test('resolve-bb uses BB_REPO over cache and rewrites stale cache', async () => {
|
||||
await withTempDir(async (root) => {
|
||||
const repoA = path.join(root, 'repo-a');
|
||||
const repoB = path.join(root, 'repo-b');
|
||||
const home = path.join(root, 'home');
|
||||
|
||||
await fs.mkdir(path.join(repoA, 'tools'), { recursive: true });
|
||||
await fs.mkdir(path.join(repoB, 'tools'), { recursive: true });
|
||||
await fs.writeFile(path.join(repoA, 'bb.ps1'), 'echo a', 'utf8');
|
||||
await fs.writeFile(path.join(repoB, 'bb.ps1'), 'echo b', 'utf8');
|
||||
await fs.mkdir(path.join(home, '.beadboard'), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(home, '.beadboard', 'skill-config.json'),
|
||||
JSON.stringify({ bb_path: path.join(repoA, 'bb.ps1') }, null, 2),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const result = await runResolve({
|
||||
BB_REPO: repoB,
|
||||
BB_SKILL_HOME: home,
|
||||
PATH: '',
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.source, 'env');
|
||||
assert.match(result.reason, /cache mismatch/i);
|
||||
|
||||
const cacheRaw = await fs.readFile(path.join(home, '.beadboard', 'skill-config.json'), 'utf8');
|
||||
const cache = JSON.parse(cacheRaw);
|
||||
assert.equal(cache.bb_path, path.join(repoB, 'bb.ps1'));
|
||||
});
|
||||
});
|
||||
60
tests/skills/beadboard-driver/session-preflight.test.ts
Normal file
60
tests/skills/beadboard-driver/session-preflight.test.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
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 { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const scriptPath = path.resolve('skills/beadboard-driver/scripts/session-preflight.mjs');
|
||||
|
||||
async function runPreflight(env: Record<string, string | undefined> = {}) {
|
||||
const { stdout } = await execFileAsync(process.execPath, [scriptPath], {
|
||||
env: { ...process.env, ...env },
|
||||
});
|
||||
return JSON.parse(stdout);
|
||||
}
|
||||
|
||||
async function withTempDir(run: (root: string) => Promise<void>) {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'bb-skill-preflight-'));
|
||||
try {
|
||||
await run(root);
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test('session-preflight fails when bd is unavailable', async () => {
|
||||
const result = await runPreflight({
|
||||
PATH: '',
|
||||
});
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.error_code, 'BD_NOT_FOUND');
|
||||
});
|
||||
|
||||
test('session-preflight succeeds with fake bd and BB_REPO', async () => {
|
||||
await withTempDir(async (root) => {
|
||||
const repo = path.join(root, 'beadboard');
|
||||
const toolsDir = path.join(root, 'tools');
|
||||
const bdCmd = path.join(toolsDir, 'bd.cmd');
|
||||
|
||||
await fs.mkdir(path.join(repo, 'tools'), { recursive: true });
|
||||
await fs.mkdir(toolsDir, { recursive: true });
|
||||
await fs.writeFile(path.join(repo, 'bb.ps1'), 'echo ok', 'utf8');
|
||||
await fs.writeFile(bdCmd, '@echo off\r\necho beads\r\n', 'utf8');
|
||||
|
||||
const result = await runPreflight({
|
||||
PATH: toolsDir,
|
||||
BB_REPO: repo,
|
||||
BB_SKILL_HOME: path.join(root, 'home'),
|
||||
BB_SKIP_PROBE: '1',
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.bb.ok, true);
|
||||
assert.equal(result.bb.source, 'env');
|
||||
assert.equal(result.tools.bd.available, true);
|
||||
});
|
||||
});
|
||||
15
tests/skills/beadboard-driver/skill-local-runner.test.ts
Normal file
15
tests/skills/beadboard-driver/skill-local-runner.test.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import path from 'node:path';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
test('skill-local runner passes', async () => {
|
||||
const runnerPath = path.resolve('skills/beadboard-driver/tests/run-tests.mjs');
|
||||
const { stdout, stderr } = await execFileAsync(process.execPath, [runnerPath], {
|
||||
env: process.env,
|
||||
});
|
||||
assert.doesNotMatch(`${stdout}\n${stderr}`, /not ok/i);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue