mirror of
https://github.com/block/buzz.git
synced 2026-10-29 21:41:33 +00:00
feat(beehive): separate manager sections and enable guarded restart
Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
159
beehive/UX_NAVIGATION_CHECKPOINT.md
Normal file
159
beehive/UX_NAVIGATION_CHECKPOINT.md
Normal file
@@ -0,0 +1,159 @@
|
||||
# Revised UX checkpoint — incomplete
|
||||
|
||||
This is a local implementation checkpoint for delegation 6d8d3485, **not the
|
||||
requested end-to-end candidate**. Base: `176497874fb450818fddcad6b04ce0e619183f81`.
|
||||
No push, export, installation or native rebuild was performed.
|
||||
|
||||
## Implemented
|
||||
|
||||
- Four top-level sections: Host, Agents, Harnesses, Providers. Keyboard arrows
|
||||
cycle sections; header pointer targets use the same order, including 40-column
|
||||
layout. Each section retains its exact selection; a removed selection is not
|
||||
silently replaced by another target.
|
||||
- Host displays host name, exact relay URL, state and saved/loaded revisions.
|
||||
Its single Start/Stop control follows the verified service state; unknown
|
||||
ownership still refuses Start. Existing awaited Stop is unchanged.
|
||||
- Harnesses displays discovered adapters and saved runtime details. Opening the
|
||||
section invokes existing bounded discovery. Refresh and Add runtime are here.
|
||||
- Providers independently displays saved providers, Add provider and Models for
|
||||
the selected provider. Details omit OS credential references.
|
||||
- Agents exposes the existing signed management Restart operation, replacing the
|
||||
legacy blanket refusal. Exact selection/revision/freshness, assigned host,
|
||||
stopped/running phase and unresolved-operation gates precede submission.
|
||||
Host Restart still owns preflight, awaited teardown, replay and cancellation.
|
||||
|
||||
## Source/operation map
|
||||
|
||||
`manager-navigation.ts` owns section order and selection retention;
|
||||
`manager-view.ts` chooses section controls and captures selected requests;
|
||||
`opentui-screen.ts` owns keyboard/pointer presentation. The Node
|
||||
`manager-controller.ts` remains the sole UI management-request boundary.
|
||||
`intents.ts`, `host.ts`, service custody/ownership and all management-wire limits
|
||||
are unchanged. Saving provider/runtime configuration still does not alter an
|
||||
active run. Existing Pi and Codex runtime paths were not changed.
|
||||
|
||||
Current checkout Desktop sources inspected:
|
||||
- `desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs`: relay-self
|
||||
membership authority, paginated kind 39002 membership and owner kind 30177
|
||||
coordinates, exact-agent profiles and policy verification. **Not integrated
|
||||
into this checkpoint.** A broad kind-0 scan is not an equivalent directory.
|
||||
- `desktop/src-tauri/src/managed_agents/discovery/{catalog,runtime_metadata}.rs`
|
||||
and `desktop/src/features/agents/lib/agentConfigCore.ts`: capability-driven
|
||||
harness/provider/model/effort fields and dependent-value policies.
|
||||
- Existing Beehive `harness-discovery.ts`, `harness-contract.json`,
|
||||
`runtime-form.ts`, `settings-credentials.ts`, `settings-runtime.ts` and native
|
||||
helper contract remain in force. The packaged discovery contract is still
|
||||
pinned to historical `78618804...`; no new current-Desktop parity claim.
|
||||
No Desktop contributor rules changed; no Desktop files were edited.
|
||||
|
||||
## Required work still missing
|
||||
|
||||
1. **Global relay agent list**: Agents still uses private host reports, including
|
||||
host/operation rows. Next: add a bounded, cancellable authenticated directory
|
||||
adapter using the current Desktop consumer and explicit fixtures, then project
|
||||
exact-key directory rows independently from management authority.
|
||||
2. **Registration-first continuation**: Register is still identity-only. Runtime
|
||||
choice, environment variables and automatic continuation are absent. Next:
|
||||
introduce a typed operation/result state machine (the current renderer request
|
||||
returns `void`, including failures), bind the exact nsec public key and saved
|
||||
runtime atomically, then continue only after verified success and fresh
|
||||
authority. Cancellation must discard continuation without claiming OS rollback.
|
||||
Secret possession must never synthesize assignment/genesis authority.
|
||||
3. **Move in this manager**: absent. Next: expose the existing signed Move
|
||||
preparation/grant flow, capturing source and destination revisions; registration
|
||||
of a local destination must finish before source ownership is consumed. Reuse
|
||||
the real `move.test.ts`/named-Move fixtures for partial, stopped and unknown
|
||||
results rather than chaining optimistic Stop and Start.
|
||||
4. **Current Desktop discovery/provider parity**: existing provider setup and
|
||||
runtime configuration are reused, not refreshed end to end. Next: compare the
|
||||
packaged contract and provider/auth/model consumers with current Desktop;
|
||||
preserve capability-specific Pi/Codex behavior and OS custody.
|
||||
5. **Concise UI copy**: old prose, draft/publication actions and identity-only
|
||||
registration copy remain. Next: remove stale actions/copy with the completed
|
||||
agent workflow. Host name and relay are displayed, not newly editable.
|
||||
|
||||
No unresolved product-authority question was identified; these are unfinished
|
||||
implementation tasks, not external blockers. Do not schedule delivery acceptance
|
||||
against this checkpoint as if they were complete.
|
||||
|
||||
## Validation
|
||||
|
||||
Artifact directory:
|
||||
`/Users/loganj/.buzz/artifacts/beehive-ux-6d8d3485/`
|
||||
|
||||
- Node 24.15.0 TypeScript check: passed (`typecheck-final.log`).
|
||||
- Focused manager/navigation + real Restart integration: **9 passed**
|
||||
(`focused.log`). The final full run also covers added stale/standby Restart
|
||||
manager guards.
|
||||
- Bun 1.4.2 renderer: **4 passed** (`renderer-final.log`): four-way keyboard and
|
||||
pointer navigation, narrow header, selected details, disabled controls,
|
||||
hidden entry, cancellation and editor ownership.
|
||||
- One final default-concurrent package suite: **202 passed, 4 failed, 19 skipped**,
|
||||
225 tests, 32.344 seconds (`full-suite.log`). **Not green.** Failures:
|
||||
- `acp.test.ts`: ACP operation timed out (additional to the brief's historical
|
||||
three failures; cause not established).
|
||||
- `admission-cancel.test.ts`: wrong-authority control elapsed 2787 > 2500ms;
|
||||
log retains not-authority and accepted Start/Stop receipts.
|
||||
- `broker.test.ts`: `ok` stimulus conversation response timeout.
|
||||
- `disconnect.test.ts`: missing resistant readiness, null trace.
|
||||
No timeout, concurrency, assertion or production containment relaxation, and no
|
||||
full-suite rerun. Similar historical symptoms are not a waiver or diagnosis.
|
||||
- Native unchanged: reuse supplied historical 700 pass / 0 fail / 1 ignored
|
||||
evidence; not a new native run. No repository-wide CI claim.
|
||||
- Real isolated 100x30 PTY walkthrough passed (`pty-final.log`, `walkthrough.json`):
|
||||
all sections; cancelled registration and runtime form; provider cancellation
|
||||
preserves settings byte-for-byte; provider Save creates a second synthetic
|
||||
provider; selected-provider details and model listing; hidden keys absent;
|
||||
clean exit. External credential/relay/provider/browser/harness/service actions
|
||||
were explicitly blocked. This is not registration continuation or Move proof.
|
||||
|
||||
Failed evidence is retained: `renderer.log` and `renderer-diagnostic.log` used
|
||||
`pressKey('right')`, which sends literal text; corrected to the mock's
|
||||
`pressArrow('right')`. `pty-first/` and `pty.log` retain the first failed save
|
||||
probe: the driver assumed Home selects the first action; it does not. Corrected
|
||||
only the driver to use bounded Up arrows. `full-suite.log` retains all failures.
|
||||
|
||||
## Reusable reviewer walkthrough
|
||||
|
||||
From this checkout, in a real terminal at least 100x30:
|
||||
|
||||
```sh
|
||||
sh beehive/tools/ux-walkthrough.sh
|
||||
```
|
||||
|
||||
The script creates a fresh `beehive-pairing-cli-ux-` HOME, prints its path,
|
||||
sets `BEEHIVE_TEST_CREDENTIAL_FILE` and `NODE_OPTIONS --import` to the extended
|
||||
installed loader, seeds only synthetic host/provider data and starts the actual
|
||||
Node controller/Bun renderer. It does not invoke the installed Beehive launcher.
|
||||
Use only synthetic values. Every invocation creates a new fixture directory.
|
||||
|
||||
1. Inspect Host name/relay/state and Start control. Real service operations are
|
||||
blocked by the fixture; this is intentionally not a host lifecycle demo.
|
||||
2. Right to Agents; `a`, Up to Register, Enter; Escape cancels hidden input.
|
||||
3. Right to Harnesses; inspect Buzz Agent and Refresh. Add runtime traverses
|
||||
Harness → Provider → Model → Runtime name. Escape cancels.
|
||||
4. Right to Providers; inspect Review OpenAI. `a` selects controls; Up/Down and
|
||||
Enter open Add provider or Models. Save a synthetic OpenAI key with `yes`.
|
||||
5. Tab to list; Up/Down changes selected-provider details. Left/Right restores
|
||||
each section's prior selection. `q` quits outside forms; Ctrl-Q always quits.
|
||||
|
||||
For another machine, set `BEEHIVE_REVIEW_NODE` and `BEEHIVE_REVIEW_BUN` to direct
|
||||
Node 24.15.0 and Bun 1.4.2 executables. The defaults point at existing local
|
||||
runtime artifacts, not downloaded or rebuilt binaries.
|
||||
|
||||
Evidence replay: `cat <artifact-dir>/06-providers.ansi` in a 100x30 terminal.
|
||||
`01-host.txt` through `10-provider-selected.txt` are plain frame reconstructions
|
||||
from the real PTY bytes (not screenshots); corresponding `.ansi` streams are the
|
||||
raw evidence. `pty-walkthrough.py` is the saved automation, `frames.py` the decoder.
|
||||
|
||||
Exact validation commands (Node/Bun variables are the script's defaults):
|
||||
|
||||
```sh
|
||||
NODE=/Users/loganj/Library/Caches/hermit/pkg/node-24.15.0/bin/node
|
||||
BUN=/Users/loganj/.buzz/artifacts/beehive-opentui-57f9c8fa/runtime/bun-darwin-aarch64/bun
|
||||
cd beehive
|
||||
"$NODE" node_modules/typescript/bin/tsc --noEmit
|
||||
"$NODE" --test test/manager-controller.test.ts test/manager-navigation.test.ts test/restart.test.ts
|
||||
"$BUN" test ./test/opentui-screen.bun.ts
|
||||
"$NODE" --test test/*.test.ts
|
||||
```
|
||||
@@ -166,7 +166,7 @@ export class ManagerController {
|
||||
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.' : '';
|
||||
return { id, label: `Agent ${short(m.agent)} · ${fresh ? m.body.phase : 'Unknown'}`, revision: m.revision, configurations: Object.keys((m.body.configurations ?? {}) as object),
|
||||
disabled: { 'select-config': blocked, stop: blocked, start: blocked || (m.body.phase !== 'stopped' || m.body.actualRun || m.body.assignedHost !== m.host ? 'Start requires a recent report from the assigned host. It must report the agent stopped with no current run.' : '') },
|
||||
disabled: { 'select-config': blocked, stop: blocked, restart: blocked || (m.body.assignedHost !== m.host || !['stopped', 'running'].includes(String(m.body.phase)) ? 'Restart requires a recent report from the assigned host.' : ''), start: blocked || (m.body.phase !== 'stopped' || m.body.actualRun || m.body.assignedHost !== m.host ? 'Start requires a recent report from the assigned host. It must report the agent stopped with no current run.' : '') },
|
||||
evidence: JSON.stringify(m, null, 2), detail: `Agent ${short(m.agent)}
|
||||
Host ${short(m.host)}
|
||||
${fresh ? 'Recent host report' : 'Current status unknown. The report is old or the host cannot be reached. Last report:'}
|
||||
@@ -329,10 +329,10 @@ A Stop result is not a recent host report that confirms the agent is stopped.` }
|
||||
if (!this.owner || !this.client) throw plain('Owner sign-in required');
|
||||
const current = this.inventory.get(request.target ?? '');
|
||||
if (!current || !this.fresh(current) || current.revision !== request.revision) throw plain('The selection changed, or the host report is old or unavailable. Select an agent with a recent report. No request was sent.');
|
||||
if (request.action === 'restart') throw plain('Restart can change the local setup. Use Stop. Inspect a recent host report that confirms the agent is stopped. Then use Start. No Restart request is sent. No request was sent.');
|
||||
if (request.action === 'restart' && (current.body.assignedHost !== current.host || !['stopped', 'running'].includes(String(current.body.phase)))) throw plain('Restart requires a recent report from the assigned host.');
|
||||
if (request.action === 'start' && (current.body.phase !== 'stopped' || current.body.actualRun || current.body.assignedHost !== current.host)) throw plain('Start requires a recent report from the assigned host. It must report the agent stopped with no current run.');
|
||||
if (this.client.status().some(o => o.request.host === current.host && o.request.agent === current.agent && !['completed','failed'].includes(o.state))) throw plain('An operation has no confirmed result. Inspect operations and check operation results before you act again.');
|
||||
const operation = message(request.action === 'select-config' ? 'save' : request.action as 'start' | 'stop', current.host, current.agent, current.revision, request.action === 'select-config' ? { configurationAction: 'select', name: v.name } : {});
|
||||
const operation = message(request.action === 'select-config' ? 'save' : request.action as 'start' | 'stop' | 'restart', current.host, current.agent, current.revision, request.action === 'select-config' ? { configurationAction: 'select', name: v.name } : {});
|
||||
this.client.submit(operation);
|
||||
this.status = `Operation ${operation.id}: ${plainOperationType(operation)} saved and pending.\nHost: ${current.host}\nAgent: ${current.agent}\nRelay publication does not confirm host acceptance. Inspect operations. Unknown does not mean stopped.`;
|
||||
} else if (request.action !== 'refresh') throw plain('Not available in this build');
|
||||
|
||||
29
beehive/src/manager-navigation.ts
Normal file
29
beehive/src/manager-navigation.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { ManagerSnapshot, ManagerItem } from './manager-controller.ts';
|
||||
|
||||
/** Section order is shared by pointer hit testing and keyboard navigation. */
|
||||
export const managerSections = ['Host', 'Agents', 'Harnesses', 'Providers'] as const;
|
||||
|
||||
/** Public presentation only. A catalog item never becomes management authority. */
|
||||
export function sectionRows(snapshot: ManagerSnapshot, section: number): ManagerItem[] {
|
||||
const settings = snapshot.settings;
|
||||
if (section === 0) return snapshot.local.map(row => row.id !== 'host' ? row : ({ ...row, detail: `Host name: ${row.label}\nRelay URL: ${snapshot.hostRelay ?? snapshot.routing?.relay ?? '—'}\nState: ${snapshot.service?.state ?? 'unknown'}\nSaved revision: ${settings?.revision ?? 0}\nLoaded revision: ${snapshot.service?.revision ?? '—'}` }));
|
||||
if (section === 1) return snapshot.agents;
|
||||
if (section === 2) return [
|
||||
...(snapshot.harnesses ?? []).map(h => ({ id: `harness:${h.id}`, label: `${h.label} · ${h.state}`, detail: `Harness: ${h.label}\nState: ${h.state}\nExecutable: ${h.executable ?? '—'}\nCLI: ${h.cli ?? '—'}` })),
|
||||
...(settings?.runtimes ?? []).map(r => ({ id: `runtime:${r.id}`, label: r.name, detail: `Runtime: ${r.name}\nHarness: ${r.harness}\nProvider: ${settings?.providers.find(p => p.id === r.providerId)?.name ?? '—'}\nModel: ${r.model}\nEffort: ${r.effort ?? 'Inherit'}` })),
|
||||
];
|
||||
if (section === 3) return (settings?.providers ?? []).map(p => ({ id: p.id, label: p.name, detail: `Provider: ${p.name}\nType: ${p.type}\nEndpoint: ${p.endpoint}` }));
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Exact per-section selection retention; removal never selects a replacement. */
|
||||
export class ManagerNavigation {
|
||||
section = 0;
|
||||
private selected = new Map<number, string>();
|
||||
select(id: string) { this.selected.set(this.section, id); }
|
||||
switch(section: number) { if (Number.isInteger(section) && section >= 0 && section < managerSections.length) this.section = section; }
|
||||
selection(rows: ManagerItem[]) {
|
||||
if (!this.selected.has(this.section) && rows.length) this.select(rows[0]!.id);
|
||||
return this.selected.get(this.section) ?? '';
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ManagerNavigation, sectionRows } from './manager-navigation.ts';
|
||||
import { runtimeForm } from './runtime-form.ts';
|
||||
import { createCliRenderer } from '@opentui/core';
|
||||
import { createReadStream, createWriteStream } from 'node:fs';
|
||||
@@ -9,6 +10,7 @@ const renderer = await createCliRenderer({ exitOnCtrlC: false });
|
||||
const screen = new OpenTuiScreen(renderer);
|
||||
const output = createWriteStream('', { fd: 4 });
|
||||
let snapshot: ManagerSnapshot = { local: [], agents: [], status: 'Connecting to Beehive…' };
|
||||
const navigation = new ManagerNavigation();
|
||||
let scope = 0, selected = '', sequence = 0, lastStatus = '';
|
||||
let pending: { id: number; resolve: () => void } | undefined;
|
||||
function request(action: string, values?: Record<string,string>, target?: string, revision?: number) {
|
||||
@@ -51,19 +53,12 @@ function eligibility(action: string) {
|
||||
}
|
||||
function render() {
|
||||
screen.setOwner(snapshot.owner?.slice(-12));
|
||||
const hostRows = [
|
||||
{ id: 'agents', label: 'Agents', detail: '' }, ...(snapshot.settings?.agents ?? []).map(a => ({ id: a.publicKey, label: a.profile?.name ?? a.publicKey, detail: '' })),
|
||||
{ id: 'providers', label: 'Providers', detail: '' }, ...(snapshot.settings?.providers ?? []).map(p => ({ id: p.id, label: p.name, detail: '' })),
|
||||
{ id: 'runtimes', label: 'Runtimes', detail: '' }, ...(snapshot.settings?.runtimes ?? []).map(r => ({ id: r.id, label: r.name, detail: '' })),
|
||||
];
|
||||
const rows = scope === 0 ? hostRows : snapshot.agents;
|
||||
if (selected === 'empty') selected = '';
|
||||
if (!selected || (scope === 0 && selected === 'missing' && rows.some(r => r.id === 'host'))) selected = rows[0]?.id ?? '';
|
||||
const rows = sectionRows(snapshot, scope);
|
||||
selected = navigation.selection(rows);
|
||||
const vanished = !!selected && !rows.some(row => row.id === selected);
|
||||
screen.setRelay(snapshot.hostRelay ?? snapshot.routing?.relay, snapshot.service?.relay ?? 'unknown', snapshot.relayName);
|
||||
const configured = snapshot.local.some(r => r.id === 'host');
|
||||
const actions: ManagerAction[] = scope === 0 ? [
|
||||
{ label: 'Register agent', run: async () => {
|
||||
const registerAction: ManagerAction = { label: 'Register agent', run: async () => {
|
||||
if (!configured) { await routing('configure'); return; }
|
||||
let secret = await screen.input('Register agent\nAgent nsec private key. Hidden. Saved only in Beehive’s OS credential store.', '', true);
|
||||
if (secret === undefined) return;
|
||||
@@ -72,7 +67,8 @@ function render() {
|
||||
if (!preview) { secret = ''; return; }
|
||||
if (await screen.confirm(`Register agent\n${preview.profile?.name ?? 'Name unavailable'}\n${preview.publicKey}\n${preview.profileState === 'unavailable' ? 'Relay unavailable. Register without a profile?' : preview.profileState === 'none' ? 'No profile found.' : preview.profile?.about ?? ''}\nThis does not authorize or start an agent. Save?`)) await request('register-agent',{ secret });
|
||||
secret = '';
|
||||
} },
|
||||
} };
|
||||
let actions: ManagerAction[] = scope !== 1 ? [
|
||||
{ label: 'Add provider', run: async () => {
|
||||
await request('provider-form');
|
||||
const type = await screen.choose('Add provider',['OpenAI','Databricks v2']); if (!type) return;
|
||||
@@ -103,9 +99,9 @@ function render() {
|
||||
if (!await screen.confirm(`Use ${name} for the next start?\nHost and agent: ${row.id}\nRevision: ${row.revision}\nThis does not change the current run.`)) return;
|
||||
await request('select-config', { name }, row.id, row.revision);
|
||||
} },
|
||||
...(['start','stop','restart'] as const).map(action => ({ label: action === 'restart' ? 'Restart — unavailable' : `${action === 'start' ? 'Start' : 'Stop'} selected agent`, disabled: action === 'restart' ? 'Restart can change the local setup. Use Stop. Inspect a recent host report that confirms the agent is stopped. Then use Start. No Restart request is sent.' : eligibility(action), run: async () => {
|
||||
...(['start','stop','restart'] as const).map(action => ({ label: action[0]!.toUpperCase() + action.slice(1), disabled: eligibility(action), run: async () => {
|
||||
const row = snapshot.agents.find(r => r.id === selected); if (!row) return;
|
||||
if (!await screen.confirm(`${action === 'start' ? 'Start' : 'Stop'} this agent?\nHost and agent: ${row.id}\nRevision: ${row.revision}`)) return;
|
||||
if (!await screen.confirm(`${action[0]!.toUpperCase() + action.slice(1)} agent?\nHost and agent: ${row.id}\nRevision: ${row.revision}`)) return;
|
||||
await request(action, undefined, row.id, row.revision);
|
||||
} })),
|
||||
{ label: 'Inspect operations', run: () => request('operations') },
|
||||
@@ -116,14 +112,28 @@ function render() {
|
||||
{ label: 'Sign in with saved owner key', run: () => routing('signin') },
|
||||
{ label: 'Import matching owner key and sign in', run: () => routing('signin', true) },
|
||||
];
|
||||
if (scope === 0) actions = actions.filter(a => a.label === (snapshot.service?.state === 'running' ? 'Stop' : 'Start'));
|
||||
if (scope === 1) actions.unshift(registerAction);
|
||||
if (scope === 2) actions = [
|
||||
...actions.filter(a => a.label === 'Add runtime'),
|
||||
{ label: 'Refresh', run: () => request('runtime-form') },
|
||||
];
|
||||
if (scope === 3) actions = [
|
||||
...actions.filter(a => a.label === 'Add provider'),
|
||||
{ label: 'Models', disabled: rows.some(r => r.id === selected) ? undefined : 'Select provider', run: async () => {
|
||||
const provider = selected;
|
||||
await request('models', { provider });
|
||||
if (snapshot.models) await screen.choose('Models', snapshot.models);
|
||||
} },
|
||||
];
|
||||
if (scope === 1 && !snapshot.owner) actions.push({ label: 'New private instructions draft…', run: async () => { const name = await screen.input('Private instructions · 1 of 2\nDraft name', '', false, false, v => v.trim() ? '' : 'Enter a draft name.'); if (name === undefined) return; const instructions = await screen.input(`Private instructions · 2 of 2\nDraft: ${name}\nSaved only on this computer. Not published or used by an agent. Do not enter passwords or keys.`, '', false, true, v => v.trim() ? '' : 'Enter instructions.'); if (instructions !== undefined) await request('draft', { name, instructions }); } });
|
||||
if (scope !== 0) actions.push({ label: 'Quit Beehive', run: () => screen.close() });
|
||||
screen.show(vanished ? [{ id: selected, label: 'Selection no longer available', detail: 'This item is no longer available. Select a current item. No operation will use a replacement item.' }, ...rows] : scope === 0 ? hostRows : snapshot.agents.length ? snapshot.agents : [{ id: 'empty', label: snapshot.owner ? 'No host reports yet' : 'Owner sign-in required', detail: 'Sign in as owner to manage agents. This list uses private host reports, not a separate agent directory. An empty list or an offline host does not mean agents are stopped. Local Host and local drafts do not require sign-in.' }], actions, selected);
|
||||
screen.show(vanished ? [{ id: selected, label: 'Selection unavailable', detail: 'Unavailable' }, ...rows] : rows.length ? rows : [{ id: 'empty', label: 'Empty', detail: '—' }], actions, selected);
|
||||
const notice = scope === 0 ? `Host: ${snapshot.service?.state ?? 'unknown'} · Saved: ${snapshot.settings?.revision ?? 0} · Loaded: ${snapshot.service?.revision ?? 'not confirmed'}\n${snapshot.status}` : snapshot.status;
|
||||
if (lastStatus !== notice) { lastStatus = notice; screen.notice(notice); }
|
||||
}
|
||||
screen.onScope = value => { scope = value; selected = ''; render(); };
|
||||
screen.onSelect = id => { if (selected !== id) { selected = id; render(); } };
|
||||
screen.onScope = value => { navigation.switch(value); scope = navigation.section; render(); if (scope === 2 && !snapshot.harnesses) void request('runtime-form'); };
|
||||
screen.onSelect = id => { if (id === 'empty') return; if (selected !== id) { if (id !== 'empty') navigation.select(id); selected = id; render(); } };
|
||||
const input = createInterface({ input: createReadStream('', { fd: 3 }) });
|
||||
input.on('line', line => {
|
||||
try {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { RGBA, BoxRenderable, InputRenderable, ScrollBoxRenderable, SelectRenderable, TextRenderable, TextareaRenderable, type CliRenderer, type KeyEvent } from '@opentui/core';
|
||||
import { managerSections } from './manager-navigation.ts';
|
||||
import { stripVTControlCharacters } from 'node:util';
|
||||
|
||||
export type ManagerRow = { id: string; label: string; detail: string; evidence?: string };
|
||||
@@ -42,7 +43,15 @@ export class OpenTuiScreen {
|
||||
constructor(readonly renderer: CliRenderer) {
|
||||
this.root = new BoxRenderable(renderer, { width: '100%', height: '100%', flexDirection: 'column', backgroundColor: bg });
|
||||
renderer.root.add(this.root);
|
||||
this.header = new TextRenderable(renderer, { fg, height: 2, onMouseDown: event => { if (event.y !== 0) return; const end = this.scope === 0 ? 20 : 18; if (event.x >= 8 && event.x < end) this.switchScope(0); else if (event.x >= end + 3 && event.x < end + 3 + (this.scope === 1 ? 8 : 6)) this.switchScope(1); } });
|
||||
this.header = new TextRenderable(renderer, { fg, height: 2, onMouseDown: event => {
|
||||
if (event.y !== 0) return;
|
||||
let start = this.renderer.width < 56 ? 0 : 8;
|
||||
for (let index = 0; index < managerSections.length; index++) {
|
||||
const width = managerSections[index]!.length + (this.scope === index ? 2 : 0);
|
||||
if (event.x >= start && event.x < start + width) { this.switchScope(index); return; }
|
||||
start += width + (this.renderer.width < 56 ? 1 : 3);
|
||||
}
|
||||
} });
|
||||
this.root.add(this.header);
|
||||
const body = new BoxRenderable(renderer, { flexGrow: 1, flexDirection: 'row', minHeight: 1 });
|
||||
this.root.add(body);
|
||||
@@ -88,7 +97,7 @@ export class OpenTuiScreen {
|
||||
|
||||
private focusStyle() {
|
||||
this.listFrame.title = `${this.list.focused ? '[List]' : 'List'} · ${this.list.getSelectedIndex() + 1}/${this.rows.length}`;
|
||||
this.scroll.title = this.scope === 0 ? 'Host settings' : 'Agent controls';
|
||||
this.scroll.title = managerSections[this.scope] ?? 'Details';
|
||||
for (const widget of [this.list, this.actions]) { widget.selectedBackgroundColor = widget.focused ? fg : bg; widget.selectedTextColor = widget.focused ? bg : fg; }
|
||||
}
|
||||
private narrow = false;
|
||||
@@ -100,18 +109,19 @@ export class OpenTuiScreen {
|
||||
private formWidth() { return this.scope === 0 ? Math.max(20, this.renderer.width - this.listFrame.width - 2) : Math.min(72, this.renderer.width - 4); }
|
||||
private formLeft() { return this.scope === 0 ? this.listFrame.width : Math.floor((this.renderer.width - this.formWidth()) / 2); }
|
||||
private resize() {
|
||||
this.heading();
|
||||
if (this.overlay && this.modal) { this.overlay.width = this.formWidth(); this.overlay.left = this.formLeft(); this.overlay.height = this.compactOverlay ? Math.min(14, this.renderer.height - 4) : this.renderer.height - 4; }
|
||||
this.small.visible = this.renderer.width < 40 || this.renderer.height < 16;
|
||||
this.narrow = this.scope !== 0 && (this.renderer.width < 88 || this.renderer.height < 24);
|
||||
this.listFrame.visible = !this.narrow || !this.drilled;
|
||||
this.scroll.visible = !this.narrow || this.drilled;
|
||||
this.listFrame.width = this.narrow ? '100%' : Math.min(32, Math.max(14, Math.floor(this.renderer.width * .28)));
|
||||
this.scroll.title = this.scope === 0 ? 'Host settings' : 'Agent controls';
|
||||
this.scroll.title = managerSections[this.scope] ?? 'Details';
|
||||
this.footer.content = this.relayFooter + '\n' + (this.renderer.width >= 70 ? OpenTuiScreen.hintsWide : OpenTuiScreen.hintsNarrow); this.footer.height = 2;
|
||||
}
|
||||
private relayFooter = 'Relay: Not configured · disconnected';
|
||||
setRelay(url?: string, state = 'unknown', name?: string) { this.relayFooter = url ? `Relay: ${name ? `${clean(name)} · ` : ''}${clean(url)} · ${state}` : 'Relay: Not configured · disconnected'; this.resize(); }
|
||||
private heading() { this.header.content = `BEEHIVE ${this.scope === 0 ? '[Local Host]' : 'Local Host'} | ${this.scope === 1 ? '[Agents]' : 'Agents'}\n${this.scope === 0 ? 'This computer · no owner sign-in needed' : 'Owner · ' + clean(this.owner)}`; }
|
||||
private heading() { this.header.content = `${this.renderer.width < 56 ? '' : 'BEEHIVE '}${managerSections.map((name, index) => this.scope === index ? `[${name}]` : name).join(this.renderer.width < 56 ? ' ' : ' | ')}\n${clean(this.owner)}`; }
|
||||
setOwner(publicSuffix?: string) { this.owner = publicSuffix ? `${publicSuffix} · signed in · key saved here` : 'signed out'; this.heading(); }
|
||||
private switchScope(scope: number) {
|
||||
if (this.modal || this.busy || this.small.visible || this.closed) return;
|
||||
@@ -137,7 +147,7 @@ export class OpenTuiScreen {
|
||||
if (key.name === 'f3' && this.scope !== 0) { key.preventDefault(); this.inspect(); return; }
|
||||
if (key.name === 'f4') { key.preventDefault(); this.outcome(); return; }
|
||||
if (this.focusIndex === 0 && ['home','end','pageup','pagedown'].includes(key.name)) { key.preventDefault(); this.navigate(this.list, key.name); }
|
||||
if (key.name === 'left' || key.name === 'right') { key.preventDefault(); this.switchScope(1 - this.scope); }
|
||||
if (key.name === 'left' || key.name === 'right') { key.preventDefault(); this.switchScope((this.scope + (key.name === 'left' ? managerSections.length - 1 : 1)) % managerSections.length); }
|
||||
if (key.name === 'tab') { key.preventDefault(); this.focusIndex = this.focusIndex === 0 ? 2 : 0; [this.list, this.scroll, this.actions][this.focusIndex]!.focus(); this.focusStyle(); }
|
||||
if (key.name === 'f1') { key.preventDefault(); this.help(); }
|
||||
}
|
||||
@@ -154,7 +164,7 @@ export class OpenTuiScreen {
|
||||
if (this.closed) return;
|
||||
const prior = selected ?? this.rows[this.list.getSelectedIndex()]?.id;
|
||||
this.rows = rows; this.commands = actions;
|
||||
this.detail.visible = this.scope !== 0;
|
||||
this.detail.visible = true;
|
||||
const commandOptions = actions.map(a => ({ name: clean(a.label + (a.disabled ? ' — unavailable' : '')), description: '' }));
|
||||
if (JSON.stringify(this.actions.options) !== JSON.stringify(commandOptions)) this.actions.options = commandOptions;
|
||||
this.actions.height = Math.max(5,actions.length);
|
||||
@@ -184,7 +194,7 @@ export class OpenTuiScreen {
|
||||
}
|
||||
inspect() { if (this.scope === 0) return; const row = this.rows[this.list.getSelectedIndex()]; this.read('Technical details', row?.evidence ?? 'No technical details for this item.'); }
|
||||
private outcome() { this.read('Status · latest message', this.lastNotice || 'No status message yet.'); }
|
||||
private help() { this.read('Help', 'Tab: switch list and controls. Arrows: select. Enter: open.\nLeft/Right: switch Host settings and remote Agents.\nEsc: cancel or back. q or Ctrl-C: quit. The host keeps running.\nHost Start and Stop control this computer’s service, not an agent.\nAgent execution still requires authenticated management authority.\nShortcuts do not run while you edit fields.'); }
|
||||
private help() { this.read('Help', 'Tab: switch list and controls. Arrows: select. Enter: open.\nLeft/Right: Host, Agents, Harnesses, Providers.\nEsc: cancel or back. q or Ctrl-C: quit. The host keeps running.\nHost Start and Stop control this computer’s service, not an agent.\nAgent execution still requires authenticated management authority.\nShortcuts do not run while you edit fields.'); }
|
||||
private read(title: string, content: string) {
|
||||
if (this.modal || this.closed) return;
|
||||
const box = new ScrollBoxRenderable(this.renderer, { position: 'absolute', top: 2, left: this.formLeft(), width: this.formWidth(), height: this.renderer.height - 4, border: true, title: title + ' · Esc close', backgroundColor: bg, scrollY: true });
|
||||
|
||||
@@ -67,17 +67,21 @@ test('manager gates owner, exact selection/revision/freshness, unresolved operat
|
||||
assert.equal(f.snapshot.agents[0]!.disabled!.start, '');
|
||||
const target = JSON.stringify(['host-a','agent-a']);
|
||||
await request('start',{ target, revision: 2 }); assert.match(f.snapshot.status,/selection changed/);
|
||||
await request('restart',{ target, revision: 2 }); assert.match(f.snapshot.status,/selection changed/); assert.equal(f.submitted.length,0);
|
||||
await request('start',{ target, revision: 3 }); assert.equal(f.submitted.length,1); assert.equal(f.submitted[0]!.type,'start');
|
||||
await f.controller.request({ id, action: 'start', target, revision: 3 }); assert.equal(f.submitted.length,1,'duplicate request cannot act twice');
|
||||
await request('restart',{ target, revision: 3 }); assert.match(f.snapshot.status,/No Restart request is sent/); assert.equal(f.submitted.length,1,'handler refuses even if directly invoked');
|
||||
await request('restart',{ target, revision: 3 }); assert.equal(f.submitted.at(-1)!.type,'restart'); assert.equal(f.submitted.length,2);
|
||||
f.states([{ request: f.submitted[0], state: 'unknown' }]);
|
||||
await request('stop',{ target, revision: 3 }); assert.match(f.snapshot.status,/no confirmed result/); assert.equal(f.submitted.length,1);
|
||||
await request('stop',{ target, revision: 3 }); assert.match(f.snapshot.status,/no confirmed result/); assert.equal(f.submitted.length,2);
|
||||
await request('restart',{ target, revision: 3 }); assert.equal(f.submitted.length,2,'unresolved operation blocks Restart');
|
||||
f.states([{ request: f.submitted[0], state: 'completed' }]);
|
||||
await request('select-config',{ target, revision: 3, values: { name: 'Alternative' } }); assert.equal(f.submitted[1]!.type,'save');
|
||||
await request('stop',{ target, revision: 3 }); assert.equal(f.submitted[2]!.type,'stop');
|
||||
await request('select-config',{ target, revision: 3, values: { name: 'Alternative' } }); assert.equal(f.submitted[2]!.type,'save');
|
||||
await request('stop',{ target, revision: 3 }); assert.equal(f.submitted[3]!.type,'stop');
|
||||
f.receive({ ...m, revision: 4, body: { ...m.body, observedAt: Date.now()+1, phase: 'running', actualRun: { run: 'actual' } } });
|
||||
await request('start',{ target, revision: 4 }); assert.match(f.snapshot.status,/recent report from the assigned host/);
|
||||
await request('signout'); assert.equal(f.snapshot.owner,undefined); assert.equal(f.submitted.length,3); assert.equal(f.closed,1);
|
||||
f.receive({ ...m, revision: 5, body: { ...m.body, observedAt: Date.now()+2, assignedHost: 'host-b' } });
|
||||
await request('restart',{ target, revision: 5 }); assert.match(f.snapshot.status,/assigned host/); assert.equal(f.submitted.length,4);
|
||||
await request('signout'); assert.equal(f.snapshot.owner,undefined); assert.equal(f.submitted.length,4); assert.equal(f.closed,1);
|
||||
f.receive(m); assert.equal(f.controller.snapshot().agents.length,0,'late receive is fenced after signout');
|
||||
} finally { f.cleanup(); }
|
||||
});
|
||||
|
||||
25
beehive/test/manager-navigation.test.ts
Normal file
25
beehive/test/manager-navigation.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { ManagerNavigation, managerSections, sectionRows } from '../src/manager-navigation.ts';
|
||||
import type { ManagerSnapshot } from '../src/manager-controller.ts';
|
||||
|
||||
test('four sections retain exact selections without retargeting removed items', () => {
|
||||
assert.deepEqual(managerSections, ['Host', 'Agents', 'Harnesses', 'Providers']);
|
||||
const navigation = new ManagerNavigation();
|
||||
const rows = ['a','b'].map(id => ({ id, label: id, detail: id }));
|
||||
assert.equal(navigation.selection(rows), 'a');
|
||||
navigation.select('b'); navigation.switch(1);
|
||||
assert.equal(navigation.selection(rows), 'a');
|
||||
navigation.switch(0); assert.equal(navigation.selection(rows), 'b');
|
||||
assert.equal(navigation.selection(rows.slice(0,1)), 'b');
|
||||
navigation.switch(9); assert.equal(navigation.section, 0);
|
||||
});
|
||||
|
||||
test('section rows expose selected public fields, not credential references or other controls', () => {
|
||||
const snapshot: ManagerSnapshot = { local: [{ id: 'host', label: 'This host', detail: '' }], agents: [], status: '', hostRelay: 'wss://fixture.invalid', service: { state: 'stopped' }, settings: { version: 1, revision: 1, agents: [], providers: [{ id: 'p', name: 'Provider A', type: 'openai', endpoint: 'https://api.openai.com/v1', key: { service: 'beehive', account: 'secret-reference' } }], runtimes: [{ id: 'r', name: 'Runtime A', harness: 'buzz-agent', providerId: 'p', model: 'gpt-5', effort: 'high', executable: '/fixture/harness' }] } };
|
||||
assert.match(sectionRows(snapshot,0)[0]!.detail, /Host name: This host\nRelay URL: wss:\/\/fixture.invalid\nState: stopped/);
|
||||
assert.equal(sectionRows(snapshot,1).length, 0);
|
||||
assert.match(sectionRows(snapshot,2)[0]!.detail, /Provider: Provider A\nModel: gpt-5\nEffort: high/);
|
||||
assert.equal(sectionRows(snapshot,3)[0]!.id,'p');
|
||||
assert.ok(!JSON.stringify(sectionRows(snapshot,3)).includes('secret-reference'));
|
||||
});
|
||||
@@ -3,16 +3,16 @@ import assert from 'node:assert/strict';
|
||||
import { createTestRenderer } from '@opentui/core/testing';
|
||||
import { OpenTuiScreen } from '../src/opentui-screen.ts';
|
||||
|
||||
test('host settings has right-pane controls, no inspection menu, and hidden ordinary-key entry', async () => {
|
||||
test('host shows selected details and controls with hidden ordinary-key entry', async () => {
|
||||
const ui = await createTestRenderer({ width: 100, height: 30, exitOnCtrlC: false });
|
||||
const view = new OpenTuiScreen(ui.renderer);
|
||||
try {
|
||||
let calls = 0;
|
||||
view.show(['Agents','Providers','Runtimes'].map(label => ({ id: label,label,detail: 'PRIVATE DETAIL MUST NOT DISPLAY' })),['Register agent','Add provider','Add runtime','Start','Stop'].map(label => ({ label,run: () => { calls++; } })));
|
||||
view.show(['Agents','Providers','Runtimes'].map(label => ({ id: label,label,detail: 'Host name: Fixture' })),['Register agent','Add provider','Add runtime','Start','Stop'].map(label => ({ label,run: () => { calls++; } })));
|
||||
view.setRelay('wss://fixture.invalid','disconnected'); await ui.renderOnce();
|
||||
const frame = ui.captureCharFrame();
|
||||
assert.match(frame,/Host settings/); assert.match(frame,/Register agent/); assert.match(frame,/wss:\/\/fixture.invalid.*disconnected/);
|
||||
assert.ok(!frame.includes('Inspect')); assert.ok(!frame.includes('PRIVATE DETAIL')); assert.ok(!frame.includes('Actions (a)'));
|
||||
assert.match(frame,/\[Host\]/); assert.match(frame,/Register agent/); assert.match(frame,/wss:\/\/fixture.invalid.*disconnected/);
|
||||
assert.ok(!frame.includes('Inspect')); assert.ok(frame.includes('Host name: Fixture')); assert.ok(!frame.includes('Actions (a)'));
|
||||
ui.mockInput.pressTab(); ui.mockInput.pressEnter(); await ui.renderOnce(); assert.equal(calls,1);
|
||||
const secret = 'synthetic-aq?io-private-value';
|
||||
const entered = view.input('Hidden fixture key','',true);
|
||||
@@ -55,3 +55,25 @@ test('footer shows the optional sanitized relay name before the exact URL and st
|
||||
assert.match(ui.captureCharFrame(),/Relay: Not configured · disconnected/);
|
||||
} finally { view.close(); }
|
||||
});
|
||||
|
||||
|
||||
test('four sections navigate in both directions and remain readable at narrow width', async () => {
|
||||
const ui = await createTestRenderer({ width: 100, height: 30, exitOnCtrlC: false });
|
||||
const view = new OpenTuiScreen(ui.renderer);
|
||||
const scopes: number[] = [];
|
||||
view.onScope = scope => { scopes.push(scope); view.show([{ id: String(scope), label: 'Selected', detail: `Section ${scope}` }], []); };
|
||||
try {
|
||||
for (const name of ['Agents', 'Harnesses', 'Providers', 'Host']) {
|
||||
ui.mockInput.pressArrow('right'); await ui.renderOnce();
|
||||
assert.ok(ui.captureCharFrame().includes(`[${name}]`), JSON.stringify({name,scopes,frame:ui.captureCharFrame()}));
|
||||
}
|
||||
ui.mockInput.pressArrow('left'); await ui.renderOnce();
|
||||
assert.deepEqual(scopes, [1, 2, 3, 0, 3]);
|
||||
ui.resize(40, 24); await ui.renderOnce();
|
||||
assert.match(ui.captureCharFrame(), /Host Agents Harnesses \[Providers\]/);
|
||||
await ui.mockMouse.click(13,0); await ui.renderOnce();
|
||||
assert.equal(scopes.at(-1),2);
|
||||
await ui.mockMouse.click(25,0); await ui.renderOnce();
|
||||
assert.equal(scopes.at(-1),3);
|
||||
} finally { view.close(); }
|
||||
});
|
||||
|
||||
24
beehive/tools/ux-walkthrough.sh
Normal file
24
beehive/tools/ux-walkthrough.sh
Normal file
@@ -0,0 +1,24 @@
|
||||
#!/bin/sh
|
||||
# Synthetic UI only: fresh file credentials and explicitly blocked external seams.
|
||||
set -eu
|
||||
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
NODE=${BEEHIVE_REVIEW_NODE:-/Users/loganj/Library/Caches/hermit/pkg/node-24.15.0/bin/node}
|
||||
BUN=${BEEHIVE_REVIEW_BUN:-/Users/loganj/.buzz/artifacts/beehive-opentui-57f9c8fa/runtime/bun-darwin-aarch64/bun}
|
||||
[ -x "$NODE" ] && [ -x "$BUN" ] || { echo 'Set BEEHIVE_REVIEW_NODE and BEEHIVE_REVIEW_BUN' >&2; exit 1; }
|
||||
HOME=$(mktemp -d "${TMPDIR:-/tmp}/beehive-pairing-cli-ux-XXXXXX")
|
||||
export HOME
|
||||
export BEEHIVE_TEST_CREDENTIAL_FILE="$HOME/credentials.json"
|
||||
export NODE_OPTIONS="--import=$ROOT/test/manager-installed-loader.ts"
|
||||
export BEEHIVE_BUN="$BUN"
|
||||
unset BUZZ_PRIVATE_KEY BUZZ_AUTH_TAG BUZZ_RELAY_URL BEEHIVE_REAL_BUZZ_ACP
|
||||
cd "$ROOT"
|
||||
"$NODE" --input-type=module <<'JS'
|
||||
import { fixtureCredential } from './test/manager-installed-fixture.ts';
|
||||
import { publicKey } from './src/protocol.ts';
|
||||
const directory = `${process.env.HOME}/.beehive/host`;
|
||||
const signal = new AbortController().signal;
|
||||
await fixtureCredential({action:'configure',directory,label:'Review host',owner:publicKey('1'.repeat(64)),relay:'wss://fixture.invalid'},signal);
|
||||
await fixtureCredential({action:'add-openai',directory,name:'Review OpenAI',secret:'synthetic-only'},signal);
|
||||
JS
|
||||
printf 'Synthetic review HOME: %s\n' "$HOME" >&2
|
||||
exec "$NODE" src/manager-entry.ts
|
||||
Reference in New Issue
Block a user