From 240ac47fa536cd2ac2892ab4a036f8e6aeadb1ef Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 16 Sep 2026 15:46:03 -0400 Subject: [PATCH] fix(beehive): retain registered agents in snapshots Signed-off-by: Logan Johnson --- beehive/src/manager-controller.ts | 20 +++++++++++++++----- beehive/test/manager-controller.test.ts | 21 +++++++++++++++++++++ beehive/test/registration-start.test.ts | 4 ++++ 3 files changed, 40 insertions(+), 5 deletions(-) diff --git a/beehive/src/manager-controller.ts b/beehive/src/manager-controller.ts index 8190e1db8a..657ca14e86 100644 --- a/beehive/src/manager-controller.ts +++ b/beehive/src/manager-controller.ts @@ -200,6 +200,8 @@ export class ManagerController { local.push({ id: 'host', label: identity.pairing.label, detail: 'Host configured. Agent registration is not execution authority.' }); } else local.push({ id: 'missing', label: 'Configure this computer', detail: 'No local host configuration. Save the owner’s public key and relay URL. Beehive creates a host identity in the secure credential store. No owner sign-in is needed. This does not create an agent or start a host.' }); } catch (error) { local.push({ id: 'error', label: 'Saved configuration needs attention', detail: (error instanceof Error ? backendMessages[error.message] ?? `Could not read saved configuration: ${error.message}` : `Could not read saved configuration: ${String(error)}`) + '\nSaved data has not been reset.' }); } + let settings: Settings | undefined, hostRelay: string | undefined; + try { settings = readSettings(this.hostDirectory); if (existsSync(join(this.hostDirectory,'host-identity.json'))) hostRelay = readHostIdentityPublic(this.hostDirectory).pairing.relay; } catch { /* Existing error row remains actionable; never reset settings. */ } const reports: ManagerSnapshot['agents'] = [...this.inventory].map(([id, m]) => { const fresh = this.fresh(m); const blocked = !fresh ? 'The host report is old or the host cannot be reached. Wait for a recent report before you act.' : this.client?.status().some(o => o.request.host === m.host && o.request.agent === m.agent && !['completed','failed'].includes(o.state)) ? 'An operation has no confirmed result. Inspect operations before you act.' : ''; @@ -218,11 +220,21 @@ Configuration for next start ${this.configurationDescription(m.body.selectedNext)} This choice does not change the current run.` }; }); - const agents: ManagerSnapshot['agents'] = this.directory.map(agent => { + // Authenticated local registrations remain list members even when discovery is + // stale or unavailable. Directory rows enrich them, but never seed custody. + const directory = new Map(this.directory.map(agent => [agent.publicKey, agent])); + for (const registered of settings?.agents ?? []) if (!directory.has(registered.publicKey)) { + directory.set(registered.publicKey, { + publicKey: registered.publicKey, + name: registered.profile?.name ?? registered.publicKey, + status: 'unknown', + channels: [], + }); + } + const agents: ManagerSnapshot['agents'] = [...directory.values()].map(agent => { const report = this.agentReport(agent.publicKey); const shown = report ? reports.find(row => row.id === JSON.stringify([report.host,report.agent])) : undefined; - let registered = false; - try { registered = readSettings(this.hostDirectory).agents.some(a => a.publicKey === agent.publicKey); } catch { /* Configuration error is shown in Local Host. */ } + const registered = settings?.agents.some(a => a.publicKey === agent.publicKey) ?? false; const local = this.localEnrollmentTarget(agent.publicKey); const unavailable = 'No unique assigned host report. Execution authority is unknown.'; return { configuration: report ? this.directConfiguration(report.body.selectedNext) : undefined, names: { agent: agent.name && agent.name !== agent.publicKey ? agent.name : short(agent.publicKey), host: report ? String((this.offers.get(report.host)?.body.configuration as {label?: string})?.label || short(report.host)) : undefined, startHost: local ? readHostIdentityPublic(this.hostDirectory).pairing.label : undefined }, startTarget: local, startRevision: local ? -1 : undefined, destinations: report ? this.moveDestinations(report) : [], id:agent.publicKey,target:shown?.id,label:`${agent.name} · ${shown ? this.fresh(report!) ? report!.body.phase : 'Unknown' : 'Unknown'}`,revision:shown?.revision ?? -1,configurations:shown?.configurations ?? [],disabled: { ...(shown?.disabled ?? { start:unavailable,stop:unavailable,restart:unavailable,move:unavailable,'select-config':unavailable }), ...(local ? {start:''} : {}) },evidence:JSON.stringify({ discovery:agent,report },null,2),detail:`${agent.name} @@ -252,8 +264,6 @@ If the result is unknown, check operation results before you submit again. A Stop result is not a recent host report that confirms the agent is stopped.` }); let routing: ReturnType; try { routing = readControllerConfig(this.ownerDirectory); } catch { /* Invalid retained routing is refused by sign-in; never reset here. */ } - let settings: Settings | undefined, hostRelay: string | undefined; - try { settings = readSettings(this.hostDirectory); if (existsSync(join(this.hostDirectory,'host-identity.json'))) hostRelay = readHostIdentityPublic(this.hostDirectory).pairing.relay; } catch { /* Existing error row remains actionable; never reset settings. */ } const footerRelay = hostRelay ?? routing?.relay; this.observeRelayName(footerRelay); return { managementRelay:this.owner ? this.client?.connected ? 'connected' : 'disconnected' : undefined, settings, service: this.service, hostRelay, relayName: this.relayName && this.relayName.relay === footerRelay ? this.relayName.name : undefined, profilePreview: this.profilePreview, models: this.models, modelLabels:this.modelLabels, runtimeExecutable: this.runtimeExecutable, harnesses: this.harnesses, databricksHost: this.databricksHost, local, agents, diagnostics, routing: routing ? { owner: routing.owner, relay: routing.relay } : undefined, owner: this.owner, status: this.status + (this.owner ? `\n${this.directoryState}` : '') }; diff --git a/beehive/test/manager-controller.test.ts b/beehive/test/manager-controller.test.ts index 033c16d48d..111b5d59b2 100644 --- a/beehive/test/manager-controller.test.ts +++ b/beehive/test/manager-controller.test.ts @@ -8,6 +8,7 @@ import { bootstrapHostIdentity } from '../src/host-identity.ts'; import { createCredential, credentialReference, readCredential, type CredentialBackend } from '../src/credential-store.ts'; import { publicKey, message, type Message } from '../src/protocol.ts'; import { profileDrafts } from '../src/profile-drafts.ts'; +import { saveSettings } from '../src/settings.ts'; const secret = '1'.repeat(64), owner = publicKey(secret); function fixture() { @@ -33,6 +34,26 @@ function fixture() { return { controller, home, submitted, backend, get closed() { return closed; }, get snapshot() { return snapshot!; }, receive: (m: Message) => receive(m), states: (s: any[]) => { states = s; }, cleanup() { controller.close(); rmSync(home,{ recursive: true, force: true }); } }; } +test('saved registration seeds snapshots when directory omits it, survives reopen, and never duplicates discovery', () => { + const home = mkdtempSync(join(tmpdir(), 'beehive-manager-')); + const directory = join(home, '.beehive', 'host'); + const agent = 'a'.repeat(64); + saveSettings(directory, { version: 1, revision: 0, providers: [], runtimes: [], agents: [{ publicKey: agent, key: credentialReference('agent', agent), profileState: 'found', profile: { relay: 'wss://fixture.invalid', name: 'Retained agent' } }] }, 0); + const empty = new ManagerController(home, () => {}, undefined, undefined, undefined, async () => []); + try { + assert.deepEqual(empty.snapshot().agents.map(row => [row.id, row.names?.agent]), [[agent, 'Retained agent']]); + empty.close(); + const reopened = new ManagerController(home, () => {}, undefined, undefined, undefined, async () => { throw Error('relay unavailable'); }); + try { assert.deepEqual(reopened.snapshot().agents.map(row => row.id), [agent]); } + finally { reopened.close(); } + const discovered = new ManagerController(home, () => {}, undefined, undefined, undefined, async () => [{ publicKey: agent, name: 'Directory name', status: 'offline', channels: [] }]); + try { + (discovered as any).directory = [{ publicKey: agent, name: 'Directory name', status: 'offline', channels: [] }]; + assert.deepEqual(discovered.snapshot().agents.map(row => [row.id, row.names?.agent]), [[agent, 'Directory name']]); + } finally { discovered.close(); } + } finally { empty.close(); rmSync(home, { recursive: true, force: true }); } +}); + test('manager config save is real/keyless owner routing; retained configuration refuses reset; offline multiline draft persists', async () => { const f = fixture(); try { diff --git a/beehive/test/registration-start.test.ts b/beehive/test/registration-start.test.ts index 2855c03e85..5507b1e62d 100644 --- a/beehive/test/registration-start.test.ts +++ b/beehive/test/registration-start.test.ts @@ -8,6 +8,7 @@ import { nsecEncode } from 'nostr-tools/nip19'; import { registrationFixture } from './registration-fixture.ts'; import { readSettings } from '../src/settings.ts'; import type { ManagerSnapshot } from '../src/manager-controller.ts'; +import { sectionRows } from '../src/manager-navigation.ts'; async function until(fn:()=>boolean) { for(let n=0;n<400;n++) { if(fn()) return; await delay(20); } throw Error('Synthetic workflow evidence missing'); } async function fixture(t:test.TestContext) { const home = realpathSync(mkdtempSync(join(tmpdir(),'beehive-pairing-cli-registration-'))); @@ -255,6 +256,9 @@ test('Other-agent registration requires that exact identity even without a Start assert.equal(readSettings(f.directory).agents.length, 0); assert.equal((await f.request('register-agent', { secret: f.nsec, agent: f.agent, runtime: f.runtimeId })).state, 'completed'); assert.equal(readSettings(f.directory).agents[0]?.publicKey, f.agent); + const rows = sectionRows(f.snapshot, 1); + assert.equal(rows.filter(row => row.id === f.agent).length, 1, 'registered identity has one row'); + assert.ok(rows.indexOf(rows.find(row => row.id === f.agent)!) < rows.findIndex(row => row.id === 'other-agents'), 'successful registration moves the row immediately without Start'); await f.request('operations'); assert.match(f.snapshot.status, /No saved operations/); });