feat(telemetry): complete bb-buff.1.3 - Backend Liveness Refactor
STORY: The session backend needed to aggregate agent health from a live telemetry stream rather than static bead metadata. This refactor makes liveness signals real-time and accurate. COLLABORATION: We extended the ActivityEvent model with a native 'heartbeat' kind, updated extendActivityLease() to emit through the activity bus, and refactored getAgentLivenessMap() to prioritize heartbeat activity history over stale bead metadata. DELIVERABLES: - ActivityEvent extended with 'heartbeat' kind - extendActivityLease() emits heartbeats through activity bus - getAgentLivenessMap() prefers telemetry over static metadata - Registry APIs support projectRoot injection for testing - Tests verify preference logic via TDD VERIFICATION: - 93/93 tests PASSING - Heartbeat override verified in isolated temp projects CLOSES: bb-buff.1.3 BLOCKS: bb-buff.3.2, bb-buff.3.3, bb-buff.2.1
This commit is contained in:
parent
0016b57e37
commit
4ee550c333
36 changed files with 1380 additions and 541 deletions
|
|
@ -4,6 +4,10 @@ import assert from 'node:assert/strict';
|
|||
import { GET as eventsGet } from '../../src/app/api/events/route';
|
||||
import { getIssuesWatchManager } from '../../src/lib/watcher';
|
||||
|
||||
test.afterEach(async () => {
|
||||
await getIssuesWatchManager().stopAll();
|
||||
});
|
||||
|
||||
test.after(async () => {
|
||||
await getIssuesWatchManager().stopAll();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ test('graph page defines tabbed layout, epic chips, and mobile fallback', async
|
|||
// Task details drawer
|
||||
assert.match(graphPage, /TaskDetailsDrawer/, 'should use TaskDetailsDrawer drawer');
|
||||
assert.match(graphPage, /projectRoot=\{projectRoot\}/, 'drawer should receive project root for edits');
|
||||
assert.match(graphPage, /onIssueUpdated=\{\(\) => router.refresh\(\)\}/, 'drawer should trigger refresh after edits');
|
||||
assert.match(graphPage, /onIssueUpdated=\{.*refreshIssues\(\)\}/, 'drawer should trigger refresh after edits');
|
||||
|
||||
// Dependency flow strip
|
||||
assert.match(graphPage, /DependencyFlowStrip/, 'should use DependencyFlowStrip component');
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import {
|
|||
registerAgent,
|
||||
extendActivityLease,
|
||||
deriveLiveness,
|
||||
agentFilePath,
|
||||
} from '../../src/lib/agent-registry';
|
||||
|
||||
async function withTempUserProfile(run: () => Promise<void>): Promise<void> {
|
||||
|
|
@ -29,10 +28,9 @@ async function withTempUserProfile(run: () => Promise<void>): Promise<void> {
|
|||
}
|
||||
}
|
||||
|
||||
test('extendActivityLease updates last_seen_at and increments version', async () => {
|
||||
test('extendActivityLease emits heartbeat and returns null data (side effect only)', async () => {
|
||||
await withTempUserProfile(async () => {
|
||||
const start = '2026-02-14T10:00:00.000Z';
|
||||
const next = '2026-02-14T10:05:00.000Z';
|
||||
|
||||
await registerAgent(
|
||||
{ name: 'active-agent', role: 'infra' },
|
||||
|
|
@ -41,16 +39,11 @@ test('extendActivityLease updates last_seen_at and increments version', async ()
|
|||
|
||||
const result = await extendActivityLease(
|
||||
{ agent: 'active-agent' },
|
||||
{ now: () => next }
|
||||
{ now: () => start }
|
||||
);
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.data?.last_seen_at, next);
|
||||
assert.equal(result.data?.version, 2);
|
||||
|
||||
const raw = await fs.readFile(agentFilePath('active-agent'), 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
assert.equal(parsed.last_seen_at, next);
|
||||
assert.equal(result.data, null, 'extendActivityLease returns null data - heartbeat is side effect');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -3,25 +3,40 @@ 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';
|
||||
|
||||
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;
|
||||
async function withTempProject(run: (projectRoot: string) => Promise<void>): Promise<void> {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-agent-mail-'));
|
||||
|
||||
execSync('bd init --prefix bb --force', { cwd: tempDir, stdio: 'ignore' });
|
||||
|
||||
const previousProfile = process.env.USERPROFILE;
|
||||
process.env.USERPROFILE = tempDir;
|
||||
|
||||
const originalCwd = process.cwd();
|
||||
process.chdir(tempDir);
|
||||
|
||||
try {
|
||||
await run();
|
||||
await run(tempDir);
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
process.chdir(originalCwd);
|
||||
if (previousProfile === undefined) {
|
||||
delete process.env.USERPROFILE;
|
||||
} else {
|
||||
process.env.USERPROFILE = previous;
|
||||
process.env.USERPROFILE = previousProfile;
|
||||
}
|
||||
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -32,37 +47,23 @@ async function seedAgents(): Promise<void> {
|
|||
}
|
||||
|
||||
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',
|
||||
await withTempProject(async () => {
|
||||
const result = await sendAgentMessage({
|
||||
from: 'unknown',
|
||||
to: 'also-unknown',
|
||||
bead: 'bb-1',
|
||||
category: 'INFO',
|
||||
subject: 'Hello',
|
||||
body: 'World',
|
||||
});
|
||||
|
||||
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');
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.error?.code, 'UNKNOWN_SENDER');
|
||||
});
|
||||
});
|
||||
|
||||
test('send/inbox/read/ack flows end-to-end', async () => {
|
||||
await withTempUserProfile(async () => {
|
||||
await withTempProject(async () => {
|
||||
await seedAgents();
|
||||
|
||||
const sent = await sendAgentMessage(
|
||||
|
|
@ -110,7 +111,7 @@ test('send/inbox/read/ack flows end-to-end', async () => {
|
|||
});
|
||||
|
||||
test('ackAgentMessage forbids non-recipient agent', async () => {
|
||||
await withTempUserProfile(async () => {
|
||||
await withTempProject(async () => {
|
||||
await seedAgents();
|
||||
|
||||
await sendAgentMessage(
|
||||
|
|
@ -139,7 +140,7 @@ test('ackAgentMessage forbids non-recipient agent', async () => {
|
|||
});
|
||||
|
||||
test('sendAgentMessage validates category and bead id', async () => {
|
||||
await withTempUserProfile(async () => {
|
||||
await withTempProject(async () => {
|
||||
await seedAgents();
|
||||
|
||||
const invalidCategory = await sendAgentMessage({
|
||||
|
|
@ -165,3 +166,185 @@ test('sendAgentMessage validates category and bead id', async () => {
|
|||
assert.equal(missingBead.error?.code, 'MISSING_BEAD_ID');
|
||||
});
|
||||
});
|
||||
|
||||
async function seedRoleAgents(): Promise<void> {
|
||||
const now = '2026-02-14T00:00:00.000Z';
|
||||
await registerAgent({ name: 'ui-agent-1', role: 'ui' }, { now: () => now });
|
||||
await registerAgent({ name: 'ui-agent-2', role: 'ui' }, { now: () => now });
|
||||
await registerAgent({ name: 'graph-agent-1', role: 'graph' }, { now: () => now });
|
||||
}
|
||||
|
||||
test('sendAgentMessage routes to role:ui with multiple recipients', async () => {
|
||||
await withTempProject(async () => {
|
||||
await seedRoleAgents();
|
||||
|
||||
const sent = await sendAgentMessage(
|
||||
{
|
||||
from: 'graph-agent-1',
|
||||
to: 'role:ui',
|
||||
bead: 'bb-test.1',
|
||||
category: 'INFO',
|
||||
subject: 'Hello UI agents',
|
||||
body: 'Please check the dashboard',
|
||||
},
|
||||
{
|
||||
now: () => '2026-02-14T00:01:00.000Z',
|
||||
idGenerator: () => 'msg_role_test_1',
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(sent.ok, true);
|
||||
|
||||
const inbox1 = await inboxAgentMessages({ agent: 'ui-agent-1' });
|
||||
const inbox2 = await inboxAgentMessages({ agent: 'ui-agent-2' });
|
||||
const inboxGraph = await inboxAgentMessages({ agent: 'graph-agent-1' });
|
||||
|
||||
assert.equal(inbox1.data?.length, 1);
|
||||
assert.equal(inbox2.data?.length, 1);
|
||||
assert.equal(inboxGraph.data?.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
test('sendAgentMessage role fanout excludes sender from recipient list', async () => {
|
||||
await withTempProject(async () => {
|
||||
await seedRoleAgents();
|
||||
|
||||
const sent = await sendAgentMessage(
|
||||
{
|
||||
from: 'ui-agent-1',
|
||||
to: 'role:ui',
|
||||
bead: 'bb-test.2',
|
||||
category: 'INFO',
|
||||
subject: 'Peer message',
|
||||
body: 'Hello fellow UI agents',
|
||||
},
|
||||
{
|
||||
now: () => '2026-02-14T00:01:00.000Z',
|
||||
idGenerator: () => 'msg_role_test_2',
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(sent.ok, true);
|
||||
|
||||
const inbox1 = await inboxAgentMessages({ agent: 'ui-agent-1' });
|
||||
const inbox2 = await inboxAgentMessages({ agent: 'ui-agent-2' });
|
||||
|
||||
assert.equal(inbox1.data?.length, 0, 'sender should not receive');
|
||||
assert.equal(inbox2.data?.length, 1, 'other ui agent should receive');
|
||||
});
|
||||
});
|
||||
|
||||
test('sendAgentMessage direct send includes recipient even if sender matches recipient role', async () => {
|
||||
await withTempProject(async () => {
|
||||
await seedRoleAgents();
|
||||
|
||||
const sent = await sendAgentMessage(
|
||||
{
|
||||
from: 'ui-agent-1',
|
||||
to: 'ui-agent-2',
|
||||
bead: 'bb-test.3',
|
||||
category: 'INFO',
|
||||
subject: 'Direct message',
|
||||
body: 'Hello specifically',
|
||||
},
|
||||
{
|
||||
now: () => '2026-02-14T00:01:00.000Z',
|
||||
idGenerator: () => 'msg_role_test_3',
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(sent.ok, true);
|
||||
|
||||
const inbox2 = await inboxAgentMessages({ agent: 'ui-agent-2' });
|
||||
|
||||
assert.equal(inbox2.data?.length, 1, 'direct recipient should receive');
|
||||
});
|
||||
});
|
||||
|
||||
test('sendAgentMessage unknown role returns UNKNOWN_RECIPIENT', async () => {
|
||||
await withTempProject(async () => {
|
||||
await seedRoleAgents();
|
||||
|
||||
const sent = await sendAgentMessage({
|
||||
from: 'ui-agent-1',
|
||||
to: 'role:nonexistent',
|
||||
bead: 'bb-test.4',
|
||||
category: 'INFO',
|
||||
subject: 'Hello',
|
||||
body: 'Anyone there?',
|
||||
});
|
||||
|
||||
assert.equal(sent.ok, false);
|
||||
assert.equal(sent.error?.code, 'UNKNOWN_RECIPIENT');
|
||||
assert.ok(sent.error?.message.includes('no agents found with role'));
|
||||
});
|
||||
});
|
||||
|
||||
test('sendAgentMessage known role but all agents excluded returns UNKNOWN_RECIPIENT', async () => {
|
||||
await withTempProject(async () => {
|
||||
const now = '2026-02-14T00:00:00.000Z';
|
||||
await registerAgent({ name: 'only-ui-agent', role: 'ui' }, { now: () => now });
|
||||
|
||||
const sent = await sendAgentMessage({
|
||||
from: 'only-ui-agent',
|
||||
to: 'role:ui',
|
||||
bead: 'bb-test.5',
|
||||
category: 'INFO',
|
||||
subject: 'Hello myself',
|
||||
body: 'No one else to hear',
|
||||
});
|
||||
|
||||
assert.equal(sent.ok, false);
|
||||
assert.equal(sent.error?.code, 'UNKNOWN_RECIPIENT');
|
||||
assert.ok(sent.error?.message.includes('all recipients were excluded'));
|
||||
});
|
||||
});
|
||||
|
||||
test('sendAgentMessage role fanout HANDOFF creates individual messages with per-recipient ack', async () => {
|
||||
await withTempProject(async () => {
|
||||
await seedRoleAgents();
|
||||
|
||||
const sent = await sendAgentMessage(
|
||||
{
|
||||
from: 'graph-agent-1',
|
||||
to: 'role:ui',
|
||||
bead: 'bb-test.6',
|
||||
category: 'HANDOFF',
|
||||
subject: 'Take over',
|
||||
body: 'Please handle this',
|
||||
},
|
||||
{
|
||||
now: () => '2026-02-14T00:01:00.000Z',
|
||||
idGenerator: () => 'msg_handoff_test',
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(sent.ok, true);
|
||||
|
||||
const inbox1 = await inboxAgentMessages({ agent: 'ui-agent-1' });
|
||||
const inbox2 = await inboxAgentMessages({ agent: 'ui-agent-2' });
|
||||
|
||||
assert.equal(inbox1.data?.length, 1);
|
||||
assert.equal(inbox2.data?.length, 1);
|
||||
|
||||
const msg1 = inbox1.data![0];
|
||||
const msg2 = inbox2.data![0];
|
||||
|
||||
assert.notEqual(msg1.message_id, msg2.message_id, 'each recipient gets unique message ID');
|
||||
assert.equal(msg1.state, 'unread');
|
||||
assert.equal(msg2.state, 'unread');
|
||||
|
||||
const ack1 = await ackAgentMessage(
|
||||
{ agent: 'ui-agent-1', message: msg1.message_id },
|
||||
{ now: () => '2026-02-14T00:02:00.000Z' },
|
||||
);
|
||||
assert.equal(ack1.ok, true);
|
||||
assert.equal(ack1.data?.state, 'acked');
|
||||
|
||||
const inbox1AfterAck = await inboxAgentMessages({ agent: 'ui-agent-1', state: 'acked' });
|
||||
const inbox2AfterAck = await inboxAgentMessages({ agent: 'ui-agent-2', state: 'unread' });
|
||||
|
||||
assert.equal(inbox1AfterAck.data?.length, 1, 'ui-agent-1 message is acked');
|
||||
assert.equal(inbox2AfterAck.data?.length, 1, 'ui-agent-2 message still unread');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,35 +3,36 @@ 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';
|
||||
|
||||
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;
|
||||
async function withTempProject(run: (projectRoot: string) => Promise<void>): Promise<void> {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-agent-legacy-test-'));
|
||||
|
||||
// Initialize bd rig
|
||||
execSync('bd init --prefix bb --force', { cwd: tempDir, stdio: 'ignore' });
|
||||
|
||||
try {
|
||||
await run();
|
||||
await run(tempDir);
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env.USERPROFILE;
|
||||
} else {
|
||||
process.env.USERPROFILE = previous;
|
||||
// Windows cleanup retry
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test('registerAgent creates stable metadata file with idle status', async () => {
|
||||
await withTempUserProfile(async () => {
|
||||
await withTempProject(async (projectRoot) => {
|
||||
const now = '2026-02-13T23:55:00.000Z';
|
||||
const result = await registerAgent(
|
||||
{
|
||||
|
|
@ -39,29 +40,20 @@ test('registerAgent creates stable metadata file with idle status', async () =>
|
|||
display: 'UI Agent 1',
|
||||
role: 'ui',
|
||||
},
|
||||
{ now: () => now },
|
||||
{ now: () => now, projectRoot }
|
||||
);
|
||||
|
||||
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' });
|
||||
await withTempProject(async (projectRoot) => {
|
||||
await registerAgent({ name: 'agent-ui-1', role: 'ui' }, { projectRoot });
|
||||
|
||||
const duplicate = await registerAgent({ name: 'agent-ui-1', role: 'ui' }, { now: () => '2026-02-13T23:56:00.000Z' });
|
||||
const duplicate = await registerAgent({ name: 'agent-ui-1', role: 'ui' }, { projectRoot });
|
||||
|
||||
assert.equal(duplicate.ok, false);
|
||||
assert.equal(duplicate.error?.code, 'DUPLICATE_AGENT_ID');
|
||||
|
|
@ -69,71 +61,47 @@ test('registerAgent rejects duplicate id without --force-update', async () => {
|
|||
});
|
||||
|
||||
test('registerAgent force update mutates display/role but keeps created_at', async () => {
|
||||
await withTempUserProfile(async () => {
|
||||
await withTempProject(async (projectRoot) => {
|
||||
const t1 = '2026-02-13T23:55:00.000Z';
|
||||
const first = await registerAgent(
|
||||
{ name: 'agent-ui-1', display: 'UI Agent', role: 'ui' },
|
||||
{ now: () => '2026-02-13T23:55:00.000Z' },
|
||||
{ now: () => t1, projectRoot }
|
||||
);
|
||||
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' },
|
||||
{ projectRoot }
|
||||
);
|
||||
|
||||
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 withTempProject(async (projectRoot) => {
|
||||
await registerAgent({ name: 'agent-b', role: 'backend' }, { projectRoot });
|
||||
await registerAgent({ name: 'agent-a', role: 'ui' }, { projectRoot });
|
||||
|
||||
await registerAgent(
|
||||
{ name: 'agent-b', role: 'backend', forceUpdate: true },
|
||||
{ now: () => '2026-02-13T23:56:00.000Z' },
|
||||
);
|
||||
const originalCwd = process.cwd();
|
||||
process.chdir(projectRoot);
|
||||
try {
|
||||
const all = await listAgents({});
|
||||
assert.equal(all.ok, true);
|
||||
assert.deepEqual(
|
||||
all.data?.map((agent) => agent.agent_id),
|
||||
['agent-a', 'agent-b'],
|
||||
);
|
||||
|
||||
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');
|
||||
const byRole = await listAgents({ role: 'ui' });
|
||||
assert.deepEqual(
|
||||
byRole.data?.map((agent) => agent.agent_id),
|
||||
['agent-a'],
|
||||
);
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
81
tests/lib/agent-sessions-liveness.test.ts
Normal file
81
tests/lib/agent-sessions-liveness.test.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
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';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { registerAgent } from '../../src/lib/agent-registry';
|
||||
import { getAgentLivenessMap } from '../../src/lib/agent-sessions';
|
||||
import type { ActivityEvent } from '../../src/lib/activity';
|
||||
|
||||
async function withTempProject(run: (projectRoot: string) => Promise<void>): Promise<void> {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-liveness-test-'));
|
||||
execSync('bd init --prefix bb --force', { 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('getAgentLivenessMap prefers telemetry over bead metadata', async () => {
|
||||
await withTempProject(async (projectRoot) => {
|
||||
const agentId = 'telemetry-agent';
|
||||
const regResult = await registerAgent({ name: agentId, role: 'infra' }, { projectRoot });
|
||||
assert.equal(regResult.ok, true, `Failed to register agent: ${regResult.error?.message}`);
|
||||
|
||||
// Verify bead exists on disk
|
||||
const issues = execSync('bd list --label gt:agent --json', { cwd: projectRoot, encoding: 'utf8' });
|
||||
console.log('Registered agents:', issues);
|
||||
|
||||
// 1. No heartbeats in stream -> use metadata
|
||||
const livenessMap1 = await getAgentLivenessMap(projectRoot, []);
|
||||
console.log('Liveness Map 1:', livenessMap1);
|
||||
assert.equal(livenessMap1[agentId], 'active');
|
||||
|
||||
// 2. Add an old heartbeat to stream (2 hours ago)
|
||||
// Metadata is fresh (just registered), so it should fallback to metadata and stay active.
|
||||
const wayOldTime = new Date(Date.now() - 120 * 60 * 1000).toISOString();
|
||||
const wayOldHeartbeat: ActivityEvent = {
|
||||
id: randomUUID(),
|
||||
kind: 'heartbeat',
|
||||
beadId: `bb-${agentId}`,
|
||||
beadTitle: `Agent: ${agentId}`,
|
||||
projectId: projectRoot,
|
||||
projectName: 'test',
|
||||
timestamp: wayOldTime,
|
||||
actor: agentId,
|
||||
payload: { message: 'running' }
|
||||
};
|
||||
|
||||
const livenessMap2 = await getAgentLivenessMap(projectRoot, [wayOldHeartbeat]);
|
||||
assert.equal(livenessMap2[agentId], 'active', 'Should fallback to fresh metadata if telemetry is ancient');
|
||||
|
||||
// 3. Fresh heartbeat should stay active
|
||||
const nowTime = new Date().toISOString();
|
||||
const freshHeartbeat: ActivityEvent = {
|
||||
id: randomUUID(),
|
||||
kind: 'heartbeat',
|
||||
beadId: `bb-${agentId}`,
|
||||
beadTitle: `Agent: ${agentId}`,
|
||||
projectId: projectRoot,
|
||||
projectName: 'test',
|
||||
timestamp: nowTime,
|
||||
actor: agentId,
|
||||
payload: { message: 'running' }
|
||||
};
|
||||
|
||||
const livenessMap3 = await getAgentLivenessMap(projectRoot, [freshHeartbeat]);
|
||||
assert.equal(livenessMap3[agentId], 'active');
|
||||
});
|
||||
});
|
||||
64
tests/lib/agent-sessions-state.test.ts
Normal file
64
tests/lib/agent-sessions-state.test.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import type { BeadIssue } from '../../src/lib/types';
|
||||
|
||||
|
||||
/**
|
||||
* Tests for bb-buff.3.2: Critical Visual Signals
|
||||
*
|
||||
* These tests verify that stuck/dead ZFC states are properly
|
||||
* derived into session states for visual rendering.
|
||||
*/
|
||||
|
||||
// Import the deriveState function (will be exported from agent-sessions)
|
||||
import { deriveSessionState } from '../../src/lib/agent-sessions';
|
||||
|
||||
function makeTask(overrides: Partial<BeadIssue> = {}): BeadIssue {
|
||||
return {
|
||||
id: 'bb-1',
|
||||
title: 'Test Task',
|
||||
status: 'in_progress',
|
||||
updated_at: new Date().toISOString(),
|
||||
dependencies: [],
|
||||
labels: [],
|
||||
...overrides
|
||||
} as BeadIssue;
|
||||
}
|
||||
|
||||
test('deriveSessionState returns stuck when ZFC state is stuck', () => {
|
||||
const task = makeTask();
|
||||
const result = deriveSessionState(task, null, false, 'active', 'stuck');
|
||||
assert.equal(result, 'stuck');
|
||||
});
|
||||
|
||||
test('deriveSessionState returns dead when ZFC state is dead', () => {
|
||||
const task = makeTask();
|
||||
const result = deriveSessionState(task, null, false, 'active', 'dead');
|
||||
assert.equal(result, 'dead');
|
||||
});
|
||||
|
||||
test('deriveSessionState prioritizes stuck over evicted', () => {
|
||||
const task = makeTask();
|
||||
// Even if liveness is evicted, stuck should win
|
||||
const result = deriveSessionState(task, null, false, 'evicted', 'stuck');
|
||||
assert.equal(result, 'stuck');
|
||||
});
|
||||
|
||||
test('deriveSessionState prioritizes dead over stale', () => {
|
||||
const task = makeTask();
|
||||
const result = deriveSessionState(task, null, false, 'stale', 'dead');
|
||||
assert.equal(result, 'dead');
|
||||
});
|
||||
|
||||
test('deriveSessionState returns evicted when liveness is evicted and no ZFC state', () => {
|
||||
const task = makeTask();
|
||||
const result = deriveSessionState(task, null, false, 'evicted', undefined);
|
||||
assert.equal(result, 'evicted');
|
||||
});
|
||||
|
||||
test('deriveSessionState returns completed when task is closed', () => {
|
||||
const task = makeTask({ status: 'closed' });
|
||||
// Even with stuck ZFC state, closed task is completed
|
||||
const result = deriveSessionState(task, null, false, 'active', 'stuck');
|
||||
assert.equal(result, 'completed');
|
||||
});
|
||||
|
|
@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
|
|||
|
||||
import { runBdCommand } from '../../src/lib/bridge';
|
||||
|
||||
test('runBdCommand returns structured success payload from execFile output', async () => {
|
||||
test('runBdCommand returns structured success payload from exec output', async () => {
|
||||
const result = await runBdCommand(
|
||||
{
|
||||
projectRoot: 'C:/repo/project',
|
||||
|
|
@ -13,9 +13,10 @@ test('runBdCommand returns structured success payload from execFile output', asy
|
|||
},
|
||||
{
|
||||
resolveBdExecutable: async () => ({ executable: 'C:/tools/bd.exe', source: 'config' }),
|
||||
execFile: async (command, args, options) => {
|
||||
assert.equal(command, 'C:/tools/bd.exe');
|
||||
assert.deepEqual(args, ['list', '--json']);
|
||||
exec: async (command: string, options: any) => {
|
||||
assert.ok(command.includes('bd'));
|
||||
assert.ok(command.includes('list'));
|
||||
assert.ok(command.includes('--json'));
|
||||
assert.equal(options.cwd, 'C:/repo/project');
|
||||
return { stdout: '[{"id":"bb-1"}]\r\n', stderr: '' };
|
||||
},
|
||||
|
|
@ -32,7 +33,7 @@ test('runBdCommand classifies missing executable as not_found', async () => {
|
|||
{ projectRoot: 'C:/repo/project', args: ['list'] },
|
||||
{
|
||||
resolveBdExecutable: async () => ({ executable: 'C:/tools/bd.exe', source: 'config' }),
|
||||
execFile: async () => {
|
||||
exec: async () => {
|
||||
const error = new Error('spawn ENOENT') as NodeJS.ErrnoException;
|
||||
error.code = 'ENOENT';
|
||||
throw error;
|
||||
|
|
@ -49,7 +50,7 @@ test('runBdCommand classifies timeout failures', async () => {
|
|||
{ projectRoot: 'C:/repo/project', args: ['list'], timeoutMs: 5 },
|
||||
{
|
||||
resolveBdExecutable: async () => ({ executable: 'C:/tools/bd.exe', source: 'config' }),
|
||||
execFile: async () => {
|
||||
exec: async () => {
|
||||
const error = new Error('timed out') as NodeJS.ErrnoException & { killed?: boolean; signal?: string };
|
||||
error.code = 'ETIMEDOUT';
|
||||
error.killed = true;
|
||||
|
|
@ -68,7 +69,7 @@ test('runBdCommand classifies non-zero bad-argument exits', async () => {
|
|||
{ projectRoot: 'C:/repo/project', args: ['update', '--bad-flag'] },
|
||||
{
|
||||
resolveBdExecutable: async () => ({ executable: 'C:/tools/bd.exe', source: 'config' }),
|
||||
execFile: async () => {
|
||||
exec: async () => {
|
||||
const error = new Error('exit code 1') as NodeJS.ErrnoException & {
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
|
|
|
|||
|
|
@ -44,3 +44,14 @@ test('toSseFrame includes id, event name, and data payload', () => {
|
|||
assert.equal(frame.includes('event: issues'), true);
|
||||
assert.equal(frame.includes('"projectRoot":"C:\\\\Repo\\\\One"'), true);
|
||||
});
|
||||
|
||||
test('toSseFrame uses telemetry event name for telemetry kind', () => {
|
||||
const frame = toSseFrame({
|
||||
id: 42,
|
||||
projectRoot: 'C:/Repo',
|
||||
kind: 'telemetry',
|
||||
at: new Date().toISOString(),
|
||||
});
|
||||
assert.ok(frame.includes('event: telemetry'), 'Should use telemetry event name');
|
||||
assert.ok(frame.includes('id: 42'), 'Should preserve ID');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
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 os from 'node:os';
|
||||
import { execSync } from 'node:child_process';
|
||||
|
||||
import { IssuesEventBus, ActivityEventBus } from '../../src/lib/realtime';
|
||||
import { IssuesWatchManager } from '../../src/lib/watcher';
|
||||
|
|
@ -44,7 +45,7 @@ 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 () => {
|
||||
test('IssuesWatchManager emits telemetry event after beads.db change (not issues)', 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');
|
||||
|
|
@ -54,9 +55,9 @@ test('IssuesWatchManager emits event after beads.db change', async () => {
|
|||
const bus = new IssuesEventBus();
|
||||
const manager = new IssuesWatchManager({ eventBus: bus, debounceMs: 40 });
|
||||
|
||||
const events: string[] = [];
|
||||
const events: Array<{ kind: string; changedPath?: string }> = [];
|
||||
const stop = bus.subscribe((event) => {
|
||||
events.push(event.projectRoot);
|
||||
events.push({ kind: event.kind, changedPath: event.changedPath });
|
||||
});
|
||||
|
||||
await manager.startWatch(root);
|
||||
|
|
@ -67,7 +68,14 @@ test('IssuesWatchManager emits event after beads.db change', async () => {
|
|||
stop();
|
||||
await manager.stopAll();
|
||||
|
||||
assert.equal(events.length >= 1, true);
|
||||
// REGRESSION: beads.db should emit 'telemetry', not 'issues'
|
||||
// This prevents the "typing interrupt" refresh loop during agent heartbeats
|
||||
assert.equal(events.length >= 1, true, 'Expected at least one event');
|
||||
const dbEvents = events.filter(e => e.changedPath?.includes('beads.db'));
|
||||
assert.ok(dbEvents.length > 0, 'Expected beads.db change event');
|
||||
for (const event of dbEvents) {
|
||||
assert.equal(event.kind, 'telemetry', `beads.db change should emit 'telemetry', got '${event.kind}'. This prevents refresh loops during agent heartbeats.`);
|
||||
}
|
||||
});
|
||||
|
||||
test('IssuesWatchManager emits event after beads.db-wal change', async () => {
|
||||
|
|
@ -99,13 +107,15 @@ test('IssuesWatchManager emits event after beads.db-wal change', async () => {
|
|||
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');
|
||||
// Initialize bd in temp dir
|
||||
execSync('bd init --prefix bb --force', { cwd: root, stdio: 'ignore' });
|
||||
|
||||
// Initial state: 1 issue via bd
|
||||
execSync('bd create "Task A" --id bb-1', { cwd: root, stdio: 'ignore' });
|
||||
execSync('bd update bb-1 --status open', { cwd: root, stdio: 'ignore' });
|
||||
|
||||
const issuesBus = new IssuesEventBus();
|
||||
const activityBus = new ActivityEventBus();
|
||||
|
|
@ -126,16 +136,25 @@ test('IssuesWatchManager emits ActivityEvent on issue change', async () => {
|
|||
// 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');
|
||||
// Modify issue via bd: status change
|
||||
execSync('bd update bb-1 --status in_progress', { cwd: root, stdio: 'ignore' });
|
||||
|
||||
// Wait for debounce + processing
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
// Wait for debounce + processing with retry loop
|
||||
let found = false;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
if (activities.includes('status_changed:bb-1')) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
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(', ')}`);
|
||||
if (!found) {
|
||||
console.error('WATCHER FAIL. Activities found:', JSON.stringify(activities, null, 2));
|
||||
}
|
||||
assert.ok(found, `Expected status_changed event. Got: ${activities.join(', ')}`);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,23 +5,30 @@ import fs from 'node:fs/promises';
|
|||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
|
||||
import { registerAgent } from '../../src/lib/agent-registry';
|
||||
|
||||
const projectRoot = path.resolve(__dirname, '../../');
|
||||
const initScript = path.join(projectRoot, 'scripts', 'bb-init.mjs');
|
||||
|
||||
async function withTempRegistry(run: (tempDir: string) => Promise<void>): Promise<void> {
|
||||
const previous = process.env.USERPROFILE;
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'bb-init-lease-'));
|
||||
process.env.USERPROFILE = tempDir;
|
||||
|
||||
// Initialize a fake git repo
|
||||
|
||||
// Initialize a fake git repo first
|
||||
execSync('git init', { cwd: tempDir, stdio: 'ignore' });
|
||||
await fs.writeFile(path.join(tempDir, 'dummy'), 'data');
|
||||
execSync('git add dummy && git commit -m "initial"', { cwd: tempDir, stdio: 'ignore' });
|
||||
|
||||
// Initialize bd rig with explicit prefix
|
||||
execSync('bd init --prefix bb- --force', { cwd: tempDir, stdio: 'ignore' });
|
||||
execSync('bd migrate --update-repo-id', { cwd: tempDir, stdio: 'ignore' });
|
||||
|
||||
// Create a dummy issue to force a flush
|
||||
execSync('bd create --title "Warmup" --id bb-warmup', { cwd: tempDir, stdio: 'ignore' });
|
||||
execSync('bd admin flush', { cwd: tempDir, stdio: 'ignore' });
|
||||
|
||||
try {
|
||||
await run(tempDir);
|
||||
} finally {
|
||||
process.env.USERPROFILE = previous;
|
||||
// Cleanup with retries for Windows
|
||||
for (let i = 0; i < 5; i++) {
|
||||
try {
|
||||
|
|
@ -34,50 +41,51 @@ async function withTempRegistry(run: (tempDir: string) => Promise<void>): Promis
|
|||
}
|
||||
}
|
||||
|
||||
test('LEASE: bb-init --register updates liveness and starts lease', async () => {
|
||||
await withTempRegistry(async (tempDir) => {
|
||||
const agentId = 'lease-agent';
|
||||
const cmd = `node ${initScript} --register ${agentId} --role backend --json`;
|
||||
test('REGISTRY: registerAgent includes rig fingerprint', async () => {
|
||||
await withTempRegistry(async (projectRoot) => {
|
||||
const agentId = 'direct-agent';
|
||||
const rigId = 'test-rig-123';
|
||||
|
||||
const out = execSync(cmd, {
|
||||
cwd: tempDir,
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, BB_REPO: projectRoot }
|
||||
});
|
||||
const result = JSON.parse(out);
|
||||
const result = await registerAgent({
|
||||
name: agentId,
|
||||
role: 'tester',
|
||||
rig: rigId
|
||||
}, { projectRoot });
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.lease.status, 'active');
|
||||
assert.equal(result.ok, true, `registerAgent failed: ${result.error?.message}`);
|
||||
assert.equal(result.data?.rig, rigId);
|
||||
|
||||
// Verify Registry Entry exists and has a timestamp
|
||||
const agentFile = path.join(tempDir, '.beadboard', 'agent', 'agents', `${agentId}.json`);
|
||||
const agentData = JSON.parse(await fs.readFile(agentFile, 'utf8'));
|
||||
assert.equal(agentData.agent_id, agentId);
|
||||
assert.ok(agentData.last_seen_at);
|
||||
// Verify persistence via bd list
|
||||
const listOut = execSync('bd list --all --json', { cwd: projectRoot, encoding: 'utf8' });
|
||||
const agents = JSON.parse(listOut);
|
||||
const agentData = agents.find((a: { id: string }) => a.id.includes(agentId));
|
||||
|
||||
assert.ok(agentData, `Agent ${agentId} should exist in list`);
|
||||
const rigLabel = agentData.labels?.find((l: string) => l.startsWith('rig:'));
|
||||
assert.ok(rigLabel, `Rig fingerprint should be present in labels`);
|
||||
assert.equal(rigLabel, `rig:${rigId}`);
|
||||
});
|
||||
});
|
||||
|
||||
test('LEASE: activity-lease command works via CLI', async () => {
|
||||
test('FINGERPRINT: bb-init --register includes rig fingerprint', async () => {
|
||||
await withTempRegistry(async (tempDir) => {
|
||||
const agentId = 'cli-agent';
|
||||
// Register
|
||||
execSync(`node ${initScript} --register ${agentId} --role test --json`, {
|
||||
cwd: tempDir,
|
||||
env: { ...process.env, BB_REPO: projectRoot }
|
||||
const agentId = 'fingerprint-agent';
|
||||
const cmd = `node ${initScript} --register ${agentId} --role test --project-root ${tempDir} --json`;
|
||||
|
||||
execSync(cmd, {
|
||||
cwd: tempDir,
|
||||
env: { ...process.env, BB_REPO: projectRoot, BD_NO_DAEMON: 'false' }
|
||||
});
|
||||
|
||||
const agentFile = path.join(tempDir, '.beadboard', 'agent', 'agents', `${agentId}.json`);
|
||||
const firstSeen = JSON.parse(await fs.readFile(agentFile, 'utf8')).last_seen_at;
|
||||
|
||||
// Extend lease
|
||||
await new Promise(r => setTimeout(r, 100)); // Ensure clock tick
|
||||
const bbPath = path.join(projectRoot, 'tools', 'bb.ts');
|
||||
execSync(`npx tsx ${bbPath} agent activity-lease --agent ${agentId} --json`, {
|
||||
cwd: tempDir,
|
||||
env: { ...process.env, BB_REPO: projectRoot }
|
||||
});
|
||||
|
||||
const secondSeen = JSON.parse(await fs.readFile(agentFile, 'utf8')).last_seen_at;
|
||||
assert.notEqual(firstSeen, secondSeen, 'Lease extension should update last_seen_at');
|
||||
// Verify Registry Entry exists via bd list
|
||||
const listOut = execSync('bd list --all --json', { cwd: tempDir, encoding: 'utf8' });
|
||||
const agents = JSON.parse(listOut);
|
||||
const agentData = agents.find((a: { id: string }) => a.id.includes(agentId));
|
||||
|
||||
// Check for fingerprint fields
|
||||
assert.ok(agentData, `Agent ${agentId} should exist in list`);
|
||||
const rigLabel = agentData.labels?.find((l: string) => l.startsWith('rig:'));
|
||||
assert.ok(rigLabel, 'Rig fingerprint should be present in labels');
|
||||
const rigValue = rigLabel?.split(':')[1];
|
||||
assert.ok(rigValue?.includes(os.platform()), `Rig ${rigValue} should include platform ${os.platform()}`);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue