fix(beehive): replace routine status actions with automatic checks

Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
Logan Johnson
2026-09-22 10:41:59 -04:00
parent 1d3782a9c7
commit beb8952841
7 changed files with 140 additions and 63 deletions

View File

@@ -31,7 +31,8 @@ export class HostPanel {
get modal() { return !!this.confirmation; }
private commands() {
const host = this.snapshot.host;
return [...(host?.state === 'running' ? ['Stop Host'] : host?.state === 'stopped' ? [...(host.resetPending ? [] : ['Start Host']), 'Reset Host'] : []), 'Refresh status'];
if (!host || host.state === 'unknown') return this.snapshot.hostPhase === 'error' || host?.state === 'unknown' ? ['Retry'] : [];
return [...(host.state === 'running' ? ['Stop Host'] : [...(host.resetPending ? [] : ['Start Host']), 'Reset Host']), ...(this.snapshot.hostPhase === 'error' ? ['Retry'] : [])];
}
private refresh() { if (this.shown && !this.confirmation && this.snapshot.hostPhase !== 'busy' && this.snapshot.hostPhase !== 'error') void this.client.request({ action: 'host-status' }); }
key(key: KeyEvent) {
@@ -50,8 +51,8 @@ export class HostPanel {
private async run() {
if (!this.shown || this.confirmation || this.snapshot.hostPhase === 'busy') return;
const command = this.commands()[this.index], host = this.snapshot.host, generation = this.generation;
if (command === 'Refresh status') { await this.client.request({ action: 'host-status' }); return; }
if (!host) return;
if (command === 'Retry') { await this.client.request({ action: 'host-status' }); return; }
if (!host || !command) return;
if (command === 'Stop Host' || command === 'Reset Host') {
const dialog = new HostConfirm(this.renderer, command === 'Reset Host'); this.confirmation = dialog;
const confirmed = await dialog.done; if (this.confirmation === dialog) this.confirmation = undefined;
@@ -74,7 +75,7 @@ export class HostPanel {
this.facts.top = 1; this.facts.left = 2 + labelWidth; this.facts.width = width - labelWidth; this.facts.height = 5;
this.facts.content = host ? [host.resetPending ? 'RESET INCOMPLETE' : host.state.toUpperCase(), host.name, host.owner, host.relay, host.agents === undefined ? 'Unknown' : String(host.agents)].map(truncate).join('\n') : `${this.snapshot.hostPhase === 'error' ? 'UNKNOWN' : 'Checking…'}\n—\n—\n—\nUnknown`;
this.heading.top = 7; this.heading.height = 1; this.heading.width = width; this.heading.content = 'AVAILABLE ACTIONS ' + '─'.repeat(Math.max(1, width - 18));
const commands = this.commands(); this.index = Math.max(0, Math.min(this.index, commands.length - 1));
const commands = this.commands(); this.heading.visible = commands.length > 0; this.index = Math.max(0, Math.min(this.index, commands.length - 1));
this.actions.forEach((action, i) => { action.top = this.actionTop + i; action.width = width; action.height = 1; action.visible = i < commands.length; action.content = t`${fg(palette.focus)(this.state.focus === 'detail' && this.index === i ? '›' : ' ')} ${commands[i] ?? ''}`; action.fg = commands[i] === 'Reset Host' ? palette.failure : this.state.focus === 'detail' && this.index === i ? palette.focus : palette.text; });
this.message.top = this.actionTop + commands.length; this.message.width = width; this.message.height = Math.max(1, height - Number(this.message.top) - 1);
this.message.content = this.snapshot.hostMessage || 'Host runs independently of the manager.'; this.message.fg = this.snapshot.hostPhase === 'error' ? palette.failure : palette.muted;

View File

@@ -25,6 +25,7 @@ export class OwnerPanel {
private shown = false;
private generation = 0;
private wasOwner = false;
private timer?: ReturnType<typeof setInterval>;
constructor(private renderer: CliRenderer, pane: BoxRenderable, private state: ShellState, private client: OwnerClient, private repaint: () => void) {
this.snapshot = client.snapshot();
this.box = new BoxRenderable(renderer, { width: '100%', height: '100%', backgroundColor: palette.surface }); pane.add(this.box);
@@ -41,7 +42,7 @@ export class OwnerPanel {
if (!this.state.ownerActive) return this.snapshot.signedIn ? [] : ['Sign in'];
if (this.snapshot.signedIn) return ['Sign out'];
if (!this.snapshot.relay && !this.relay) return ['Choose a relay', 'Use Buzz Desktop identity', 'Provide owner nsec'];
return ['Use Buzz Desktop identity', 'Provide owner nsec', ...(this.snapshot.relay ? [] : ['Change relay']), 'Check Desktop identity'];
return ['Use Buzz Desktop identity', 'Provide owner nsec', ...(this.snapshot.relay ? [] : ['Change relay'])];
}
private disabled(command: string) { return command === 'Use Buzz Desktop identity' && (this.snapshot.desktop !== 'available' || !(this.snapshot.relay || this.relay)) || command === 'Provide owner nsec' && !(this.snapshot.relay || this.relay); }
private selectionNote(command: string) {
@@ -93,7 +94,6 @@ export class OwnerPanel {
const generation = this.generation;
if (command === 'Sign in') { this.openOwner(); return; }
if (command === 'Sign out') { this.requestedSection = undefined; this.prior = undefined; this.index = 0; await this.client.request({ action: 'signout' }); this.repaint(); return; }
if (command === 'Check Desktop identity') { await this.client.request({ action: 'probe' }); return; }
if (command.includes('relay')) {
const dialog = new ProviderDialog(this.renderer, 'CHOOSE A RELAY', [{ label: 'Relay URL', value: this.relay || 'wss://' }], () => {}, 'Use relay');
this.dialog = dialog; const result = await dialog.done; if (this.dialog === dialog) this.dialog = undefined;
@@ -114,19 +114,23 @@ export class OwnerPanel {
}
this.repaint();
}
private probe() {
if (this.shown && this.state.ownerActive && !this.snapshot.signedIn && !this.dialog && !this.state.helpOpen && this.snapshot.phase !== 'busy') void this.client.request({ action: 'probe' });
}
paint() {
const owner = this.state.ownerActive;
const shown = !this.state.belowMinimum && (owner || this.state.protectedSection && !(this.state.activeSection === 0 && this.snapshot.signedIn));
const left = (this.wasOwner && !owner) || (this.shown && !shown);
const entered = owner && !this.wasOwner;
this.wasOwner = owner; this.shown = shown; this.box.visible = shown;
if (left) {
this.generation++; this.dialog?.cancel(); this.dialog = undefined;
if (this.snapshot.phase === 'busy') this.client.cancel();
this.requestedSection = undefined; this.prior = undefined; this.index = 0;
}
const detecting = shown && owner && !this.snapshot.signedIn;
if (!detecting && this.timer) { clearInterval(this.timer); this.timer = undefined; }
if (detecting && !this.timer) { this.timer = setInterval(() => this.probe(), 3000); this.probe(); }
if (!shown) return;
if (entered && !this.snapshot.signedIn && this.snapshot.desktop === 'unknown') void this.client.request({ action: 'probe' });
const width = this.renderer.width - 4, height = this.renderer.height - 5;
const wrap = (value: string) => { const rows: string[] = []; for (const line of value.split('\n')) { let rest = line; while (rest.length > width) { const at = rest.lastIndexOf(' ', width); const end = at > 0 ? at : width; rows.push(rest.slice(0, end)); rest = rest.slice(end).trimStart(); } rows.push(rest); } return rows; };
const truncate = (value: string) => { const limit = width - (owner && this.requestedSection !== undefined ? 19 : 8); return value.length > limit ? value.slice(0, Math.ceil((limit - 1) / 2)) + '…' + value.slice(-Math.floor((limit - 1) / 2)) : value; };
@@ -154,5 +158,5 @@ export class OwnerPanel {
this.note.content = wrap(this.selectionNote(commands[this.index] ?? '')).join('\n');
this.dialog?.paint();
}
dispose() { this.generation++; this.dialog?.cancel(); this.unsubscribe(); this.client.dispose(); }
dispose() { if (this.timer) clearInterval(this.timer); this.generation++; this.dialog?.cancel(); this.unsubscribe(); this.client.dispose(); }
}

View File

@@ -14,7 +14,7 @@ import type { OwnerClient, OwnerRequest, OwnerSnapshot } from './owner-protocol.
export type HostDependencies = { credential: typeof managerCredential; status: typeof serviceStatus; start: typeof startService; stop: typeof stopService; reset: typeof resetHost };
const hostDependencies: HostDependencies = { credential: managerCredential, status: serviceStatus, start: startService, stop: stopService, reset: resetHost };
const missing = 'Desktop identity unavailable. Open and unlock Buzz, then check again or provide an owner nsec.';
const missing = 'Desktop identity unavailable. Open and unlock Buzz, or provide an owner nsec. Availability updates automatically.';
/** Offline Node authority. Session keys have no persistence, snapshot, or relay path. */
export class OwnerService implements OwnerClient {
private current: OwnerSnapshot = { signedIn: false, desktop: 'unknown', desktopReason: 'Identity availability has not been checked.', phase: 'idle', message: 'Sign in to manage Host and Agents.', secretLength: 0 };
@@ -22,6 +22,7 @@ export class OwnerService implements OwnerClient {
private input = '';
private session?: string;
private active?: AbortController;
private probing?: AbortController;
private generation = 0;
private hostOperation = false;
private disposed = false;
@@ -49,62 +50,74 @@ export class OwnerService implements OwnerClient {
else if (action === 'append') this.input = (this.input + value.replace(/[\x00-\x1f\x7f]/g, '')).slice(0, 256);
this.current.secretLength = this.input.length; this.publish();
}
/** Availability checks never own input or replace the last operation result. */
private async probe() {
if (this.probing || this.active || this.current.signedIn) return false;
const abort = new AbortController(); this.probing = abort;
try {
const result = await this.desktop(true, abort.signal);
if (abort.signal.aborted || this.disposed || this.probing !== abort) return false;
this.current.desktop = result.available ? 'available' : 'unavailable';
this.current.desktopReason = result.available ? 'Desktop entry detected. Sign-in checks identity and access.' : missing;
return true;
} catch {
if (!abort.signal.aborted && !this.disposed && this.probing === abort) {
this.current.desktop = 'unavailable'; this.current.desktopReason = missing;
}
return false;
} finally { if (this.probing === abort) this.probing = undefined; this.publish(); }
}
async request(request: OwnerRequest) {
if (this.disposed) return false;
if (request.action === 'probe') return this.probe();
if (request.action.startsWith('host-')) return this.hostRequest(request);
if (request.action === 'signout') {
this.cancel(); this.session = undefined; this.current.signedIn = false; this.current.owner = undefined; this.current.host = undefined; this.current.hostMessage = undefined; this.current.hostPhase = 'idle';
this.current.message = 'Signed out. Host and agents keep running.'; this.publish(); return true;
}
if (this.active || this.current.signedIn) return false;
this.probing?.abort(); this.probing = undefined;
const abort = new AbortController(), generation = ++this.generation;
this.active = abort; this.current.phase = 'busy'; this.current.message = request.action === 'probe' ? 'Checking Desktop identity…' : 'Signing in… Esc cancels.'; this.publish();
this.active = abort; this.current.phase = 'busy'; this.current.message = 'Signing in… Esc cancels.'; this.publish();
let error = 'Sign-in failed. No session was opened. Check the retained owner configuration and try again.';
const check = () => { abort.signal.throwIfAborted(); if (generation !== this.generation || this.disposed) throw Error('Cancelled'); };
try {
if (request.action === 'probe') {
const result = await this.desktop(true, abort.signal); check();
this.current.desktop = result.available ? 'available' : 'unavailable';
this.current.desktopReason = result.available ? 'Desktop entry detected. Sign-in checks identity and access.' : missing;
this.current.message = result.available ? 'Buzz Desktop identity is available.' : missing;
} else {
const before = this.binding();
const relay = before?.relay ?? request.relay?.trim();
error = 'Enter a relay URL using wss:// (or ws://localhost for local testing). Nothing was changed.';
if (!relay) throw Error();
const url = new URL(relay);
if (url.username || url.password || url.hash || !(url.protocol === 'wss:' || (url.protocol === 'ws:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)))) throw Error();
let secret: string;
if (request.action === 'signin-desktop') {
error = 'Desktop key access failed or was cancelled. Unlock Keychain or provide owner nsec. No session opened.';
const result = await this.desktop(false, abort.signal); check();
if (!result.secret) throw Error();
secret = result.secret;
} else if (request.action === 'signin-nsec') {
error = 'Enter a valid owner nsec. No session was opened and nothing was saved.';
secret = agentNsec(this.input);
} else throw Error('Unsupported action');
const owner = publicKey(secret); check();
error = 'Identity does not match this owner. Use the matching key. Owner and relay were not changed.';
const now = this.binding();
if (before && !now || now && (now.owner !== owner || now.relay !== relay)) throw Error();
error = 'Owner routing changed or was not saved. Reopen sign-in and check configuration. No session opened.';
if (!now) createControllerConfig(join(this.home, '.beehive', 'owner'), owner, relay);
// Read back the sole public authority after first-use activation.
const saved = this.binding();
if (saved?.owner !== owner || saved.relay !== relay) throw Error();
const before = this.binding();
const relay = before?.relay ?? request.relay?.trim();
error = 'Enter a relay URL using wss:// (or ws://localhost for local testing). Nothing was changed.';
if (!relay) throw Error();
const url = new URL(relay);
if (url.username || url.password || url.hash || !(url.protocol === 'wss:' || (url.protocol === 'ws:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)))) throw Error();
let secret: string;
if (request.action === 'signin-desktop') {
error = 'Desktop key access failed or was cancelled. Unlock Keychain or provide owner nsec. No session opened.';
const result = await this.desktop(false, abort.signal); check();
if (!result.secret) throw Error();
secret = result.secret;
} else if (request.action === 'signin-nsec') {
error = 'Enter a valid owner nsec. No session was opened and nothing was saved.';
secret = agentNsec(this.input);
} else throw Error('Unsupported action');
const owner = publicKey(secret); check();
error = 'Identity does not match this owner. Use the matching key. Owner and relay were not changed.';
const now = this.binding();
if (before && !now || now && (now.owner !== owner || now.relay !== relay)) throw Error();
error = 'Owner routing changed or was not saved. Reopen sign-in and check configuration. No session opened.';
if (!now) createControllerConfig(join(this.home, '.beehive', 'owner'), owner, relay);
// Read back the sole public authority after first-use activation.
const saved = this.binding();
if (saved?.owner !== owner || saved.relay !== relay) throw Error();
check();
const directory = join(this.home, '.beehive', 'host');
error = 'Host identity creation failed. Binding may be saved. Retry sign-in with the same owner and relay; no Host was started.';
if (!existsSync(join(directory, 'host-identity.json')) && !pendingHostReset(directory)) {
await this.hosts.credential({ action: 'configure', directory, ownerDirectory: join(this.home, '.beehive', 'owner'), label: hostname().slice(0, 128), owner, relay }, abort.signal);
check();
const directory = join(this.home, '.beehive', 'host');
error = 'Host identity creation failed. Binding may be saved. Retry sign-in with the same owner and relay; no Host was started.';
if (!existsSync(join(directory, 'host-identity.json')) && !pendingHostReset(directory)) {
await this.hosts.credential({ action: 'configure', directory, ownerDirectory: join(this.home, '.beehive', 'owner'), label: hostname().slice(0, 128), owner, relay }, abort.signal);
check();
}
const finalBinding = this.binding();
if (finalBinding?.owner !== owner || finalBinding.relay !== relay) throw Error('Binding changed');
check(); this.session = secret; this.current.signedIn = true; this.current.owner = nip19.npubEncode(owner); this.projectBinding();
this.current.message = pendingHostReset(directory) ? 'Signed in. Reset incomplete; open Host to finish Reset Host.' : 'Signed in. Host is configured. No service or agent was started.';
}
const finalBinding = this.binding();
if (finalBinding?.owner !== owner || finalBinding.relay !== relay) throw Error('Binding changed');
check(); this.session = secret; this.current.signedIn = true; this.current.owner = nip19.npubEncode(owner); this.projectBinding();
this.current.message = pendingHostReset(directory) ? 'Signed in. Reset incomplete; open Host to finish Reset Host.' : 'Signed in. Host is configured. No service or agent was started.';
this.current.phase = 'idle'; return true;
} catch {
if (generation === this.generation && !this.disposed) { this.current.phase = 'error'; this.current.message = error; }
@@ -158,18 +171,18 @@ export class OwnerService implements OwnerClient {
const final = await project();
if (request.action === 'host-start' && final.state !== 'running' || request.action === 'host-stop' && final.state !== 'stopped') throw Error('Unconfirmed result');
this.current.hostPhase = 'idle';
this.current.hostMessage = pendingHostReset(directory) ? 'Reset incomplete. Retry Reset Host to finish.' : final.state === 'unknown' ? 'Status unknown. Check the local service, then Refresh status. Start and Reset are blocked.' : request.action === 'host-start' ? 'Host started. No agent was started.' : request.action === 'host-stop' ? 'Host and its agents stopped.' : '';
this.current.hostMessage = pendingHostReset(directory) ? 'Reset incomplete. Retry Reset Host to finish.' : final.state === 'unknown' ? 'Status unknown. Check the local service, then Retry. Start and Reset are blocked.' : request.action === 'host-start' ? 'Host started. No agent was started.' : request.action === 'host-stop' ? 'Host and its agents stopped.' : '';
return true;
} catch {
if (generation === this.generation && !this.disposed && this.current.signedIn) {
try { await project(); } catch { if (generation === this.generation) this.current.host = undefined; }
if (generation !== this.generation || this.disposed || !this.current.signedIn) return false;
this.current.hostPhase = 'error';
this.current.hostMessage = pendingHostReset(directory) ? 'Reset incomplete. Keep local files. Retry Reset Host.' : 'Operation unconfirmed. Refresh status before retrying.';
this.current.hostMessage = pendingHostReset(directory) ? 'Reset incomplete. Keep local files. Retry Reset Host.' : 'Operation unconfirmed. Use Retry to check status before trying again.';
}
return false;
} finally { if (this.active === abort) this.active = undefined; this.hostOperation = false; this.publish(); }
}
cancel() { this.generation++; this.active?.abort(); if (!this.hostOperation) this.active = undefined; this.input = ''; this.current.secretLength = 0; this.current.phase = 'idle'; if (this.current.hostPhase === 'busy') { this.current.hostPhase = 'idle'; this.current.hostMessage = 'Stopped waiting. Changes may already be saved. Refresh status before retrying.'; } this.current.message = 'Stopped waiting. Configuration may already be saved; sign in again with the same owner and relay.'; this.publish(); }
cancel() { this.probing?.abort(); this.probing = undefined; this.generation++; this.active?.abort(); if (!this.hostOperation) this.active = undefined; this.input = ''; this.current.secretLength = 0; this.current.phase = 'idle'; if (this.current.hostPhase === 'busy') { this.current.hostPhase = 'idle'; this.current.hostMessage = 'Stopped waiting. Changes may already be saved. Status will update automatically.'; } this.current.message = 'Stopped waiting. Configuration may already be saved; sign in again with the same owner and relay.'; this.publish(); }
dispose() { this.cancel(); this.session = undefined; this.current.owner = undefined; this.current.signedIn = false; this.current.host = undefined; this.disposed = true; this.listeners.clear(); }
}

View File

@@ -114,3 +114,34 @@ test('cancelled read cannot block or overwrite a replacement session', async ()
assert.equal(f.service.snapshot().signedIn, true);
} finally { f.close(); }
});
test('passive Desktop checks preserve input and sign-in errors and fail closed', async () => {
let fail = false;
const f = fixture(async () => { if (fail) throw Error('private probe error'); return { available: true }; });
try {
assert.equal(await f.service.request({ action: 'signin-nsec', relay }), false);
const error = f.service.snapshot().message;
f.service.secret('append', nsec);
for (fail of [false, true]) {
assert.equal(await f.service.request({ action: 'probe' }), !fail);
assert.equal(f.service.snapshot().desktop, fail ? 'unavailable' : 'available');
assert.equal(f.service.snapshot().message, error);
assert.equal(f.service.snapshot().phase, 'error');
assert.equal(f.service.snapshot().secretLength, nsec.length);
}
assert.equal(await f.service.request({ action: 'signin-nsec', relay }), true);
} finally { f.close(); }
});
test('sign-in supersedes a pending passive probe without losing input or accepting its late result', async () => {
let finish!: (value: { available: boolean }) => void;
const f = fixture(async () => new Promise(resolve => { finish = resolve; }));
try {
const pending = f.service.request({ action: 'probe' });
assert.equal(await f.service.request({ action: 'probe' }), false);
f.service.secret('append', nsec);
assert.equal(await f.service.request({ action: 'signin-nsec', relay }), true);
finish({ available: true });assert.equal(await pending, false);
assert.equal(f.service.snapshot().signedIn, true);
assert.equal(f.service.snapshot().desktop, 'unknown');
} finally { f.close(); }
});

View File

@@ -7,7 +7,7 @@ import { HarnessInventoryController } from '../src/harness-inventory.ts';
import { localOwner } from '../src/owner-client.ts';
import { serviceStatus, stopService } from '../src/host-service.ts';
for (const [width, height] of [[120,40],[60,20]]) test(`Host real IPC and detached lifecycle, confirmation, reset recovery at ${width}x${height}`, async () => {
for (const [width, height] of [[120,40],[60,20]]) test(`Host real IPC and detached lifecycle, confirmation, reset recovery at ${width}x${height}`, { timeout: 15000 }, async () => {
const home = mkdtempSync('/tmp/owner-tui-fixture-host-'), directory = home+'/.beehive/host';
writeFileSync(home+'/desktop-mode','available'); writeFileSync(home+'/host-service-mode','real');
const client=localOwner({helper:new URL('./owner-tui-fixture-child.ts',import.meta.url),environment:{HOME:home,BEEHIVE_HOME:home,PATH:process.env.PATH}});
@@ -21,16 +21,16 @@ for (const [width, height] of [[120,40],[60,20]]) test(`Host real IPC and detach
await wait(()=>!client.snapshot().message.includes('Loading'));
assert.equal(await client.request({action:'signin-desktop',relay:'wss://fixture.example'}),true);
await wait(()=>client.snapshot().host?.state==='stopped' && client.snapshot().hostPhase==='idle');
assert.equal(shell.state.splitPane,false);assert.match(ui.captureCharFrame(),/Agents running\s+0/);
assert.doesNotMatch(ui.captureCharFrame(),/Refresh status|Retry/);assert.equal(shell.state.splitPane,false);assert.match(ui.captureCharFrame(),/Agents running\s+0/);
await click('Reset Host');assert.match(ui.captureCharFrame(),/RESET HOST/);await esc();assert.ok(existsSync(directory+'/host-identity.json'));
await click('Start Host');await wait(()=>client.snapshot().host?.state==='running' && client.snapshot().hostPhase==='idle');
assert.match(ui.captureCharFrame(),/Stop Host/);assert.doesNotMatch(ui.captureCharFrame(),/Reset Host/);assert.equal(client.snapshot().host!.agents,0);
assert.doesNotMatch(ui.captureCharFrame(),/Refresh status|Retry/);assert.match(ui.captureCharFrame(),/Stop Host/);assert.doesNotMatch(ui.captureCharFrame(),/Reset Host/);assert.equal(client.snapshot().host!.agents,0);
await click('Stop Host');assert.match(ui.captureCharFrame(),/every agent/);await click('Keep running');await ui.renderOnce();assert.equal((await serviceStatus(directory)).state,'running');
await client.request({action:'signout'});await ui.renderOnce();assert.equal((await serviceStatus(directory)).state,'running');assert.doesNotMatch(ui.captureCharFrame(),/Stop Host/);
assert.equal(await client.request({action:'signin-desktop'}),true);await wait(()=>client.snapshot().host?.state==='running' && client.snapshot().hostPhase==='idle');
await click('Stop Host');ui.mockInput.pressEnter();await wait(()=>client.snapshot().host?.state==='stopped' && client.snapshot().hostPhase==='idle');
mkdirSync(directory+'/host.lock');await client.request({action:'host-status'});await ui.renderOnce();assert.match(ui.captureCharFrame(),/UNKNOWN/);assert.doesNotMatch(ui.captureCharFrame(),/Start Host|Reset Host/);
rmSync(directory+'/host.lock',{recursive:true});await click('Refresh status');await wait(()=>client.snapshot().host?.state==='stopped' && client.snapshot().hostPhase==='idle');
mkdirSync(directory+'/host.lock');await wait(()=>client.snapshot().host?.state==='unknown' && client.snapshot().hostPhase==='idle');assert.match(ui.captureCharFrame(),/UNKNOWN/);assert.doesNotMatch(ui.captureCharFrame(),/Start Host|Reset Host/);
rmSync(directory+'/host.lock',{recursive:true});await click('Retry');await wait(()=>client.snapshot().host?.state==='stopped' && client.snapshot().hostPhase==='idle');
writeFileSync(home+'/reset-denied','true');await click('Reset Host');await click('Reset Host');await wait(()=>client.snapshot().hostPhase==='error');assert.match(ui.captureCharFrame(),/Reset incomplete/);assert.doesNotMatch(ui.captureCharFrame(),/Start Host/);
rmSync(home+'/reset-denied');await click('Reset Host');ui.mockInput.pressEnter();await wait(()=>!client.snapshot().signedIn);assert.match(ui.captureCharFrame(),/Sign in to view/);assert.equal(client.snapshot().relay,undefined);assert.equal(existsSync(directory+'/host-identity.json'),false);
} finally {

View File

@@ -52,3 +52,31 @@ for (const [width, height] of [[120,40],[60,20]]) test(`real owner IPC: form fai
assert.match(ui.captureCharFrame(),/Start Host/); assert.doesNotMatch(ui.captureCharFrame(),/Continue to|Stay here/);
} finally {shell.close();rmSync(home,{recursive:true,force:true});}
});
for (const [width, height] of [[120,40],[60,20]]) test(`Desktop availability updates automatically in the open sign-in screen at ${width}x${height}`, { timeout: 15000 }, async () => {
const home=mkdtempSync('/tmp/owner-tui-fixture-detect-');
const mode=(value:string)=>writeFileSync(home+'/desktop-mode',value);
mode('missing');
const client=localOwner({helper:new URL('./owner-tui-fixture-child.ts',import.meta.url),environment:{HOME:home,BEEHIVE_HOME:home,PATH:process.env.PATH}});
const ui=await createTestRenderer({width,height,exitOnCtrlC:false});
const inventory=new HarnessInventoryController({read:()=>({version:1,revision:0,agents:[],providers:[],runtimes:[]}),save:c=>c},async()=>[]);
const shell=new OpenTuiShell(ui.renderer,inventory,undefined,undefined,client);
const wait=async(fn:()=>boolean)=>{for(let i=0;i<450;i++){if(fn()){await ui.renderOnce();return;}await new Promise(r=>setTimeout(r,10));}assert.fail(JSON.stringify(client.snapshot()));};
try {
await wait(()=>!client.snapshot().message.includes('Loading'));
for(let i=0;i<4;i++)ui.mockInput.pressArrow('right');
await wait(()=>client.snapshot().desktop==='unavailable');
assert.doesNotMatch(ui.captureCharFrame(),/Check Desktop identity/);
// Choose a relay, then Enter on unavailable Desktop must not sign in.
ui.mockInput.pressEnter();ui.mockInput.pressEnter();await ui.renderOnce();
ui.mockInput.typeText('fixture.example');ui.mockInput.pressTab();ui.mockInput.pressEnter();
await new Promise(r=>setTimeout(r,0));ui.mockInput.pressEnter();
assert.equal(client.snapshot().signedIn,false);
mode('available');await wait(()=>client.snapshot().desktop==='available');
mode('missing');await wait(()=>client.snapshot().desktop==='unavailable');
ui.mockInput.pressEnter();assert.equal(client.snapshot().signedIn,false);
mode('available');await wait(()=>client.snapshot().desktop==='available');
ui.mockInput.pressEnter();await wait(()=>client.snapshot().signedIn && client.snapshot().host?.state==='stopped');
assert.match(ui.captureCharFrame(),/Start Host/);assert.doesNotMatch(ui.captureCharFrame(),/Refresh status|Check Desktop identity/);
} finally {shell.close();rmSync(home,{recursive:true,force:true});}
});

View File

@@ -15,7 +15,7 @@ function fixture() {
const emit = () => { for (const fn of listeners) fn(structuredClone(snapshot)); };
const client: OwnerClient = {
snapshot: () => structuredClone(snapshot), subscribe(fn) { listeners.add(fn); fn(structuredClone(snapshot)); return () => { listeners.delete(fn); }; },
async request(r) { requests.push(r); if (r.action === 'host-status') { snapshot.host = { name: 'Fixture Host', host: 'a'.repeat(64), owner: 'b'.repeat(64), relay: snapshot.relay!, state: 'stopped', agents: 0, revision: 'fixture', resetPending: false }; } else if (r.action.startsWith('signin')) { snapshot.signedIn = true; snapshot.owner = 'npub1fixture'; snapshot.secretLength = 0; snapshot.message = 'Signed in. No operation was started.'; } else if (r.action === 'signout') { snapshot.signedIn = false; snapshot.owner = undefined; snapshot.message = 'Signed out. Host and agents keep running.'; } emit(); return true; },
async request(r) { if (r.action === 'probe') return true; requests.push(r); if (r.action === 'host-status') { snapshot.host = { name: 'Fixture Host', host: 'a'.repeat(64), owner: 'b'.repeat(64), relay: snapshot.relay!, state: 'stopped', agents: 0, revision: 'fixture', resetPending: false }; } else if (r.action.startsWith('signin')) { snapshot.signedIn = true; snapshot.owner = 'npub1fixture'; snapshot.secretLength = 0; snapshot.message = 'Signed in. No operation was started.'; } else if (r.action === 'signout') { snapshot.signedIn = false; snapshot.owner = undefined; snapshot.message = 'Signed out. Host and agents keep running.'; } emit(); return true; },
secret(action, value) { snapshot.secretLength = action === 'clear' ? 0 : action === 'backspace' ? Math.max(0, snapshot.secretLength - 1) : snapshot.secretLength + (value?.length ?? 0); emit(); },
cancel() {}, dispose() {},
};
@@ -52,7 +52,7 @@ for (const [width,height] of [[120,40],[60,20]]) test(`owner unavailable identit
try {
for(let i=0;i<4;i++) ui.mockInput.pressArrow('right');
ui.mockInput.pressEnter(); ui.mockInput.pressEnter(); await render(ui);
assert.equal(f.requests.length,0); assert.match(ui.captureCharFrame(),/No usable Buzz/);
assert.doesNotMatch(ui.captureCharFrame(),/Check Desktop identity/);assert.equal(f.requests.length,0); assert.match(ui.captureCharFrame(),/No usable Buzz/);
const lines=ui.captureCharFrame().split('\n'),y=lines.findIndex(l=>l.includes('Provide owner nsec'));
await ui.mockMouse.click(lines[y]!.indexOf('Provide owner nsec'),y);await render(ui);
assert.match(ui.captureCharFrame(),/PROVIDE OWNER NSEC/); assert.match(ui.captureCharFrame(),/Enter owner nsec/);
@@ -85,7 +85,7 @@ for (const [width,height] of [[120,40],[60,20]]) for (const target of [0,1]) tes
for (const cancel of ['escape', 'navigate']) test(`late successful sign-in does not redirect after ${cancel}`, async () => {
const ui=await createTestRenderer({width:60,height:20,exitOnCtrlC:false}),f=fixture();
let finish!: (ok:boolean)=>void;
f.client.request=async r=>{ f.requests.push(r); f.snapshot.phase='busy'; f.emit(); return new Promise<boolean>(resolve=>{finish=resolve;}); };
f.client.request=async r=>{ if(r.action==='probe') return true; f.requests.push(r); f.snapshot.phase='busy'; f.emit(); return new Promise<boolean>(resolve=>{finish=resolve;}); };
const shell=new OpenTuiShell(ui.renderer,f.inventory,undefined,undefined,f.client);
try {
ui.mockInput.pressEnter();ui.mockInput.pressEnter();ui.mockInput.pressEnter();await render(ui);