mirror of
https://github.com/block/buzz.git
synced 2026-10-30 05:51:46 +00:00
Use ordinary keys and plain-language Beehive manager copy
Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
@@ -21,14 +21,16 @@ Arguments still route to the supported existing Node CLI; no renderer/domain for
|
|||||||
owner verified before OS-only persistence. Sign-out retains the saved key and does
|
owner verified before OS-only persistence. Sign-out retains the saved key and does
|
||||||
not Stop hosts/agents. Missing, locked, denied and mismatched keys are not reset.
|
not Stop hosts/agents. Missing, locked, denied and mismatched keys are not reset.
|
||||||
- Actual private host availability and per-host agent inventory, selectable JSON
|
- Actual private host availability and per-host agent inventory, selectable JSON
|
||||||
detail with actual run versus selected-next and explicit stale/UNKNOWN wording.
|
detail with Current run (host report) versus Configuration for next start and
|
||||||
|
explicit unknown status.
|
||||||
This is **not an independent agent catalog**. No fabricated inventory rows.
|
This is **not an independent agent catalog**. No fabricated inventory rows.
|
||||||
- Select an existing named next configuration; exact host/agent/revision confirmation.
|
- Select an existing named next configuration; exact host/agent/revision confirmation.
|
||||||
Explicit Start and Stop through the existing durable management intent client.
|
Explicit Start and Stop through the existing durable management intent client.
|
||||||
Start requires a fresh assigned stopped report without actual run. Unresolved
|
Start requires a fresh assigned stopped report without actual run. Unresolved
|
||||||
operations fence further lifecycle submissions; inspect/reconcile remains available.
|
operations fence further lifecycle submissions; inspect/reconcile remains available.
|
||||||
- Selectable operation records include operation ID, exact target, CAS revision,
|
- Selectable operation records include operation ID, exact target, CAS revision,
|
||||||
receipt/publication and unknown state. Reconcile does not retry blocked work.
|
receipt/publication and unknown state. Check operation results reconnects and can
|
||||||
|
resend pending work, but does not retry operations blocked by relay policy.
|
||||||
- Offline multiline private instruction draft creation, atomically persisted using
|
- Offline multiline private instruction draft creation, atomically persisted using
|
||||||
the existing draft API. Publishing/applying remains a distinct existing CLI action.
|
the existing draft API. Publishing/applying remains a distinct existing CLI action.
|
||||||
|
|
||||||
@@ -38,7 +40,8 @@ Arguments still route to the supported existing Node CLI; no renderer/domain for
|
|||||||
Existing backend Restart can consume a different selected binding. Rather than
|
Existing backend Restart can consume a different selected binding. Rather than
|
||||||
relying on a disabled button or changing established service semantics in this
|
relying on a disabled button or changing established service semantics in this
|
||||||
bounded prototype, this manager sends no Restart at all. Explicit Stop, inspect
|
bounded prototype, this manager sends no Restart at all. Explicit Stop, inspect
|
||||||
accepted stopped state, then Start is available. Existing advanced CLI retains
|
a recent host report that confirms the agent is stopped, then Start is available.
|
||||||
|
Existing advanced CLI retains
|
||||||
its prior explicit Restart behavior; it does not promise same-runtime Restart.
|
its prior explicit Restart behavior; it does not promise same-runtime Restart.
|
||||||
- Foreground host service launch remains `beehive host --owner-present`; Quit this
|
- Foreground host service launch remains `beehive host --owner-present`; Quit this
|
||||||
manager first. No daemon, process ownership transfer or auto service management.
|
manager first. No daemon, process ownership transfer or auto service management.
|
||||||
@@ -48,7 +51,7 @@ Arguments still route to the supported existing Node CLI; no renderer/domain for
|
|||||||
- Existing draft editing/resume, profile publication/application, public metadata
|
- Existing draft editing/resume, profile publication/application, public metadata
|
||||||
editing/publication and policy retry remain supported through `beehive drafts`
|
editing/publication and policy retry remain supported through `beehive drafts`
|
||||||
and `beehive tui discover <relay> ~/.beehive/owner`. New forms explicitly say
|
and `beehive tui discover <relay> ~/.beehive/owner`. New forms explicitly say
|
||||||
“Not available in this build”; these are not simulated failures or fake success.
|
“unavailable”; these are not simulated failures or fake success.
|
||||||
- No owner-key generation/reveal UI, independent relay catalog, zero-agent reusable
|
- No owner-key generation/reveal UI, independent relay catalog, zero-agent reusable
|
||||||
runtime catalog, identity-only registration, Move UI or remote provider login.
|
runtime catalog, identity-only registration, Move UI or remote provider login.
|
||||||
- For lists/actions longer than their visible pane, use keyboard arrows/Enter.
|
- For lists/actions longer than their visible pane, use keyboard arrows/Enter.
|
||||||
@@ -118,18 +121,56 @@ Node 24.15.0 and Bun 1.4.2 executables at `beehive/runtime/node` and
|
|||||||
`beehive/runtime/bun`. Link `~/.local/bin/beehive` to the exported package's
|
`beehive/runtime/bun`. Link `~/.local/bin/beehive` to the exported package's
|
||||||
`bin/beehive`, not to a worktree. See the immutable delivery recipe for the exact pin.
|
`bin/beehive`, not to a worktree. See the immutable delivery recipe for the exact pin.
|
||||||
|
|
||||||
## Local visual-polish candidate (not installed)
|
## Ordinary-key and plain-language candidate (not installed)
|
||||||
|
|
||||||
The subsequent presentation candidate replaces default JSON walls with Node-built
|
The presentation candidate replaces default JSON walls with Node-built plain-language
|
||||||
public summaries and F3 technical evidence; uses ≥88×24 split / narrow Enter-to-detail;
|
summaries and `i` technical evidence; uses ≥88×24 split / narrow Enter-to-detail;
|
||||||
puts contextual guarded commands in F2; retains permanent keys with F4 scrollable
|
puts contextual guarded commands in the `a` actions list; retains a persistent
|
||||||
outcomes; and uses compact validated inputs plus a named configuration chooser.
|
footer with `?` help and `o` scrollable latest status message; and uses compact validated
|
||||||
The installed `6390e594` exports are deliberately unchanged pending independent
|
inputs plus a named configuration chooser. Every affordance is reachable
|
||||||
implementation-vs-design review.
|
without a function key: `?` help, `a` actions, `i` inspect, `o` Status, `q` or
|
||||||
|
Ctrl-Q quit; Tab/Shift-Tab, arrows, Enter and Esc keep their existing routing,
|
||||||
|
including pending-Esc priority. Letter shortcuts never fire inside public,
|
||||||
|
secret or multiline forms — there they type as text — and never clear values,
|
||||||
|
bypass confirmations or double-invoke operations. F1–F4 remain undocumented
|
||||||
|
compatibility aliases; footer, help and instructions never lead with function
|
||||||
|
keys. Quit and sign out are never Stop, and Restart remains refused at the
|
||||||
|
controller.
|
||||||
|
|
||||||
Evidence and the corrected external design are under
|
Earlier polish evidence and the corrected external design are under
|
||||||
`/Users/loganj/.buzz/artifacts/beehive-opentui-polish-6390e594/implementation/`.
|
`/Users/loganj/.buzz/artifacts/beehive-opentui-polish-6390e594/implementation/`.
|
||||||
Read `IMPLEMENTATION.md` there for the eight-point self-review, scoped deviations,
|
Ordinary-key correction evidence is under
|
||||||
actual PTY comparisons versus offscreen fixtures, exact runtime commands and limits.
|
`/Users/loganj/.buzz/artifacts/beehive-opentui-keys-cb1b341/`. Neither claims
|
||||||
This candidate does not claim exhaustive design conformance, real relay inventory,
|
exhaustive design conformance, real relay inventory, light-terminal visual
|
||||||
light-terminal visual proof, or safe scrolled-action mouse activation.
|
proof, or safe scrolled-action mouse activation.
|
||||||
|
|
||||||
|
|
||||||
|
### Combined candidate validation
|
||||||
|
|
||||||
|
Copy follows the bounded packet at
|
||||||
|
`/Users/loganj/.buzz/artifacts/beehive-plain-language-0d962712/COPY_CHANGES.md`.
|
||||||
|
Status is the latest message, not history. Display translations do not rename
|
||||||
|
protocol fields or change operation classification. Configuration names, model
|
||||||
|
names and workspace values remain literal even if they match an operation result.
|
||||||
|
Inspect retains raw evidence. Unknown status never means stopped.
|
||||||
|
|
||||||
|
Integration evidence: `/Users/loganj/.buzz/artifacts/beehive-integration-abe68e43/`.
|
||||||
|
Direct Node 24.15.0 typecheck passed; Bun 1.4.2 renderer tests passed 3/3.
|
||||||
|
One initial renderer failure was a stale “Terminal too small” text assertion;
|
||||||
|
only that assertion changed, and the failure log is retained. The one full Node
|
||||||
|
package run completed in 28.910s: **150 pass, 2 fail, 19 skip** (171 total).
|
||||||
|
All five manager tests passed, including the new raw-evidence/literal-value and
|
||||||
|
unknown-state presentation test. Full-suite failures remain unmodified:
|
||||||
|
`broker.test.ts:31` expected a successful verification value, and
|
||||||
|
`credential-helper.test.ts:33` received its 1000ms deadline error where cancellation
|
||||||
|
was expected. Neither path imports this presentation change; the broker cause is
|
||||||
|
not established. No rerun-to-green, timing changes or full-suite green claim.
|
||||||
|
|
||||||
|
Actual 100×30 worktree PTY smoke used the committed installed-manager loader,
|
||||||
|
synthetic credentials and disconnected transport in a fresh fixture directory.
|
||||||
|
Ordinary-key Help, Actions, configuration form/confirmation, Inspect, Status and
|
||||||
|
Quit passed; exit 0 and alternate-screen restoration were verified. No production
|
||||||
|
credentials, relay, provider, host lifecycle or installed-package change was used.
|
||||||
|
Narrow renderer checks cover the 40-column footer, input and pane routing; this is
|
||||||
|
not an exhaustive screenshot matrix for every prompt or launch error. Publication
|
||||||
|
and installed desktop/laptop smoke belong to the next continuation.
|
||||||
|
|||||||
@@ -15,11 +15,104 @@ export type ManagerRequest = { id: number; action: string; values?: Record<strin
|
|||||||
export type ManagerItem = { id: string; label: string; detail: string; evidence?: string; disabled?: Record<string, string> };
|
export type ManagerItem = { id: string; label: string; detail: string; evidence?: string; disabled?: Record<string, string> };
|
||||||
export type ManagerSnapshot = { local: ManagerItem[]; agents: (ManagerItem & { revision: number; configurations: string[] })[]; routing?: { owner: string; relay: string }; owner?: string; status: string };
|
export type ManagerSnapshot = { local: ManagerItem[]; agents: (ManagerItem & { revision: number; configurations: string[] })[]; routing?: { owner: string; relay: string }; owner?: string; status: string };
|
||||||
const short = (s: string) => s.length > 22 ? `${s.slice(0,8)}…${s.slice(-6)}` : s;
|
const short = (s: string) => s.length > 22 ? `${s.slice(0,8)}…${s.slice(-6)}` : s;
|
||||||
const describe = (v: unknown): string => {
|
|
||||||
|
/** Final plain display text thrown by this boundary; never rewrapped or retranslated. */
|
||||||
|
class PlainStatus extends Error {}
|
||||||
|
const plain = (text: string) => new PlainStatus(text);
|
||||||
|
/** Shared backend validation/protocol sentinels are translated here, at the manager
|
||||||
|
* display boundary only. Legacy CLI text, tests and result sentinels stay exact;
|
||||||
|
* unknown diagnostics are retained after a plain failure context, never replaced. */
|
||||||
|
const backendMessages: Record<string, string> = {
|
||||||
|
'Unsupported Beehive controller configuration': 'Saved Beehive configuration uses an unsupported version.',
|
||||||
|
'Invalid retained management relay URL': 'The saved relay URL is invalid.',
|
||||||
|
'Controller already configured; use Settings for deliberate changes': 'Owner and relay are already configured. They cannot be changed in this view.',
|
||||||
|
'Management relay requires wss:// (ws://127.0.0.1 for fixtures)': 'Enter a relay URL that starts with ws:// or wss://.',
|
||||||
|
'Owner PUBLIC npub or 64-character hex key required; never enter a private key': 'Enter the owner’s public key as an npub or 64 hex characters. Do not enter a private key.',
|
||||||
|
'Legacy identity retained; explicit consent required for migration': 'The saved host identity uses an older format. It has not been changed. Migration requires explicit permission.',
|
||||||
|
'Invalid host credential reference': 'The saved host key reference is invalid.',
|
||||||
|
'Explicit stopped migrate-slots required': 'This installation needs migration. Stop its agents before you use migrate-slots.',
|
||||||
|
'Installation requires 1..32 slots': 'The installation must contain 1 to 32 agent records.',
|
||||||
|
'Invalid slot identity': 'An agent record has an invalid identity.',
|
||||||
|
'Invalid slot binding': 'An agent record refers to an invalid local setup.',
|
||||||
|
'Public slot binding mismatch': 'The saved agent record does not match its host, owner or agent identity.',
|
||||||
|
'Shared harness inventory must not copy key material': 'Shared local setups must not contain identity keys.',
|
||||||
|
'Binding cannot carry identity': 'A local setup must not contain host or agent identity fields.',
|
||||||
|
'Binding cannot change conversation authority': 'A local setup cannot change permission to use the conversation.',
|
||||||
|
'Invalid retired binding definition': 'A retired local setup does not match its saved definition.',
|
||||||
|
'Public credential installation required; no automatic plaintext migration': 'This installation must use secure key references. Plain-text keys will not be migrated automatically.',
|
||||||
|
'Invalid agent credential reference': 'The saved agent key reference is invalid.',
|
||||||
|
'Draft directory must be owner-only': 'Only the local user may access the draft folder.',
|
||||||
|
'Draft storage exceeds limit': 'The draft file exceeds the size limit.',
|
||||||
|
'Invalid draft storage': 'The saved draft file is invalid.',
|
||||||
|
'Invalid draft identity': 'A saved draft has an invalid or duplicate ID.',
|
||||||
|
'Draft changed; resume again': 'The draft changed. Open it again before you edit.',
|
||||||
|
'Draft storage full; discard unused drafts': 'Draft storage is full. Discard unused drafts through beehive drafts.',
|
||||||
|
'Invalid profile name/parent': 'The instructions name or previous revision is invalid. Do not use default as the name.',
|
||||||
|
'Invalid nonsecret instructions': 'Enter instructions of no more than 2048 UTF-8 bytes. Do not use control characters.',
|
||||||
|
'Profile revision conflict': 'The instructions do not match their revision.',
|
||||||
|
'Management journal directory must be owner-only': 'Only the local user may access the operation records folder.',
|
||||||
|
'Management journal full': 'Operation storage is full.',
|
||||||
|
'Management journal full; no operation submitted': 'Operation storage is full. No operation was submitted.',
|
||||||
|
'Oversized intent': 'A saved operation request exceeds the size limit.',
|
||||||
|
'Oversized receipt': 'A saved host result exceeds the size limit.',
|
||||||
|
'Oversized blocked intent': 'A saved blocked request exceeds the size limit.',
|
||||||
|
'Wrong journal scope': 'Saved operation records do not match this owner and relay.',
|
||||||
|
'Invalid intent': 'A saved operation request is invalid.',
|
||||||
|
'Unmatched journal receipt': 'A saved host result does not match its operation.',
|
||||||
|
'Invalid blocked intent': 'A saved blocked request is invalid.',
|
||||||
|
'Unmatched durable receipt': 'A saved host result does not match its operation.',
|
||||||
|
'Operation ID already prepared': 'This operation ID is already saved.',
|
||||||
|
'UI closed': 'Beehive is closed.',
|
||||||
|
'Relay disconnected; result unknown': 'The relay disconnected. The operation result is unknown.',
|
||||||
|
};
|
||||||
|
const plainBackendMessage = (message: string) => backendMessages[message] ?? `Could not complete the operation: ${message}`;
|
||||||
|
|
||||||
|
/** Raw management values keep their exact protocol/evidence bytes; only the default
|
||||||
|
* summary gets plain labels. Unknown nested fields stay technical, never discarded. */
|
||||||
|
const plainValues: Record<string, string> = {
|
||||||
|
'immutable publication observed; no agent application': 'Private instructions published. Not applied to an agent.',
|
||||||
|
'host terminal receipt': 'Host result received.',
|
||||||
|
'relay policy failure; automatic retry disabled; reconcile, then retry after policy repair': 'Relay policy blocked the operation. Automatic retry is disabled. Check operation results. Correct the policy problem before you retry through the CLI.',
|
||||||
|
'relay observed; not host admission': 'Relay publication confirmed. Host acceptance not confirmed.',
|
||||||
|
'unconfirmed': 'Not confirmed.',
|
||||||
|
'accepted': 'Accepted by host.',
|
||||||
|
'saved; running configuration unchanged': 'Configuration saved. Current run unchanged.',
|
||||||
|
'interrupted-reconcile-locally': 'Operation interrupted. Result unknown. Inspect and repair the saved state on the host before you try again.',
|
||||||
|
};
|
||||||
|
const plainStates: Record<string, string> = { pending: 'Pending', completed: 'Completed', failed: 'Failed', unknown: 'Unknown' };
|
||||||
|
const plainTypes: Record<string, string> = { start: 'Start', stop: 'Stop', restart: 'Restart', move: 'Move', metadata: 'Publish profile', profile: 'Publish instructions' };
|
||||||
|
const plainOperationType = (request: Message) => request.type === 'save' && request.body.configurationAction === 'select' ? 'Choose configuration' : plainTypes[request.type] ?? request.type;
|
||||||
|
const operationText = (value: unknown) => typeof value === 'string' ? plainValues[value] ?? value : describe(value);
|
||||||
|
/** Named launch choice: the model/workspace/instruction selection of one agent. */
|
||||||
|
const selectionLabels: Record<string, string> = {
|
||||||
|
'model': 'Model', 'workspace': 'Workspace', 'profile': 'Instructions revision',
|
||||||
|
'harnessSetup.id': 'Local setup', 'harnessSetup.fingerprint': 'Local setup fingerprint',
|
||||||
|
'configuration.name': 'Configuration name', 'configuration.revision': 'Configuration revision',
|
||||||
|
'behavior.name': 'Instructions name', 'behavior.instructions': 'Private instructions',
|
||||||
|
'behavior.revision': 'Instructions revision', 'behavior.parent': 'Previous instructions revision',
|
||||||
|
};
|
||||||
|
const runLabels: Record<string, string> = { ...selectionLabels, ...Object.fromEntries(Object.entries(selectionLabels).map(([key, value]) => [`selection.${key}`, value])) };
|
||||||
|
/** Host availability pairing: host infrastructure, not agent configuration. */
|
||||||
|
const availabilityLabels: Record<string, string> = {
|
||||||
|
'configuration': 'Host configuration', 'configuration.host': 'Host', 'configuration.owner': 'Owner',
|
||||||
|
'configuration.relay': 'Relay', 'configuration.label': 'Host name', 'configuration.observedAt': 'Report time', 'observedAt': 'Report time',
|
||||||
|
};
|
||||||
|
/** These containers hold only labelled choice fields; their children speak alone. */
|
||||||
|
const inlineChoice = new Set(['harnessSetup', 'configuration', 'behavior', 'selection', 'selection.harnessSetup', 'selection.configuration', 'selection.behavior']);
|
||||||
|
const describe = (v: unknown, labels: Record<string, string> = {}, path = ''): string => {
|
||||||
if (v === undefined || v === null) return 'Not reported';
|
if (v === undefined || v === null) return 'Not reported';
|
||||||
if (typeof v !== 'object') return typeof v === 'string' && /^[0-9a-f]{32,}$/i.test(v) ? short(v) : String(v);
|
if (typeof v !== 'object') {
|
||||||
|
if (typeof v !== 'string') return String(v);
|
||||||
|
if ((path === 'profile' || path.endsWith('.profile')) && v === 'default') return 'Default instructions';
|
||||||
|
return /^[0-9a-f]{32,}$/i.test(v) ? short(v) : v;
|
||||||
|
}
|
||||||
if (!Object.keys(v).length) return 'Not reported';
|
if (!Object.keys(v).length) return 'Not reported';
|
||||||
return Object.entries(v).map(([k, value]) => `${k}: ${describe(value)}`).join('\n');
|
return Object.entries(v).map(([k, value]) => {
|
||||||
|
const child = path ? `${path}.${k}` : k;
|
||||||
|
const rendered = describe(value, labels, child);
|
||||||
|
if (labels !== availabilityLabels && inlineChoice.has(child) && value !== null && !Array.isArray(value) && typeof value === 'object') return rendered;
|
||||||
|
return `${labels[child] ?? k}: ${rendered}`;
|
||||||
|
}).join('\n');
|
||||||
};
|
};
|
||||||
|
|
||||||
/** One bounded Node helper per explicit credential operation. Completion waits for exit.
|
/** One bounded Node helper per explicit credential operation. Completion waits for exit.
|
||||||
@@ -40,7 +133,7 @@ export function managerCredential(input: object, signal: AbortSignal, helper = n
|
|||||||
child.once('message', value => { result = value; });
|
child.once('message', value => { result = value; });
|
||||||
child.once('close', code => {
|
child.once('close', code => {
|
||||||
clearTimeout(timer); signal.removeEventListener('abort', cancel);
|
clearTimeout(timer); signal.removeEventListener('abort', cancel);
|
||||||
if (code !== 0 || failed || signal.aborted || !result?.ok) reject(Error('Credential operation cancelled, timed out, denied, mismatched or unavailable. Persistence may have completed; inspect before retry. No reset or plaintext fallback.'));
|
if (code !== 0 || failed || signal.aborted || !result?.ok) reject(plain('Could not complete key access. It may have been cancelled, timed out, denied, or unavailable. The key may not match. Changes may already be saved. Inspect before you try again. No key was reset or saved as plain text.'));
|
||||||
else resolve(result);
|
else resolve(result);
|
||||||
});
|
});
|
||||||
child.send(input, error => { if (error) cancel(); });
|
child.send(input, error => { if (error) cancel(); });
|
||||||
@@ -58,7 +151,7 @@ export class ManagerController {
|
|||||||
private active?: AbortController;
|
private active?: AbortController;
|
||||||
private closed = false;
|
private closed = false;
|
||||||
private lastId = 0;
|
private lastId = 0;
|
||||||
private status = 'Local Host does not require owner sign-in. Quit/sign out never Stop.';
|
private status = 'Local Host does not require owner sign-in. Quitting or signing out does not stop hosts or agents.';
|
||||||
readonly home: string;
|
readonly home: string;
|
||||||
readonly changed: (snapshot: ManagerSnapshot) => void;
|
readonly changed: (snapshot: ManagerSnapshot) => void;
|
||||||
private credential: typeof managerCredential;
|
private credential: typeof managerCredential;
|
||||||
@@ -78,52 +171,51 @@ export class ManagerController {
|
|||||||
Configuration: saved
|
Configuration: saved
|
||||||
Relay: ${identity.pairing.relay}
|
Relay: ${identity.pairing.relay}
|
||||||
Owner: ${short(identity.pairing.owner)}
|
Owner: ${short(identity.pairing.owner)}
|
||||||
Service: not checked by this view
|
Host service: not checked
|
||||||
|
|
||||||
Registration is not Start authority.
|
Host configuration alone does not permit an agent to start.
|
||||||
|
|
||||||
Next steps
|
Open Actions to add an agent from prepared files.
|
||||||
F2: provision from prepared binding/genesis files.
|
Quit before you run:
|
||||||
Quit manager before foreground CLI use:
|
|
||||||
beehive host --owner-present
|
beehive host --owner-present
|
||||||
Service launch and binding authoring forms are unavailable.` });
|
Host start and local setup forms are unavailable.` });
|
||||||
if (existsSync(join(this.hostDirectory, 'setup.json'))) for (const slot of installationPublicSlots(this.hostDirectory)) local.push({ id: slot.agent, label: slot.agent, detail: `Local public slot (not current run status)\nAgent ${slot.agent}\nBindings: ${Object.keys(slot.bindings).join(', ')}` });
|
if (existsSync(join(this.hostDirectory, 'setup.json'))) for (const slot of installationPublicSlots(this.hostDirectory)) local.push({ id: slot.agent, label: slot.agent, detail: `Local agent record. This is not current run status.\nAgent ${slot.agent}\nLocal setups: ${Object.keys(slot.bindings).join(', ')}` });
|
||||||
} else local.push({ id: 'missing', label: 'Configure this computer', detail: 'No local host configuration. Save owner PUBLIC npub and relay; host identity is created in OS credentials. No owner sign-in, agent or service Start.' });
|
} 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: 'Retained configuration needs attention', detail: String(error) + '\nPreserved; no automatic reset.' }); }
|
} 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.' }); }
|
||||||
const agents: ManagerSnapshot['agents'] = [...this.inventory].map(([id, m]) => {
|
const agents: ManagerSnapshot['agents'] = [...this.inventory].map(([id, m]) => {
|
||||||
const fresh = this.fresh(m);
|
const fresh = this.fresh(m);
|
||||||
const blocked = !fresh ? 'Host stale or unreachable. Refresh before acting.' : this.client?.status().some(o => o.request.host === m.host && o.request.agent === m.agent && !['completed','failed'].includes(o.state)) ? 'Unresolved operation: inspect receipts before acting.' : '';
|
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),
|
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 ? 'Requires fresh assigned stopped report without an actual run.' : '') },
|
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.' : '') },
|
||||||
evidence: JSON.stringify(m, null, 2), detail: `Agent ${short(m.agent)}
|
evidence: JSON.stringify(m, null, 2), detail: `Agent ${short(m.agent)}
|
||||||
Host ${short(m.host)}
|
Host ${short(m.host)}
|
||||||
${fresh ? 'Recent host report' : 'UNKNOWN — stale or unreachable; last reported below'}
|
${fresh ? 'Recent host report' : 'Current status unknown. The report is old or the host cannot be reached. Last report:'}
|
||||||
Reported phase: ${m.body.phase}
|
Reported status: ${m.body.phase}
|
||||||
Start authority: ${m.body.assignedHost === m.host ? 'Assigned to this host' : 'Not assigned to this host'}
|
Assigned host: ${m.body.assignedHost === m.host ? 'Assigned to this host' : 'Not assigned to this host'}
|
||||||
|
|
||||||
Actual run (reported)
|
Current run (host report)
|
||||||
${m.body.actualRun ? describe(m.body.actualRun) : 'No actual run in this report'}
|
${m.body.actualRun ? describe(m.body.actualRun, runLabels) : 'No current run in this report'}
|
||||||
|
|
||||||
Selected for next Start
|
Configuration for next start
|
||||||
${describe(m.body.selectedNext)}
|
${describe(m.body.selectedNext, selectionLabels)}
|
||||||
Choosing next does not change the actual run.` };
|
This choice does not change the current run.` };
|
||||||
});
|
});
|
||||||
for (const [id, m] of this.offers) agents.push({ id: `host:${id}`, label: `Host ${short(id)}`, revision: m.revision, configurations: [], evidence: JSON.stringify(m, null, 2), detail: `Host availability report
|
for (const [id, m] of this.offers) agents.push({ id: `host:${id}`, label: `Host ${short(id)}`, revision: m.revision, configurations: [], evidence: JSON.stringify(m, null, 2), detail: `Host report
|
||||||
Host ${short(id)}
|
Host ${short(id)}
|
||||||
${this.fresh(m) ? 'Recent host report' : 'UNKNOWN — stale or unreachable'}
|
${this.fresh(m) ? 'Recent host report' : 'Current status unknown. The report is old or the host cannot be reached.'}
|
||||||
${describe(m.body)}
|
${describe(m.body, availabilityLabels)}
|
||||||
Availability is not agent Start authority.` });
|
A host report does not grant permission to start an agent.` });
|
||||||
for (const operation of this.client?.status() ?? []) agents.push({ id: `operation:${operation.request.id}`, label: `${operation.request.type} · ${operation.state} · ${operation.request.id.slice(0,8)}`, revision: operation.request.revision, configurations: [], evidence: JSON.stringify(operation, null, 2), detail: `Operation: ${operation.request.type}
|
for (const operation of this.client?.status() ?? []) agents.push({ id: `operation:${operation.request.id}`, label: `${plainOperationType(operation.request)} · ${plainStates[operation.state] ?? operation.state} · ${operation.request.id.slice(0,8)}`, revision: operation.request.revision, configurations: [], evidence: JSON.stringify(operation, null, 2), detail: `Operation: ${plainOperationType(operation.request)}
|
||||||
State: ${operation.state}
|
State: ${plainStates[operation.state] ?? operation.state}
|
||||||
Host: ${short(operation.request.host)}
|
Host: ${short(operation.request.host)}
|
||||||
Agent: ${short(operation.request.agent)}
|
Agent: ${short(operation.request.agent)}
|
||||||
Captured revision: ${operation.request.revision}
|
Revision when requested: ${operation.request.revision}
|
||||||
Publication: ${describe(operation.publication)}
|
Relay status: ${operationText(operation.publication)}
|
||||||
Receipt/result: ${describe(operation.result)}
|
${operation.request.type === 'profile' ? 'Result' : 'Host result'}: ${operationText(operation.result)}
|
||||||
|
|
||||||
Publication is not acceptance.
|
Relay publication does not confirm that the host accepted the operation.
|
||||||
Outcome unknown? Check receipts; do not submit again.
|
If the result is unknown, check operation results before you submit again.
|
||||||
A Stop receipt is not a fresh stopped inventory report.` });
|
A Stop result is not a recent host report that confirms the agent is stopped.` });
|
||||||
let routing: ReturnType<typeof readControllerConfig>;
|
let routing: ReturnType<typeof readControllerConfig>;
|
||||||
try { routing = readControllerConfig(this.ownerDirectory); } catch { /* Invalid retained routing is refused by sign-in; never reset here. */ }
|
try { routing = readControllerConfig(this.ownerDirectory); } catch { /* Invalid retained routing is refused by sign-in; never reset here. */ }
|
||||||
return { local, agents, routing: routing ? { owner: routing.owner, relay: routing.relay } : undefined, owner: this.owner, status: this.status };
|
return { local, agents, routing: routing ? { owner: routing.owner, relay: routing.relay } : undefined, owner: this.owner, status: this.status };
|
||||||
@@ -138,25 +230,25 @@ A Stop receipt is not a fresh stopped inventory report.` });
|
|||||||
this.lastId = request.id;
|
this.lastId = request.id;
|
||||||
const abort = new AbortController(); this.active = abort;
|
const abort = new AbortController(); this.active = abort;
|
||||||
const generation = ++this.generation;
|
const generation = ++this.generation;
|
||||||
const check = () => { abort.signal.throwIfAborted(); if (this.closed || generation !== this.generation) throw Error('Cancelled'); };
|
const check = () => { abort.signal.throwIfAborted(); if (this.closed || generation !== this.generation) throw plain('Stopped waiting. Changes may already be saved or submitted. Inspect before you try again.'); };
|
||||||
const v = request.values ?? {};
|
const v = request.values ?? {};
|
||||||
try {
|
try {
|
||||||
if (request.action === 'configure') {
|
if (request.action === 'configure') {
|
||||||
const owner = ownerPublicInput(v.owner ?? '');
|
const owner = ownerPublicInput(v.owner ?? '');
|
||||||
if (!/^(wss|ws):\/\//.test(v.relay ?? '')) throw Error('Management relay URL required');
|
if (!/^(wss|ws):\/\//.test(v.relay ?? '')) throw plain('Enter a relay URL that starts with ws:// or wss://.');
|
||||||
await this.credential({ action: 'configure', directory: this.hostDirectory, label: hostname().slice(0,128), owner, relay: v.relay }, abort.signal);
|
await this.credential({ action: 'configure', directory: this.hostDirectory, label: hostname().slice(0,128), owner, relay: v.relay }, abort.signal);
|
||||||
check(); this.status = 'Configuration saved. This action did not start a service or agent.';
|
check(); this.status = 'Configuration saved. This action did not start a service or agent.';
|
||||||
} else if (request.action === 'provision') {
|
} else if (request.action === 'provision') {
|
||||||
await this.credential({ action: 'provision', directory: this.hostDirectory, binding: v.binding, genesis: v.genesis, secret: v.secret }, abort.signal);
|
await this.credential({ action: 'provision', directory: this.hostDirectory, binding: v.binding, genesis: v.genesis, secret: v.secret }, abort.signal);
|
||||||
check(); this.status = 'Local agent provisioned stopped using existing binding/genesis APIs. No Start or relay publication.';
|
check(); this.status = 'Local agent added and stopped. Nothing was started or published to the relay.';
|
||||||
} else if (request.action === 'signin') {
|
} else if (request.action === 'signin') {
|
||||||
const retained = readControllerConfig(this.ownerDirectory);
|
const retained = readControllerConfig(this.ownerDirectory);
|
||||||
const owner = retained?.owner ?? ownerPublicInput(v.owner ?? '');
|
const owner = retained?.owner ?? ownerPublicInput(v.owner ?? '');
|
||||||
const relay = retained?.relay ?? v.relay ?? '';
|
const relay = retained?.relay ?? v.relay ?? '';
|
||||||
if (!/^(wss|ws):\/\//.test(relay)) throw Error('Management relay URL required');
|
if (!/^(wss|ws):\/\//.test(relay)) throw plain('Enter a relay URL that starts with ws:// or wss://.');
|
||||||
const result = await this.credential({ action: 'signin', owner, ...(v.secret ? { secret: v.secret.toLowerCase() } : {}) }, abort.signal);
|
const result = await this.credential({ action: 'signin', owner, ...(v.secret ? { secret: v.secret.toLowerCase() } : {}) }, abort.signal);
|
||||||
delete v.secret; check();
|
delete v.secret; check();
|
||||||
if (result.secret === null) throw Error('No saved owner key. Use Import owner key with the matching 64-hex private key.');
|
if (result.secret === null) throw plain('No saved owner key. Choose Import matching owner key and sign in. Enter the matching private key with 64 hex characters.');
|
||||||
if (!retained) createControllerConfig(this.ownerDirectory, owner, relay);
|
if (!retained) createControllerConfig(this.ownerDirectory, owner, relay);
|
||||||
this.client?.close(); this.inventory.clear(); this.offers.clear();
|
this.client?.close(); this.inventory.clear(); this.offers.clear();
|
||||||
this.owner = owner;
|
this.owner = owner;
|
||||||
@@ -172,28 +264,28 @@ A Stop receipt is not a fresh stopped inventory report.` });
|
|||||||
}, () => this.refresh(), { catalog: { version: 1, owner, relay, registrations: [] } });
|
}, () => this.refresh(), { catalog: { version: 1, owner, relay, registrations: [] } });
|
||||||
result.secret = undefined; this.client = client;
|
result.secret = undefined; this.client = client;
|
||||||
abort.signal.addEventListener('abort', () => client.close(), { once: true });
|
abort.signal.addEventListener('abort', () => client.close(), { once: true });
|
||||||
await client.ready; check(); this.status = 'Owner signed in. Inventory scope: privately advertising hosts and their agent slots, not independent catalog.';
|
await client.ready; check(); this.status = 'Owner signed in. This list shows private host reports and their agents. It is not a separate agent directory.';
|
||||||
} else if (request.action === 'signout') {
|
} else if (request.action === 'signout') {
|
||||||
this.client?.close(); this.client = undefined; this.owner = undefined; this.inventory.clear(); this.offers.clear();
|
this.client?.close(); this.client = undefined; this.owner = undefined; this.inventory.clear(); this.offers.clear();
|
||||||
this.status = 'Signed out. OS key retained. Hosts and agents were NOT stopped.';
|
this.status = 'Signed out. The owner key is still saved on this computer. Hosts and agents were not stopped.';
|
||||||
} else if (request.action === 'draft') {
|
} else if (request.action === 'draft') {
|
||||||
await editProfileDraft(profileDrafts(this.ownerDirectory), async prompt => prompt.startsWith('Profile') ? v.name ?? '' : v.instructions ?? '');
|
await editProfileDraft(profileDrafts(this.ownerDirectory), async prompt => prompt.startsWith('Profile') ? v.name ?? '' : v.instructions ?? '');
|
||||||
this.status = 'Private instruction draft saved locally. Not published/applied. Resume through beehive drafts ~/.beehive/owner or beehive tui.';
|
this.status = 'Private instructions draft saved on this computer. Not published or used by an agent. To resume, use beehive drafts ~/.beehive/owner or beehive tui.';
|
||||||
} else if (request.action === 'reconcile') { this.client?.reconcile(); this.status = 'Querying receipts; not retrying blocked operations.';
|
} else if (request.action === 'reconcile') { this.client?.reconcile(); this.status = 'Checking operation results. Operations blocked by relay policy will not be retried.';
|
||||||
} else if (request.action === 'operations') {
|
} else if (request.action === 'operations') {
|
||||||
this.status = this.client?.status().map(o => `${o.request.id} · ${o.request.host}/${o.request.agent} · ${o.request.type} · ${o.state}: ${o.result ?? o.publication}`).join('\n') || 'No recorded operations in this session.';
|
this.status = this.client?.status().map(o => `${o.request.id} · ${o.request.host}/${o.request.agent} · ${plainOperationType(o.request)} · ${plainStates[o.state] ?? o.state}: ${operationText(o.result ?? o.publication)}`).join('\n') || 'No saved operations.';
|
||||||
} else if (['start', 'stop', 'restart', 'select-config'].includes(request.action)) {
|
} else if (['start', 'stop', 'restart', 'select-config'].includes(request.action)) {
|
||||||
if (!this.owner || !this.client) throw Error('Owner sign-in required');
|
if (!this.owner || !this.client) throw plain('Owner sign-in required');
|
||||||
const current = this.inventory.get(request.target ?? '');
|
const current = this.inventory.get(request.target ?? '');
|
||||||
if (!current || !this.fresh(current) || current.revision !== request.revision) throw Error('Selection changed or host stale/unreachable. Refresh; no request sent.');
|
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 Error('Restart: Not available in this build. Backend Restart can switch bindings; use explicit Stop, inspect stopped receipt, then Start. No request 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 === 'start' && (current.body.phase !== 'stopped' || current.body.actualRun || current.body.assignedHost !== current.host)) throw Error('Start requires a fresh assigned stopped report without an actual run.');
|
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 Error('Unresolved operation exists. Inspect operations and reconcile before another action.');
|
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', current.host, current.agent, current.revision, request.action === 'select-config' ? { configurationAction: 'select', name: v.name } : {});
|
||||||
this.client.submit(operation);
|
this.client.submit(operation);
|
||||||
this.status = `Operation ${operation.id} · ${current.host}/${current.agent}: durably pending ${request.action}. Publication is not acceptance. Inspect operations; unknown is not stopped.`;
|
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 Error('Not available in this build');
|
} else if (request.action !== 'refresh') throw plain('Not available in this build');
|
||||||
} catch (error) { if (!this.closed) this.status = error instanceof Error ? error.message : 'Operation failed; inspect before retry.'; }
|
} catch (error) { if (!this.closed) this.status = error instanceof PlainStatus ? error.message : abort.signal.aborted && error === abort.signal.reason ? 'Stopped waiting. Changes may already be saved or submitted. Inspect before you try again.' : error instanceof Error ? plainBackendMessage(error.message) : `Could not complete the operation: ${String(error)}`; }
|
||||||
finally { delete v.secret; this.active = undefined; this.refresh(); }
|
finally { delete v.secret; this.active = undefined; this.refresh(); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { homedir } from 'node:os';
|
|||||||
import { ManagerController } from './manager-controller.ts';
|
import { ManagerController } from './manager-controller.ts';
|
||||||
import type { Readable, Writable } from 'node:stream';
|
import type { Readable, Writable } from 'node:stream';
|
||||||
|
|
||||||
if (process.versions.node !== '24.15.0') throw Error('The OpenTUI manager controller requires Node 24.15.0. Use the packaged launcher/runtime.');
|
if (process.versions.node !== '24.15.0') throw Error('Beehive requires Node 24.15.0. Use the packaged Beehive launcher.');
|
||||||
const bun = process.env.BEEHIVE_BUN ?? fileURLToPath(new URL('../runtime/bun', import.meta.url));
|
const bun = process.env.BEEHIVE_BUN ?? fileURLToPath(new URL('../runtime/bun', import.meta.url));
|
||||||
const child = spawn(bun, [fileURLToPath(new URL('./manager-view.ts', import.meta.url))], {
|
const child = spawn(bun, [fileURLToPath(new URL('./manager-view.ts', import.meta.url))], {
|
||||||
stdio: ['inherit', 'inherit', 'inherit', 'pipe', 'pipe'],
|
stdio: ['inherit', 'inherit', 'inherit', 'pipe', 'pipe'],
|
||||||
@@ -28,6 +28,6 @@ input.on('line', line => {
|
|||||||
});
|
});
|
||||||
const refresh = setInterval(() => controller.refresh(), 2000);
|
const refresh = setInterval(() => controller.refresh(), 2000);
|
||||||
child.once('spawn', () => controller.refresh());
|
child.once('spawn', () => controller.refresh());
|
||||||
child.once('error', () => { console.error('Beehive presentation runtime unavailable; install the pinned app-local Bun runtime.'); process.exitCode = 1; });
|
child.once('error', () => { console.error('Beehive could not start its terminal interface. The packaged Bun runtime is unavailable.'); process.exitCode = 1; });
|
||||||
child.once('close', code => { closed = true; clearInterval(refresh); controller.close(); input.close(); process.exitCode = code ?? 1; });
|
child.once('close', code => { closed = true; clearInterval(refresh); controller.close(); input.close(); process.exitCode = code ?? 1; });
|
||||||
for (const signal of ['SIGINT','SIGTERM'] as const) process.on(signal, () => { controller.close(); child.kill(signal); });
|
for (const signal of ['SIGINT','SIGTERM'] as const) process.on(signal, () => { controller.close(); child.kill(signal); });
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import type { ManagerRequest, ManagerSnapshot } from './manager-controller.ts';
|
|||||||
const renderer = await createCliRenderer({ exitOnCtrlC: false });
|
const renderer = await createCliRenderer({ exitOnCtrlC: false });
|
||||||
const screen = new OpenTuiScreen(renderer);
|
const screen = new OpenTuiScreen(renderer);
|
||||||
const output = createWriteStream('', { fd: 4 });
|
const output = createWriteStream('', { fd: 4 });
|
||||||
let snapshot: ManagerSnapshot = { local: [], agents: [], status: 'Connecting Node controller…' };
|
let snapshot: ManagerSnapshot = { local: [], agents: [], status: 'Connecting to Beehive…' };
|
||||||
let scope = 0, selected = '', sequence = 0, lastStatus = '';
|
let scope = 0, selected = '', sequence = 0, lastStatus = '';
|
||||||
let pending: { id: number; resolve: () => void } | undefined;
|
let pending: { id: number; resolve: () => void } | undefined;
|
||||||
function request(action: string, values?: Record<string,string>, target?: string, revision?: number) {
|
function request(action: string, values?: Record<string,string>, target?: string, revision?: number) {
|
||||||
@@ -15,7 +15,7 @@ function request(action: string, values?: Record<string,string>, target?: string
|
|||||||
const id = ++sequence;
|
const id = ++sequence;
|
||||||
const completion = new Promise<void>(resolve => { pending = { id, resolve }; });
|
const completion = new Promise<void>(resolve => { pending = { id, resolve }; });
|
||||||
const message: ManagerRequest = { id, action, values, target, revision };
|
const message: ManagerRequest = { id, action, values, target, revision };
|
||||||
screen.setPending(true); screen.notice(`Working: ${action}… Esc cancels waiting, not submitted remote work or committed OS writes. Inspect before retry.`);
|
screen.setPending(true); screen.notice('Working… Esc stops waiting. Remote work and saved changes are not cancelled. Inspect before you try again.');
|
||||||
output.write(JSON.stringify(message) + '\n');
|
output.write(JSON.stringify(message) + '\n');
|
||||||
return completion;
|
return completion;
|
||||||
}
|
}
|
||||||
@@ -25,8 +25,8 @@ async function routing(action: string, secret = false) {
|
|||||||
if (!retained) {
|
if (!retained) {
|
||||||
let step = 0;
|
let step = 0;
|
||||||
while (step < 2) {
|
while (step < 2) {
|
||||||
const label = step === 0 ? `${action === 'configure' ? 'Configure this computer' : 'Owner sign-in'} · 1 of 3\nOwner PUBLIC npub/hex (required)` : 'Configure routing · 2 of 3\nManagement relay URL (ws:// or wss://)';
|
const label = step === 0 ? `${action === 'configure' ? 'Configure this computer' : 'Sign in as owner'} · 1 of 3\nOwner public key (npub or 64 hex characters). Do not enter a private key.` : 'Relay · 2 of 3\nRelay URL (ws:// or wss://)';
|
||||||
const answer = await screen.input(label, step === 0 ? owner : relay, false, false, value => step === 0 ? /^(?:[0-9a-fA-F]{64}|npub1[023456789acdefghjklmnpqrstuvwxyz]{58})$/.test(value.trim()) ? '' : 'Enter a public 64-hex key or npub.' : /^wss?:\/\//.test(value) ? '' : 'Enter a ws:// or wss:// relay URL.', step === 1 ? value => { relay = value; } : undefined);
|
const answer = await screen.input(label, step === 0 ? owner : relay, false, false, value => step === 0 ? /^(?:[0-9a-fA-F]{64}|npub1[023456789acdefghjklmnpqrstuvwxyz]{58})$/.test(value.trim()) ? '' : 'Enter an npub or a public key with 64 hex characters.' : /^wss?:\/\//.test(value) ? '' : 'Enter a ws:// or wss:// relay URL.', step === 1 ? value => { relay = value; } : undefined);
|
||||||
if (answer === undefined) return;
|
if (answer === undefined) return;
|
||||||
if (answer === '\0back') { step = 0; continue; }
|
if (answer === '\0back') { step = 0; continue; }
|
||||||
if (step === 0) owner = answer; else relay = answer;
|
if (step === 0) owner = answer; else relay = answer;
|
||||||
@@ -35,18 +35,18 @@ async function routing(action: string, secret = false) {
|
|||||||
}
|
}
|
||||||
const values: Record<string,string> = { owner, relay };
|
const values: Record<string,string> = { owner, relay };
|
||||||
if (secret) {
|
if (secret) {
|
||||||
const key = await screen.input('Matching owner private key, 64 hex. Saved only in this computer’s OS credentials. Never sent to a host.', '', true);
|
const key = await screen.input('Owner private key (64 hex characters). It must match the owner public key. Saved in this computer’s secure credential store. Never sent to a host.', '', true);
|
||||||
if (key === undefined) return;
|
if (key === undefined) return;
|
||||||
values.secret = key;
|
values.secret = key;
|
||||||
}
|
}
|
||||||
if (!await screen.confirm(action === 'configure' ? `Configure this computer · 3 of 3\nOwner: ${owner}\nRelay: ${relay}\nCreate host identity in OS credentials? No agent or Start.` : `Owner: ${owner}\nRelay: ${relay}\n${retained ? 'Retained routing (read-only). ' : ''}Access owner OS credential? Permission may prompt. Matching import only.`)) { delete values.secret; return; }
|
if (!await screen.confirm(action === 'configure' ? `Configure this computer · 3 of 3\nOwner: ${owner}\nRelay: ${relay}\nCreate a host identity in this computer’s secure credential store? This does not create an agent or start a host.` : `Owner: ${owner}\nRelay: ${relay}\n${retained ? 'Saved owner and relay cannot change here.\n' : ''}Access the owner key in this computer’s secure credential store? The system may ask for permission. An imported key must match the owner.`)) { delete values.secret; return; }
|
||||||
await request(action, values); delete values.secret;
|
await request(action, values); delete values.secret;
|
||||||
}
|
}
|
||||||
function eligibility(action: string) {
|
function eligibility(action: string) {
|
||||||
const row = snapshot.agents.find(r => r.id === selected);
|
const row = snapshot.agents.find(r => r.id === selected);
|
||||||
if (!snapshot.owner) return 'Owner sign-in required.';
|
if (!snapshot.owner) return 'Owner sign-in required.';
|
||||||
if (!row?.disabled) return 'Select an agent observation.';
|
if (!row?.disabled) return 'Select an agent.';
|
||||||
return row.disabled[action] || (action === 'select-config' && !row.configurations.length ? 'No existing configurations reported.' : undefined);
|
return row.disabled[action] || (action === 'select-config' && !row.configurations.length ? 'The host reported no saved configurations.' : undefined);
|
||||||
}
|
}
|
||||||
function render() {
|
function render() {
|
||||||
screen.setOwner(snapshot.owner?.slice(-12));
|
screen.setOwner(snapshot.owner?.slice(-12));
|
||||||
@@ -55,44 +55,44 @@ function render() {
|
|||||||
if (!selected || (scope === 0 && selected === 'missing' && rows.some(r => r.id === 'host'))) selected = rows[0]?.id ?? '';
|
if (!selected || (scope === 0 && selected === 'missing' && rows.some(r => r.id === 'host'))) selected = rows[0]?.id ?? '';
|
||||||
const vanished = !!selected && !rows.some(row => row.id === selected);
|
const vanished = !!selected && !rows.some(row => row.id === selected);
|
||||||
const actions: ManagerAction[] = scope === 0 ? [
|
const actions: ManagerAction[] = scope === 0 ? [
|
||||||
snapshot.local.some(r => r.id === 'host') ? { label: 'Host configuration saved — inspect', run: () => screen.inspect() } : { label: 'Configure this computer', disabled: snapshot.local.some(r => r.id === 'error') ? 'Retained configuration needs attention — preserved, not reset.' : undefined, run: () => routing('configure') },
|
snapshot.local.some(r => r.id === 'host') ? { label: 'Inspect host configuration', run: () => screen.inspect() } : { label: 'Configure this computer', disabled: snapshot.local.some(r => r.id === 'error') ? 'Saved configuration needs attention. It has not been reset.' : undefined, run: () => routing('configure') },
|
||||||
{ label: 'Provision from prepared binding/genesis files', disabled: snapshot.local.some(r => r.id === 'host') ? undefined : 'Configure this computer first.', run: async () => {
|
{ label: 'Add agent from prepared files', disabled: snapshot.local.some(r => r.id === 'host') ? undefined : 'Configure this computer first.', run: async () => {
|
||||||
const binding = await screen.input('Absolute existing local binding JSON path (no identity fields)'); if (binding === undefined) return;
|
const binding = await screen.input('Local setup file (binding JSON). Enter its full path. The file must not contain host or agent identity fields.'); if (binding === undefined) return;
|
||||||
const genesis = await screen.input('Absolute owner-authorized agent genesis JSON path'); if (genesis === undefined) return;
|
const genesis = await screen.input('Agent authorization file (genesis JSON). Enter its full path. The file must be authorized by the owner.'); if (genesis === undefined) return;
|
||||||
const secret = await screen.input('Matching AGENT private key, 64 hex. Stored in host OS credentials, not the owner key.', '', true); if (secret === undefined) return;
|
const secret = await screen.input('Agent private key (64 hex characters). It must match the agent. Saved in this host’s secure credential store. Do not enter the owner key.', '', true); if (secret === undefined) return;
|
||||||
if (!await screen.confirm('Provision STOPPED agent into this fresh local installation? Existing or partial installations refuse reset. OS access may prompt.')) return;
|
if (!await screen.confirm('Add a stopped agent to this new local installation? Existing or incomplete installations cannot be reset here. The system may ask for permission.')) return;
|
||||||
await request('provision', { binding, genesis, secret });
|
await request('provision', { binding, genesis, secret });
|
||||||
} },
|
} },
|
||||||
{ label: 'Refresh local inspection', run: () => request('refresh') },
|
{ label: 'Refresh local details', run: () => request('refresh') },
|
||||||
{ label: 'Save private instructions draft', run: async () => {
|
{ label: 'New private instructions draft…', run: async () => {
|
||||||
const name = await screen.input('New private instruction draft · 1 of 2\nProfile name', '', false, false, v => v.trim() ? '' : 'A name is required.'); if (name === undefined) return;
|
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(`Draft: ${name}\nLocal only — not published or applied. Private instructions (not credentials)`, '', false, true, v => v.trim() ? '' : 'Instructions are required.'); if (instructions === 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) return;
|
||||||
await request('draft', { name, instructions });
|
await request('draft', { name, instructions });
|
||||||
} },
|
} },
|
||||||
{ label: 'Provisioning / service commands', run: () => screen.notice('Forms: Not available in this build. Existing CLI: beehive local-setup ~/.beehive/host; beehive provision-agent; beehive host --owner-present. Quit this manager before foreground CLI use.') },
|
{ label: 'Host setup commands', run: () => screen.notice('These forms are unavailable. Use the CLI:\nbeehive local-setup ~/.beehive/host\nbeehive provision-agent\nbeehive host --owner-present\nQuit Beehive before you use a foreground CLI command.') },
|
||||||
] : snapshot.owner ? [
|
] : snapshot.owner ? [
|
||||||
{ label: 'Choose next configuration…', disabled: eligibility('select-config'), run: async () => {
|
{ label: 'Choose configuration…', disabled: eligibility('select-config'), run: async () => {
|
||||||
const row = snapshot.agents.find(r => r.id === selected); if (!row) return;
|
const row = snapshot.agents.find(r => r.id === selected); if (!row) return;
|
||||||
const name = await screen.choose('Choose existing next configuration', row.configurations); if (name === undefined) return;
|
const name = await screen.choose('Configuration for next start', row.configurations); if (name === undefined) return;
|
||||||
if (!await screen.confirm(`Select ${name} for exact host/agent ${row.id} at revision ${row.revision}? Actual run unchanged.`)) return;
|
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);
|
await request('select-config', { name }, row.id, row.revision);
|
||||||
} },
|
} },
|
||||||
...(['start','stop','restart'] as const).map(action => ({ label: action === 'restart' ? 'Restart — not available (binding safety)' : `${action === 'start' ? 'Start' : 'Stop'} selected agent`, disabled: action === 'restart' ? 'Backend Restart may switch bindings. Use Stop, inspect accepted stopped report, then Start. No Restart request is sent.' : eligibility(action), run: async () => {
|
...(['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 () => {
|
||||||
const row = snapshot.agents.find(r => r.id === selected); if (!row) return;
|
const row = snapshot.agents.find(r => r.id === selected); if (!row) return;
|
||||||
if (!await screen.confirm(`${action.toUpperCase()} exact agent/host ${row.id} at revision ${row.revision}?`)) return;
|
if (!await screen.confirm(`${action === 'start' ? 'Start' : 'Stop'} this agent?\nHost and agent: ${row.id}\nRevision: ${row.revision}`)) return;
|
||||||
await request(action, undefined, row.id, row.revision);
|
await request(action, undefined, row.id, row.revision);
|
||||||
} })),
|
} })),
|
||||||
{ label: 'Inspect operation receipts', run: () => request('operations') },
|
{ label: 'Inspect operations', run: () => request('operations') },
|
||||||
{ label: 'Reconcile (not retry)', run: () => request('reconcile') },
|
{ label: 'Check operation results', run: () => request('reconcile') },
|
||||||
{ label: 'Sign out — not Stop', run: () => request('signout') },
|
{ label: 'Sign out', run: () => request('signout') },
|
||||||
{ label: 'Public metadata / profile publication', run: () => screen.notice('Publication forms: Not available in this build. Existing CLI: beehive drafts ~/.beehive/owner; beehive tui discover <relay> ~/.beehive/owner. Generated keys and independent agent catalog are not implemented.') },
|
{ label: 'Publish profile or instructions', run: () => screen.notice('Publication forms are unavailable. Use the CLI:\nbeehive drafts ~/.beehive/owner\nbeehive tui discover <relay> ~/.beehive/owner\nThis build cannot generate owner keys or list agents independently of hosts.') },
|
||||||
] : [
|
] : [
|
||||||
{ label: 'Sign in with saved owner key', run: () => routing('signin') },
|
{ label: 'Sign in with saved owner key', run: () => routing('signin') },
|
||||||
{ label: 'Import matching owner key and sign in', run: () => routing('signin', true) },
|
{ label: 'Import matching owner key and sign in', run: () => routing('signin', true) },
|
||||||
];
|
];
|
||||||
if (scope === 1 && !snapshot.owner) actions.push({ label: 'New private instruction draft…', run: async () => { const name = await screen.input('New private instruction draft · 1 of 2\nName', '', false, false, v => v.trim() ? '' : 'Name required.'); if (name === undefined) return; const instructions = await screen.input(`Draft: ${name}\nLocal only — not published or applied`, '', false, true, v => v.trim() ? '' : 'Instructions required.'); if (instructions !== undefined) await request('draft', { name, instructions }); } });
|
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 }); } });
|
||||||
actions.push({ label: 'Quit manager (not Stop)', run: () => screen.close() });
|
actions.push({ label: 'Quit Beehive', run: () => screen.close() });
|
||||||
screen.show(vanished ? [{ id: selected, label: 'Selection no longer available', detail: 'Selection no longer available. Choose a current observation; no operation is targeted at a replacement row.' }, ...rows] : scope === 0 ? snapshot.local : snapshot.agents.length ? snapshot.agents : [{ id: 'empty', label: snapshot.owner ? 'No host observations yet' : 'Owner sign-in required', detail: 'Agents management is owner-authorized. Inventory comes from privately advertising hosts, NOT an independent relay agent catalog. Empty/offline observations do not prove agents stopped. Local Host and offline drafts do not require sign-in.' }], actions, selected);
|
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 ? snapshot.local : 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);
|
||||||
if (lastStatus !== snapshot.status) { lastStatus = snapshot.status; screen.notice(snapshot.status); }
|
if (lastStatus !== snapshot.status) { lastStatus = snapshot.status; screen.notice(snapshot.status); }
|
||||||
}
|
}
|
||||||
screen.onScope = value => { scope = value; selected = ''; render(); };
|
screen.onScope = value => { scope = value; selected = ''; render(); };
|
||||||
@@ -103,7 +103,7 @@ input.on('line', line => {
|
|||||||
const value = JSON.parse(line);
|
const value = JSON.parse(line);
|
||||||
if (value.snapshot) { snapshot = value.snapshot; render(); }
|
if (value.snapshot) { snapshot = value.snapshot; render(); }
|
||||||
if (value.complete === pending?.id) { const old = pending; pending = undefined; screen.setPending(false); old?.resolve(); }
|
if (value.complete === pending?.id) { const old = pending; pending = undefined; screen.setPending(false); old?.resolve(); }
|
||||||
} catch { screen.notice('Controller response unavailable. Quit and inspect retained state.'); }
|
} catch { screen.notice('Beehive could not read the response. Quit and inspect saved operation records before you try again.'); }
|
||||||
});
|
});
|
||||||
input.on('close', () => screen.close());
|
input.on('close', () => screen.close());
|
||||||
screen.onCancel = () => { if (pending) output.write(JSON.stringify({ cancel: pending.id }) + '\n'); };
|
screen.onCancel = () => { if (pending) output.write(JSON.stringify({ cancel: pending.id }) + '\n'); };
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export class OpenTuiScreen {
|
|||||||
private readonly scroll: ScrollBoxRenderable;
|
private readonly scroll: ScrollBoxRenderable;
|
||||||
private readonly actions: SelectRenderable;
|
private readonly actions: SelectRenderable;
|
||||||
private readonly status: TextRenderable;
|
private readonly status: TextRenderable;
|
||||||
|
private readonly footer: TextRenderable;
|
||||||
private readonly small: TextRenderable;
|
private readonly small: TextRenderable;
|
||||||
private rows: ManagerRow[] = [];
|
private rows: ManagerRow[] = [];
|
||||||
private commands: ManagerAction[] = [];
|
private commands: ManagerAction[] = [];
|
||||||
@@ -33,6 +34,11 @@ export class OpenTuiScreen {
|
|||||||
onScope: (index: number) => void = () => {};
|
onScope: (index: number) => void = () => {};
|
||||||
onSelect: (id: string) => void = () => {};
|
onSelect: (id: string) => void = () => {};
|
||||||
|
|
||||||
|
/** Persistent ordinary-key legend, separate from status. Never function-key first.
|
||||||
|
* The compact form keeps every affordance named at the 40-column narrow floor. */
|
||||||
|
private static readonly hintsWide = '? Help · a Actions · i Inspect · o Status · q Quit';
|
||||||
|
private static readonly hintsNarrow = '? a Actions i Inspect o Status q Quit';
|
||||||
|
|
||||||
constructor(readonly renderer: CliRenderer) {
|
constructor(readonly renderer: CliRenderer) {
|
||||||
this.root = new BoxRenderable(renderer, { width: '100%', height: '100%', flexDirection: 'column', backgroundColor: bg });
|
this.root = new BoxRenderable(renderer, { width: '100%', height: '100%', flexDirection: 'column', backgroundColor: bg });
|
||||||
renderer.root.add(this.root);
|
renderer.root.add(this.root);
|
||||||
@@ -47,19 +53,20 @@ export class OpenTuiScreen {
|
|||||||
// Pinned OpenTUI does not expose its scrolling offset. Never map a click
|
// Pinned OpenTUI does not expose its scrolling offset. Never map a click
|
||||||
// to an unrelated row; keyboard navigation remains available for long lists.
|
// to an unrelated row; keyboard navigation remains available for long lists.
|
||||||
if (this.rows.length <= this.list.height && delta >= 0) this.list.setSelectedIndex(Math.min(this.rows.length - 1, delta));
|
if (this.rows.length <= this.list.height && delta >= 0) this.list.setSelectedIndex(Math.min(this.rows.length - 1, delta));
|
||||||
else this.notice('Scrolled list: use arrows to select the exact row. Mouse selection is not available in this build.');
|
else this.notice('Use arrows to select an item in this list. Mouse selection is unavailable.');
|
||||||
} });
|
} });
|
||||||
this.listFrame.add(this.list);
|
this.listFrame.add(this.list);
|
||||||
this.scroll = new ScrollBoxRenderable(renderer, { flexGrow: 1, height: '100%', border: true, title: 'Details', scrollY: true, onMouseDown: () => { this.focusIndex = 1; this.scroll.focus(); } });
|
this.scroll = new ScrollBoxRenderable(renderer, { flexGrow: 1, height: '100%', border: true, title: 'Details', scrollY: true, onMouseDown: () => { this.focusIndex = 1; this.scroll.focus(); } });
|
||||||
body.add(this.scroll);
|
body.add(this.scroll);
|
||||||
this.detail = new TextRenderable(renderer, { fg, width: '100%', content: 'No items.', selectable: false });
|
this.detail = new TextRenderable(renderer, { fg, width: '100%', content: 'No items.', selectable: false });
|
||||||
this.scroll.add(this.detail);
|
this.scroll.add(this.detail);
|
||||||
this.actions = new SelectRenderable(renderer, { backgroundColor: bg, textColor: fg, focusedBackgroundColor: bg, focusedTextColor: fg, selectedBackgroundColor: fg, selectedTextColor: bg, height: 2, showDescription: false, showSelectionIndicator: true, options: [{name: 'Actions (F2)', description: ''}, {name: 'Inspect (F3)', description: ''}], onMouseDown: event => { this.focusIndex = 2; this.actions.focus(); this.actions.setSelectedIndex(Math.max(0, Math.min(1, event.y - this.actions.y))); this.actions.selectCurrent(); } });
|
this.actions = new SelectRenderable(renderer, { backgroundColor: bg, textColor: fg, focusedBackgroundColor: bg, focusedTextColor: fg, selectedBackgroundColor: fg, selectedTextColor: bg, height: 2, showDescription: false, showSelectionIndicator: true, options: [{name: 'Actions (a)', description: ''}, {name: 'Inspect (i)', description: ''}], onMouseDown: event => { this.focusIndex = 2; this.actions.focus(); this.actions.setSelectedIndex(Math.max(0, Math.min(1, event.y - this.actions.y))); this.actions.selectCurrent(); } });
|
||||||
this.root.add(this.actions);
|
this.root.add(this.actions);
|
||||||
this.status = new TextRenderable(renderer, { fg, height: 2, onMouseDown: () => this.outcome() });
|
this.status = new TextRenderable(renderer, { fg, height: 2, onMouseDown: () => this.outcome() });
|
||||||
this.root.add(this.status);
|
this.root.add(this.status);
|
||||||
this.root.add(new TextRenderable(renderer, { fg, height: 1, content: 'F1 Keys F2 Actions F3 Inspect ^Q Quit' }));
|
this.footer = new TextRenderable(renderer, { fg, height: 1, content: OpenTuiScreen.hintsWide });
|
||||||
this.small = new TextRenderable(renderer, { fg, position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', bg, content: 'Terminal too small. Resize to 40 × 16. Nothing submitted. Ctrl-Q quits.', visible: false });
|
this.root.add(this.footer);
|
||||||
|
this.small = new TextRenderable(renderer, { fg, position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', bg, content: 'Resize to at least 40 × 16. Ctrl-Q quits. Existing operations can continue.', visible: false });
|
||||||
renderer.root.add(this.small);
|
renderer.root.add(this.small);
|
||||||
this.list.on('selectionChanged', (index: number) => {
|
this.list.on('selectionChanged', (index: number) => {
|
||||||
const row = this.rows[index]; this.detail.content = clean(row?.detail ?? 'No items.'); this.scroll.scrollTo(0);
|
const row = this.rows[index]; this.detail.content = clean(row?.detail ?? 'No items.'); this.scroll.scrollTo(0);
|
||||||
@@ -98,6 +105,7 @@ export class OpenTuiScreen {
|
|||||||
this.scroll.visible = !this.narrow || this.drilled;
|
this.scroll.visible = !this.narrow || this.drilled;
|
||||||
this.listFrame.width = this.narrow ? '100%' : Math.min(32, Math.max(24, Math.floor(this.renderer.width * .28)));
|
this.listFrame.width = this.narrow ? '100%' : Math.min(32, Math.max(24, Math.floor(this.renderer.width * .28)));
|
||||||
this.scroll.title = this.narrow ? '[Details] · Esc back' : 'Details';
|
this.scroll.title = this.narrow ? '[Details] · Esc back' : 'Details';
|
||||||
|
this.footer.content = this.renderer.width >= 52 ? OpenTuiScreen.hintsWide : OpenTuiScreen.hintsNarrow;
|
||||||
}
|
}
|
||||||
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 = `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)}`; }
|
||||||
setOwner(publicSuffix?: string) { this.owner = publicSuffix ? `${publicSuffix} · signed in · key saved here` : 'signed out'; this.heading(); }
|
setOwner(publicSuffix?: string) { this.owner = publicSuffix ? `${publicSuffix} · signed in · key saved here` : 'signed out'; this.heading(); }
|
||||||
@@ -111,21 +119,31 @@ export class OpenTuiScreen {
|
|||||||
if (key.name === 'escape' && this.pending) { key.preventDefault(); this.onCancel(); return; }
|
if (key.name === 'escape' && this.pending) { key.preventDefault(); this.onCancel(); return; }
|
||||||
if (this.modal) { this.modal.key(key); return; }
|
if (this.modal) { this.modal.key(key); return; }
|
||||||
if (key.name === 'escape' && this.drilled) { key.preventDefault(); this.drilled = false; this.resize(); this.focusIndex = 0; this.list.focus(); }
|
if (key.name === 'escape' && this.drilled) { key.preventDefault(); this.drilled = false; this.resize(); this.focusIndex = 0; this.list.focus(); }
|
||||||
|
// Ordinary keys are the primary map. Every editable field is a modal handled
|
||||||
|
// above, so these never intercept text in public, secret or multiline inputs.
|
||||||
|
if (!key.ctrl && !key.meta) {
|
||||||
|
if (key.name === '?') { key.preventDefault(); this.help(); return; }
|
||||||
|
if (key.name === 'a') { key.preventDefault(); this.drawer(); return; }
|
||||||
|
if (key.name === 'i') { key.preventDefault(); this.inspect(); return; }
|
||||||
|
if (key.name === 'o') { key.preventDefault(); this.outcome(); return; }
|
||||||
|
if (key.name === 'q') { key.preventDefault(); this.close(); return; }
|
||||||
|
}
|
||||||
|
// Legacy compatibility aliases only; the advertised map above uses ordinary keys.
|
||||||
if (key.name === 'f2') { key.preventDefault(); this.drawer(); return; }
|
if (key.name === 'f2') { key.preventDefault(); this.drawer(); return; }
|
||||||
if (key.name === 'f3') { key.preventDefault(); this.inspect(); return; }
|
if (key.name === 'f3') { key.preventDefault(); this.inspect(); return; }
|
||||||
if (key.name === 'f4') { key.preventDefault(); this.outcome(); 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 (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(1 - this.scope); }
|
||||||
if (key.name === 'tab') { key.preventDefault(); this.focusIndex = (this.focusIndex + (key.shift ? 2 : 1)) % 3; if (this.narrow && this.focusIndex === (this.drilled ? 0 : 1)) this.focusIndex = (this.focusIndex + (key.shift ? 2 : 1)) % 3; [this.list, this.scroll, this.actions][this.focusIndex]!.focus(); this.focusStyle(); }
|
if (key.name === 'tab') { key.preventDefault(); this.focusIndex = (this.focusIndex + (key.shift ? 2 : 1)) % 3; if (this.narrow && this.focusIndex === (this.drilled ? 0 : 1)) this.focusIndex = (this.focusIndex + (key.shift ? 2 : 1)) % 3; [this.list, this.scroll, this.actions][this.focusIndex]!.focus(); this.focusStyle(); }
|
||||||
if (key.name === 'f1') { key.preventDefault(); this.read('Help', 'F2 Actions · F3 public Inspect · F4 full Outcome · Left/Right: scope · Tab/Shift-Tab: panes · arrows/PgDn/Home/End/wheel: navigate · Enter: open detail/action · Esc: cancel pending first, then dismiss/back · Ctrl-Q: quit, never Stop. Scrolled list/action mouse selection unavailable: use keyboard. No new provider, catalog, publication or Restart capability. Quit before foreground CLI use.'); }
|
if (key.name === 'f1') { key.preventDefault(); this.help(); }
|
||||||
}
|
}
|
||||||
private async run(index: number, captured?: ManagerAction) {
|
private async run(index: number, captured?: ManagerAction) {
|
||||||
const action = captured ?? this.commands[index];
|
const action = captured ?? this.commands[index];
|
||||||
if (!action || this.pending || this.busy || this.modal || this.small.visible || this.closed) return;
|
if (!action || this.pending || this.busy || this.modal || this.small.visible || this.closed) return;
|
||||||
if (action.disabled) { this.notice(action.disabled); return; }
|
if (action.disabled) { this.notice(action.disabled); return; }
|
||||||
this.busy = true; this.notice('Working… Esc cancels an open form. Quit is not Stop.');
|
this.busy = true; this.notice('Working… Esc cancels an open form. Quitting does not stop agents.');
|
||||||
try { await action.run(); }
|
try { await action.run(); }
|
||||||
catch { this.notice('Action failed. State may be unchanged or outcome unknown; inspect the operation before retrying.'); }
|
catch { this.notice('Action failed. The result is unknown. Inspect the operation before you try again.'); }
|
||||||
finally { this.busy = false; }
|
finally { this.busy = false; }
|
||||||
}
|
}
|
||||||
show(rows: ManagerRow[], actions: ManagerAction[], selected?: string) {
|
show(rows: ManagerRow[], actions: ManagerAction[], selected?: string) {
|
||||||
@@ -139,7 +157,7 @@ export class OpenTuiScreen {
|
|||||||
|
|
||||||
}
|
}
|
||||||
notice(text: string) { if (!this.closed) { this.lastNotice = clean(text); this.status.content = this.lastNotice; } }
|
notice(text: string) { if (!this.closed) { this.lastNotice = clean(text); this.status.content = this.lastNotice; } }
|
||||||
unavailable(feature: string) { this.notice(`${feature}: Not available in this build. No request made. Local Host and existing host observations remain separate.`); }
|
unavailable(feature: string) { this.notice(`${feature} is unavailable in this build. No request was sent. Local Host shows this computer. Agents shows host reports.`); }
|
||||||
|
|
||||||
choose(title: string, names: string[]): Promise<string | undefined> {
|
choose(title: string, names: string[]): Promise<string | undefined> {
|
||||||
if (this.modal || !names.length) return Promise.resolve(undefined);
|
if (this.modal || !names.length) return Promise.resolve(undefined);
|
||||||
@@ -156,8 +174,9 @@ export class OpenTuiScreen {
|
|||||||
const n = list.options.length, current = list.getSelectedIndex();
|
const n = list.options.length, current = list.getSelectedIndex();
|
||||||
list.setSelectedIndex(Math.max(0, Math.min(n - 1, key === 'home' ? 0 : key === 'end' ? n - 1 : current + (key === 'pageup' ? -1 : 1) * Math.max(1, list.height))));
|
list.setSelectedIndex(Math.max(0, Math.min(n - 1, key === 'home' ? 0 : key === 'end' ? n - 1 : current + (key === 'pageup' ? -1 : 1) * Math.max(1, list.height))));
|
||||||
}
|
}
|
||||||
inspect() { const row = this.rows[this.list.getSelectedIndex()]; this.read('Technical details · public evidence', row?.evidence ?? 'Inspect unavailable: no technical evidence for this row.'); }
|
inspect() { const row = this.rows[this.list.getSelectedIndex()]; this.read('Technical details', row?.evidence ?? 'No technical details for this item.'); }
|
||||||
private outcome() { this.read('Outcome · current attempt', this.lastNotice || 'No outcome yet.'); }
|
private outcome() { this.read('Status · latest message', this.lastNotice || 'No status message yet.'); }
|
||||||
|
private help() { this.read('Help', '? Help · a Actions · i Inspect · o Status · q Quit\nLeft/Right: switch Local Host and Agents.\nTab/Shift-Tab: switch panes.\nArrows, PgUp/PgDn, Home/End or wheel: scroll.\nEnter: open details or an action.\nEsc: cancel waiting first, then close or go back.\nq or Ctrl-Q: quit. Hosts and agents keep running.\nUse arrows and Enter in scrolled lists. Mouse selection is unavailable.\nShortcuts do not run while you edit a field.\nQuit before you use a foreground CLI command.\nRestart and publication forms are unavailable.'); }
|
||||||
private read(title: string, content: string) {
|
private read(title: string, content: string) {
|
||||||
if (this.modal || this.closed) return;
|
if (this.modal || this.closed) return;
|
||||||
const box = new ScrollBoxRenderable(this.renderer, { position: 'absolute', top: 2, left: Math.floor((this.renderer.width - Math.min(72, this.renderer.width - 4)) / 2), width: Math.min(72, this.renderer.width - 4), height: this.renderer.height - 4, border: true, title: title + ' · Esc close', backgroundColor: bg, scrollY: true });
|
const box = new ScrollBoxRenderable(this.renderer, { position: 'absolute', top: 2, left: Math.floor((this.renderer.width - Math.min(72, this.renderer.width - 4)) / 2), width: Math.min(72, this.renderer.width - 4), height: this.renderer.height - 4, border: true, title: title + ' · Esc close', backgroundColor: bg, scrollY: true });
|
||||||
@@ -172,9 +191,9 @@ export class OpenTuiScreen {
|
|||||||
this.overlay = box; this.compactOverlay = false; this.renderer.root.add(box);
|
this.overlay = box; this.compactOverlay = false; this.renderer.root.add(box);
|
||||||
box.add(new TextRenderable(this.renderer, { fg, content: clean(this.rows[this.list.getSelectedIndex()]?.label ?? 'No selection'), height: 2 }));
|
box.add(new TextRenderable(this.renderer, { fg, content: clean(this.rows[this.list.getSelectedIndex()]?.label ?? 'No selection'), height: 2 }));
|
||||||
const reason = new TextRenderable(this.renderer, { fg, height: 4 });
|
const reason = new TextRenderable(this.renderer, { fg, height: 4 });
|
||||||
const list = new SelectRenderable(this.renderer, { backgroundColor: bg, textColor: fg, focusedBackgroundColor: bg, focusedTextColor: fg, selectedBackgroundColor: fg, selectedTextColor: bg, flexGrow: 1, showDescription: false, showSelectionIndicator: true, showScrollIndicator: true, options: commands.map(a => ({ name: clean((a.disabled || this.pending ? '(unavailable) ' : '') + a.label), description: '' })), onMouseScroll: event => { if (event.scroll?.direction === 'up') list.moveUp(); else if (event.scroll?.direction === 'down') list.moveDown(); }, onMouseDown: () => this.notice('Actions: use arrows then Enter. Mouse activation is unavailable; no request sent.') });
|
const list = new SelectRenderable(this.renderer, { backgroundColor: bg, textColor: fg, focusedBackgroundColor: bg, focusedTextColor: fg, selectedBackgroundColor: fg, selectedTextColor: bg, flexGrow: 1, showDescription: false, showSelectionIndicator: true, showScrollIndicator: true, options: commands.map(a => ({ name: clean((a.disabled || this.pending ? '(unavailable) ' : '') + a.label), description: '' })), onMouseScroll: event => { if (event.scroll?.direction === 'up') list.moveUp(); else if (event.scroll?.direction === 'down') list.moveDown(); }, onMouseDown: () => this.notice('Use arrows, then Enter. Mouse selection is unavailable. No request was sent.') });
|
||||||
box.add(list); box.add(reason); list.focus();
|
box.add(list); box.add(reason); list.focus();
|
||||||
const explain = () => { reason.content = this.pending ? 'Working… Esc cancels waiting. Submitted remote work or OS writes may already have completed; inspect before retry.' : clean(commands[list.getSelectedIndex()]?.disabled || 'Enter opens action · Esc closes · arrows / PgDn scroll'); };
|
const explain = () => { reason.content = this.pending ? 'Working… Esc stops waiting. Remote work or saved changes may already be complete. Inspect before you try again.' : clean(commands[list.getSelectedIndex()]?.disabled || 'Enter opens · Esc closes · arrows/PgDn scroll'); };
|
||||||
explain(); list.on('selectionChanged', explain);
|
explain(); list.on('selectionChanged', explain);
|
||||||
const cancel = () => { this.modal = undefined; box.destroyRecursively(); [this.list, this.scroll, this.actions][this.focusIndex]!.focus(); };
|
const cancel = () => { this.modal = undefined; box.destroyRecursively(); [this.list, this.scroll, this.actions][this.focusIndex]!.focus(); };
|
||||||
list.on('itemSelected', (i: number) => { if (this.pending || commands[i]?.disabled) { explain(); return; } cancel(); void this.run(i, commands[i]); });
|
list.on('itemSelected', (i: number) => { if (this.pending || commands[i]?.disabled) { explain(); return; } cancel(); void this.run(i, commands[i]); });
|
||||||
@@ -196,8 +215,8 @@ export class OpenTuiScreen {
|
|||||||
? new TextareaRenderable(this.renderer, { initialValue: value, flexGrow: 1, width: '100%' })
|
? new TextareaRenderable(this.renderer, { initialValue: value, flexGrow: 1, width: '100%' })
|
||||||
: new InputRenderable(this.renderer, { value, maxLength: 8192, width: '100%', backgroundColor: fg, textColor: bg, focusedBackgroundColor: fg, focusedTextColor: bg });
|
: new InputRenderable(this.renderer, { value, maxLength: 8192, width: '100%', backgroundColor: fg, textColor: bg, focusedBackgroundColor: fg, focusedTextColor: bg });
|
||||||
if (entry) { const field = new BoxRenderable(this.renderer, { border: !multiline, height: multiline ? undefined : 3, flexShrink: 0, flexGrow: multiline ? 1 : 0, width: '100%' }); field.add(entry); box.add(field); entry.focus(); }
|
if (entry) { const field = new BoxRenderable(this.renderer, { border: !multiline, height: multiline ? undefined : 3, flexShrink: 0, flexGrow: multiline ? 1 : 0, width: '100%' }); field.add(entry); box.add(field); entry.focus(); }
|
||||||
else { this.list.blur(); this.actions.blur(); this.scroll.blur(); box.add(new TextRenderable(this.renderer, { fg, content: 'Input hidden — no characters or length displayed.', height: 2 })); }
|
else { this.list.blur(); this.actions.blur(); this.scroll.blur(); box.add(new TextRenderable(this.renderer, { fg, content: 'Your key is hidden. Its length is not shown.', height: 2 })); }
|
||||||
box.add(new TextRenderable(this.renderer, { fg, content: multiline ? 'Ctrl-S save · Esc cancel (unsaved text discarded)' : back ? 'Enter next · Shift-Tab back · Esc cancel' : 'Enter accept · Esc cancel · Ctrl-↑/↓ text', height: 2 }));
|
box.add(new TextRenderable(this.renderer, { fg, content: multiline ? 'Ctrl-S save · Esc cancel (unsaved text discarded)' : back ? 'Enter next · Shift-Tab back · Esc cancel' : 'Enter continue · Esc cancel · Ctrl-↑/↓ scroll', height: 2 }));
|
||||||
const error = new TextRenderable(this.renderer, { fg, height: 2, flexShrink: 0 }); box.add(error);
|
const error = new TextRenderable(this.renderer, { fg, height: 2, flexShrink: 0 }); box.add(error);
|
||||||
let settled = false;
|
let settled = false;
|
||||||
const finish = (answer?: string) => {
|
const finish = (answer?: string) => {
|
||||||
@@ -216,7 +235,7 @@ export class OpenTuiScreen {
|
|||||||
if (back && !secret && key.name === 'tab' && key.shift) { key.preventDefault(); back(entry instanceof InputRenderable ? entry.value : entry?.plainText ?? ''); finish('\0back'); return; }
|
if (back && !secret && key.name === 'tab' && key.shift) { key.preventDefault(); back(entry instanceof InputRenderable ? entry.value : entry?.plainText ?? ''); finish('\0back'); return; }
|
||||||
if (key.name === 'escape') { key.preventDefault(); finish(); return; }
|
if (key.name === 'escape') { key.preventDefault(); finish(); return; }
|
||||||
if ((!multiline && key.name === 'return') || (multiline && key.ctrl && key.name === 's')) {
|
if ((!multiline && key.name === 'return') || (multiline && key.ctrl && key.name === 's')) {
|
||||||
key.preventDefault(); const answer = secret ? hidden : entry instanceof InputRenderable ? entry.value : entry?.plainText ?? ''; const invalid = validate?.(answer); if (invalid) { error.content = secret ? 'Invalid key. Re-enter.' : clean(invalid); if (secret) hidden = ''; return; } finish(answer); return;
|
key.preventDefault(); const answer = secret ? hidden : entry instanceof InputRenderable ? entry.value : entry?.plainText ?? ''; const invalid = validate?.(answer); if (invalid) { error.content = secret ? 'Invalid key. Enter the key again.' : clean(invalid); if (secret) hidden = ''; return; } finish(answer); return;
|
||||||
}
|
}
|
||||||
if (!secret) return;
|
if (!secret) return;
|
||||||
key.preventDefault();
|
key.preventDefault();
|
||||||
@@ -227,7 +246,7 @@ export class OpenTuiScreen {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
async confirm(label: string) { return await this.input(`${label}\nType yes to confirm. Empty Enter is Cancel.`) === 'yes'; }
|
async confirm(label: string) { return await this.input(`${label}\nType yes to confirm. Press Enter with no text to cancel.`) === 'yes'; }
|
||||||
close() {
|
close() {
|
||||||
if (this.closed) return; this.closed = true; this.modal?.cancel(); this.renderer.destroy(); this.finish();
|
if (this.closed) return; this.closed = true; this.modal?.cancel(); this.renderer.destroy(); this.finish();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ test('manager config save is real/keyless owner routing; retained configuration
|
|||||||
await f.controller.request({ id: 1, action: 'configure', values: { owner, relay: 'wss://example.invalid' } });
|
await f.controller.request({ id: 1, action: 'configure', values: { owner, relay: 'wss://example.invalid' } });
|
||||||
assert.ok(existsSync(join(f.home,'.beehive','host','host-identity.json')));
|
assert.ok(existsSync(join(f.home,'.beehive','host','host-identity.json')));
|
||||||
assert.match(f.snapshot.status,/saved/);
|
assert.match(f.snapshot.status,/saved/);
|
||||||
assert.match(f.snapshot.local[0]!.detail, /Service: not checked/);
|
assert.match(f.snapshot.local[0]!.detail, /Host service: not checked/);
|
||||||
assert.ok(!f.snapshot.local[0]!.detail.includes('{'));
|
assert.ok(!f.snapshot.local[0]!.detail.includes('{'));
|
||||||
assert.match(f.snapshot.local[0]!.evidence!, /nonce/);
|
assert.match(f.snapshot.local[0]!.evidence!, /nonce/);
|
||||||
assert.equal(f.backend.read(credentialReference('owner',owner)),null);
|
assert.equal(f.backend.read(credentialReference('owner',owner)),null);
|
||||||
@@ -61,22 +61,22 @@ test('manager gates owner, exact selection/revision/freshness, unresolved operat
|
|||||||
await request('signin',{ values: { owner, relay: 'wss://example.invalid', secret } }); assert.equal(f.snapshot.owner,owner);
|
await request('signin',{ values: { owner, relay: 'wss://example.invalid', secret } }); assert.equal(f.snapshot.owner,owner);
|
||||||
const m = message('inventory','host-a','agent-a',3,{ observedAt: Date.now(), phase: 'stopped', actualRun: null, assignedHost: 'host-a', configurations: { default: {} }, selectedNext: {} });
|
const m = message('inventory','host-a','agent-a',3,{ observedAt: Date.now(), phase: 'stopped', actualRun: null, assignedHost: 'host-a', configurations: { default: {} }, selectedNext: {} });
|
||||||
f.receive(m);
|
f.receive(m);
|
||||||
assert.match(f.snapshot.agents[0]!.detail, /Actual run \(reported\)/);
|
assert.match(f.snapshot.agents[0]!.detail, /Current run \(host report\)/);
|
||||||
assert.match(f.snapshot.agents[0]!.detail, /Selected for next Start/);
|
assert.match(f.snapshot.agents[0]!.detail, /Configuration for next start/);
|
||||||
assert.ok(!f.snapshot.agents[0]!.detail.includes('{'));
|
assert.ok(!f.snapshot.agents[0]!.detail.includes('{'));
|
||||||
assert.equal(f.snapshot.agents[0]!.disabled!.start, '');
|
assert.equal(f.snapshot.agents[0]!.disabled!.start, '');
|
||||||
const target = JSON.stringify(['host-a','agent-a']);
|
const target = JSON.stringify(['host-a','agent-a']);
|
||||||
await request('start',{ target, revision: 2 }); assert.match(f.snapshot.status,/Selection changed/);
|
await request('start',{ target, revision: 2 }); assert.match(f.snapshot.status,/selection changed/);
|
||||||
await request('start',{ target, revision: 3 }); assert.equal(f.submitted.length,1); assert.equal(f.submitted[0]!.type,'start');
|
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 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,/Not available/); assert.equal(f.submitted.length,1,'handler refuses even if directly invoked');
|
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');
|
||||||
f.states([{ request: f.submitted[0], state: 'unknown' }]);
|
f.states([{ request: f.submitted[0], state: 'unknown' }]);
|
||||||
await request('stop',{ target, revision: 3 }); assert.match(f.snapshot.status,/Unresolved/); 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,1);
|
||||||
f.states([{ request: f.submitted[0], state: 'completed' }]);
|
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('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('stop',{ target, revision: 3 }); assert.equal(f.submitted[2]!.type,'stop');
|
||||||
f.receive({ ...m, revision: 4, body: { ...m.body, observedAt: Date.now()+1, phase: 'running', actualRun: { run: 'actual' } } });
|
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,/fresh assigned stopped/);
|
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);
|
await request('signout'); assert.equal(f.snapshot.owner,undefined); assert.equal(f.submitted.length,3); assert.equal(f.closed,1);
|
||||||
f.receive(m); assert.equal(f.controller.snapshot().agents.length,0,'late receive is fenced after signout');
|
f.receive(m); assert.equal(f.controller.snapshot().agents.length,0,'late receive is fenced after signout');
|
||||||
} finally { f.cleanup(); }
|
} finally { f.cleanup(); }
|
||||||
@@ -111,3 +111,37 @@ test('credential orchestration uses explicit Node, strips loaders, and awaits ow
|
|||||||
assert.throws(() => process.kill(pid,0),{ code: 'ESRCH' });
|
assert.throws(() => process.kill(pid,0),{ code: 'ESRCH' });
|
||||||
} finally { abort.abort(); rmSync(home,{ recursive: true, force: true }); }
|
} finally { abort.abort(); rmSync(home,{ recursive: true, force: true }); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('manager presentation preserves raw evidence, unknown states and literal configuration values', async () => {
|
||||||
|
const f = fixture();
|
||||||
|
try {
|
||||||
|
await f.controller.request({ id: 1, action: 'signin', values: { owner, relay: 'wss://example.invalid', secret } });
|
||||||
|
const m = message('inventory','host-a','agent-a',3,{ observedAt: Date.now() - 10000, phase: 'running', assignedHost: 'host-a', actualRun: { selection: { model: 'accepted', workspace: 'unconfirmed', profile: 'default' }, future: { diagnostic: 'raw detail' } }, selectedNext: { configuration: { name: 'accepted', revision: 2 }, harnessSetup: { id: 'setup', fingerprint: 'fingerprint' } } });
|
||||||
|
f.receive(m);
|
||||||
|
const row = f.snapshot.agents[0]!;
|
||||||
|
assert.match(row.label, /Unknown/);
|
||||||
|
assert.match(row.detail, /Current status unknown/);
|
||||||
|
assert.match(row.detail, /Reported status: running/);
|
||||||
|
assert.match(row.detail, /Model: accepted\nWorkspace: unconfirmed/);
|
||||||
|
assert.match(row.detail, /Configuration name: accepted/);
|
||||||
|
assert.match(row.detail, /future: diagnostic: raw detail/);
|
||||||
|
assert.deepEqual(JSON.parse(row.evidence!), m);
|
||||||
|
assert.match(row.disabled!.start!, /host report is old/);
|
||||||
|
const request = message('save','host-a','agent-a',3,{ configurationAction: 'select', name: 'accepted' });
|
||||||
|
const operation = { request, state: 'unknown', publication: 'relay policy failure; automatic retry disabled; reconcile, then retry after policy repair', result: 'new backend diagnostic' };
|
||||||
|
f.states([operation]); f.controller.refresh();
|
||||||
|
const shown = f.snapshot.agents.find(r => r.id === `operation:${request.id}`)!;
|
||||||
|
assert.match(shown.label, /Choose configuration · Unknown/);
|
||||||
|
assert.match(shown.detail, /Automatic retry is disabled/);
|
||||||
|
assert.match(shown.detail, /Host result: new backend diagnostic/);
|
||||||
|
assert.deepEqual(JSON.parse(shown.evidence!), operation);
|
||||||
|
await f.controller.request({ id: 2, action: 'operations' });
|
||||||
|
assert.match(f.snapshot.status, /Choose configuration · Unknown: new backend diagnostic/);
|
||||||
|
await f.controller.request({ id: 3, action: 'reconcile' });
|
||||||
|
assert.equal(f.snapshot.status, 'Checking operation results. Operations blocked by relay policy will not be retried.');
|
||||||
|
assert.equal(f.submitted.length, 0);
|
||||||
|
assert.equal(operation.state, 'unknown');
|
||||||
|
f.receive(message('availability','host-b','',0,{ observedAt: Date.now(), configuration: { label: 'Host B', relay: 'wss://example.invalid' } }));
|
||||||
|
assert.match(f.snapshot.agents.find(r => r.id === 'host:host-b')!.detail, /Host configuration: Host name: Host B/);
|
||||||
|
} finally { f.cleanup(); }
|
||||||
|
});
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ const view = new OpenTuiScreen(ui.renderer);
|
|||||||
const save = async (name: string) => { await ui.renderOnce(); writeFileSync(output + name + '.txt', ui.captureCharFrame()); writeFileSync(output + name + '-spans.json', JSON.stringify(ui.captureSpans())); };
|
const save = async (name: string) => { await ui.renderOnce(); writeFileSync(output + name + '.txt', ui.captureCharFrame()); writeFileSync(output + name + '-spans.json', JSON.stringify(ui.captureSpans())); };
|
||||||
try {
|
try {
|
||||||
ui.mockInput.pressArrow('right');
|
ui.mockInput.pressArrow('right');
|
||||||
view.show([{ id: 'synthetic-agent', label: 'Agent 12345678…abcdef', detail: 'Agent 12345678…abcdef\nHost abcdef12…123456\nUNKNOWN — stale or unreachable; last reported below\nReported phase: running\n\nActual run (reported)\nConfiguration: daily\nModel: Not reported\n\nSelected for next Start\nConfiguration: careful\nChoosing next does not change the actual run.', evidence: '{"syntheticPublicEvidence":true}' }], [{ label: 'Start agent', disabled: 'Host stale or unreachable. Refresh before acting.', run: () => { throw Error('Must never run'); } }, { label: 'Restart', disabled: 'Backend Restart may switch bindings. Use Stop, inspect accepted stopped report, then Start. No Restart request is sent.', run: () => { throw Error('Must never run'); } }]);
|
view.show([{ id: 'synthetic-agent', label: 'Agent 12345678…abcdef', detail: 'Agent 12345678…abcdef\nHost abcdef12…123456\nCurrent status unknown. The report is old or the host cannot be reached. Last report:\nReported status: running\n\nCurrent run (host report)\nConfiguration: daily\nModel: Not reported\n\nConfiguration for next start\nConfiguration: careful\nThis choice does not change the current run.', evidence: '{"syntheticPublicEvidence":true}' }], [{ label: 'Start agent', disabled: 'The host report is old or the host cannot be reached. Wait for a recent report before you act.', run: () => { throw Error('Must never run'); } }, { label: 'Restart', disabled: '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.', run: () => { throw Error('Must never run'); } }]);
|
||||||
await save('fixture-agent');
|
await save('fixture-agent');
|
||||||
view.setPending(true); view.notice('Working: synthetic request… Esc cancels waiting, not committed OS writes or remote work.');
|
view.setPending(true); view.notice('Working… Esc stops waiting. Remote work and saved changes are not cancelled. Inspect before you try again.');
|
||||||
ui.mockInput.pressKey('F2'); await save('fixture-pending');
|
ui.mockInput.pressKey('a'); await save('fixture-pending');
|
||||||
ui.mockInput.pressKey('q', { ctrl: true });
|
ui.mockInput.pressKey('q', { ctrl: true });
|
||||||
} finally { view.close(); }
|
} finally { view.close(); }
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ test('actual OpenTUI selection, hidden entry, multiline save, resize and quit',
|
|||||||
view.show([{ id: 'a', label: 'Synthetic A', detail: 'First detail' }, { id: 'b', label: 'Synthetic B', detail: 'Second detail' }], []);
|
view.show([{ id: 'a', label: 'Synthetic A', detail: 'First detail' }, { id: 'b', label: 'Synthetic B', detail: 'Second detail' }], []);
|
||||||
await ui.renderOnce();
|
await ui.renderOnce();
|
||||||
assert.match(ui.captureCharFrame(), /Local Host.*Agents/);
|
assert.match(ui.captureCharFrame(), /Local Host.*Agents/);
|
||||||
|
assert.match(ui.captureCharFrame(), /\? Help · a Actions · i Inspect · o Status · q Quit/, 'ordinary-key footer, no function keys');
|
||||||
|
ui.resize(44, 24); await ui.renderOnce();
|
||||||
|
assert.match(ui.captureCharFrame(), /\? a Actions i Inspect o Status q Quit/, 'compact ordinary-key footer fits the 40-column floor');
|
||||||
|
ui.resize(60, 24); await ui.renderOnce();
|
||||||
ui.mockInput.pressArrow('down');
|
ui.mockInput.pressArrow('down');
|
||||||
await ui.renderOnce();
|
await ui.renderOnce();
|
||||||
assert.equal(selected, 'b');
|
assert.equal(selected, 'b');
|
||||||
@@ -33,11 +37,15 @@ test('actual OpenTUI selection, hidden entry, multiline save, resize and quit',
|
|||||||
ui.mockInput.pressKey('s', { ctrl: true });
|
ui.mockInput.pressKey('s', { ctrl: true });
|
||||||
assert.equal(await edit, 'line one');
|
assert.equal(await edit, 'line one');
|
||||||
ui.resize(30, 10); await ui.renderOnce();
|
ui.resize(30, 10); await ui.renderOnce();
|
||||||
assert.match(ui.captureCharFrame(), /Terminal too small/);
|
assert.match(ui.captureCharFrame(), /Resize to at least 40 × 16/);
|
||||||
ui.resize(60, 24); await ui.renderOnce();
|
ui.resize(60, 24); await ui.renderOnce();
|
||||||
assert.match(ui.captureCharFrame(), /Local Host.*Agents/);
|
assert.match(ui.captureCharFrame(), /Local Host.*Agents/);
|
||||||
const quitting = view.input('Quit clears volatile input', '', true);
|
const quitting = view.input('Quit clears volatile input', '', true);
|
||||||
await ui.mockInput.typeText(secret);
|
await ui.mockInput.typeText('aq?io');
|
||||||
|
ui.mockInput.pressKey('q'); // plain q inside a secret form types text; it must never quit or fire actions
|
||||||
|
await ui.renderOnce();
|
||||||
|
assert.equal(await Promise.race([view.done.then(() => 'quit'), new Promise(resolve => setTimeout(resolve, 120, 'open'))]), 'open', 'plain q must not quit an open form');
|
||||||
|
assert.ok(!ui.captureCharFrame().includes('[Actions]'), 'a must not open the drawer from a secret form');
|
||||||
ui.mockInput.pressKey('q', { ctrl: true });
|
ui.mockInput.pressKey('q', { ctrl: true });
|
||||||
await view.done;
|
await view.done;
|
||||||
assert.equal(await quitting, undefined);
|
assert.equal(await quitting, undefined);
|
||||||
@@ -55,6 +63,9 @@ test('drawer guards, persistent keys, pending Esc priority, list navigation and
|
|||||||
{label: 'Start', disabled: 'Requires fresh stopped report.', run: () => { calls++; }},
|
{label: 'Start', disabled: 'Requires fresh stopped report.', run: () => { calls++; }},
|
||||||
...Array.from({length: 10}, (_, i) => ({label: `Action ${i}`, run: () => { calls++; }})),
|
...Array.from({length: 10}, (_, i) => ({label: `Action ${i}`, run: () => { calls++; }})),
|
||||||
]);
|
]);
|
||||||
|
await ui.renderOnce();
|
||||||
|
assert.match(ui.captureCharFrame(), /Actions \(a\)/, 'visible named control shows the ordinary key');
|
||||||
|
assert.match(ui.captureCharFrame(), /Inspect \(i\)/);
|
||||||
ui.mockInput.pressKey('HOME'); await ui.renderOnce();
|
ui.mockInput.pressKey('HOME'); await ui.renderOnce();
|
||||||
await ui.mockMouse.scroll(4, 5, 'down'); await ui.renderOnce();
|
await ui.mockMouse.scroll(4, 5, 'down'); await ui.renderOnce();
|
||||||
assert.equal(selected, '1');
|
assert.equal(selected, '1');
|
||||||
@@ -63,7 +74,7 @@ test('drawer guards, persistent keys, pending Esc priority, list navigation and
|
|||||||
await ui.mockMouse.click(4, 5); await ui.renderOnce();
|
await ui.mockMouse.click(4, 5); await ui.renderOnce();
|
||||||
assert.equal(selected, '24', 'scrolled click cannot select wrong row');
|
assert.equal(selected, '24', 'scrolled click cannot select wrong row');
|
||||||
ui.mockInput.pressEnter(); assert.equal(calls, 0);
|
ui.mockInput.pressEnter(); assert.equal(calls, 0);
|
||||||
ui.mockInput.pressKey('F2'); await ui.renderOnce();
|
ui.mockInput.pressKey('a'); await ui.renderOnce();
|
||||||
assert.match(ui.captureCharFrame(), /\(unavailable\) Start/);
|
assert.match(ui.captureCharFrame(), /\(unavailable\) Start/);
|
||||||
assert.match(ui.captureCharFrame(), /Requires fresh stopped report/);
|
assert.match(ui.captureCharFrame(), /Requires fresh stopped report/);
|
||||||
ui.mockInput.pressEnter(); assert.equal(calls, 0);
|
ui.mockInput.pressEnter(); assert.equal(calls, 0);
|
||||||
@@ -71,14 +82,14 @@ test('drawer guards, persistent keys, pending Esc priority, list navigation and
|
|||||||
assert.match(ui.captureCharFrame(), /Action 9/);
|
assert.match(ui.captureCharFrame(), /Action 9/);
|
||||||
ui.mockInput.pressEscape(); await new Promise(resolve => setTimeout(resolve, 50));
|
ui.mockInput.pressEscape(); await new Promise(resolve => setTimeout(resolve, 50));
|
||||||
view.setPending(true); view.notice('Working: synthetic pending request');
|
view.setPending(true); view.notice('Working: synthetic pending request');
|
||||||
ui.mockInput.pressKey('F2'); await ui.renderOnce();
|
ui.mockInput.pressKey('a'); await ui.renderOnce();
|
||||||
assert.match(ui.captureCharFrame(), /Esc cancels waiting/);
|
assert.match(ui.captureCharFrame(), /Esc stops waiting/);
|
||||||
assert.match(ui.captureCharFrame(), /F1 Keys F2 Actions F3 Inspect/);
|
assert.match(ui.captureCharFrame(), /\? Help · a Actions · i Inspect · o Status · q Quit/);
|
||||||
ui.mockInput.pressEnter(); assert.equal(calls, 0);
|
ui.mockInput.pressEnter(); assert.equal(calls, 0);
|
||||||
ui.mockInput.pressEscape(); await new Promise(resolve => setTimeout(resolve, 50)); assert.equal(cancelled, 1);
|
ui.mockInput.pressEscape(); await new Promise(resolve => setTimeout(resolve, 50)); assert.equal(cancelled, 1);
|
||||||
await ui.renderOnce(); assert.match(ui.captureCharFrame(), /\[Actions\]/);
|
await ui.renderOnce(); assert.match(ui.captureCharFrame(), /\[Actions\]/);
|
||||||
view.setPending(false); ui.mockInput.pressEscape(); await new Promise(resolve => setTimeout(resolve, 50));
|
view.setPending(false); ui.mockInput.pressEscape(); await new Promise(resolve => setTimeout(resolve, 50));
|
||||||
ui.mockInput.pressKey('F3'); await ui.renderOnce(); assert.match(ui.captureCharFrame(), /PUBLIC 24/);
|
ui.mockInput.pressKey('i'); await ui.renderOnce(); assert.match(ui.captureCharFrame(), /PUBLIC 24/);
|
||||||
ui.mockInput.pressEscape(); await new Promise(resolve => setTimeout(resolve, 50));
|
ui.mockInput.pressEscape(); await new Promise(resolve => setTimeout(resolve, 50));
|
||||||
const input = view.input('Required public field', '', false, false, value => value ? '' : 'Required; enter a value.');
|
const input = view.input('Required public field', '', false, false, value => value ? '' : 'Required; enter a value.');
|
||||||
ui.mockInput.pressEnter(); await ui.renderOnce(); assert.match(ui.captureCharFrame(), /Required; enter a value/);
|
ui.mockInput.pressEnter(); await ui.renderOnce(); assert.match(ui.captureCharFrame(), /Required; enter a value/);
|
||||||
@@ -86,7 +97,48 @@ test('drawer guards, persistent keys, pending Esc priority, list navigation and
|
|||||||
ui.resize(60,24); await ui.renderOnce(); assert.match(ui.captureCharFrame(), /retained public input/);
|
ui.resize(60,24); await ui.renderOnce(); assert.match(ui.captureCharFrame(), /retained public input/);
|
||||||
ui.mockInput.pressEnter(); assert.equal(await input, 'retained public input');
|
ui.mockInput.pressEnter(); assert.equal(await input, 'retained public input');
|
||||||
view.notice('FAILED: a long outcome\n' + 'Details\n'.repeat(40));
|
view.notice('FAILED: a long outcome\n' + 'Details\n'.repeat(40));
|
||||||
ui.mockInput.pressKey('F4'); await ui.renderOnce(); assert.match(ui.captureCharFrame(), /FAILED: a long outcome/);
|
ui.mockInput.pressKey('o'); await ui.renderOnce(); assert.match(ui.captureCharFrame(), /FAILED: a long outcome/);
|
||||||
assert.match(ui.captureCharFrame(), /F1 Keys F2 Actions F3 Inspect/);
|
assert.match(ui.captureCharFrame(), /\? Help · a Actions · i Inspect · o Status · q Quit/);
|
||||||
|
ui.mockInput.pressEscape(); await new Promise(resolve => setTimeout(resolve, 50));
|
||||||
|
ui.mockInput.pressKey('?'); await ui.renderOnce();
|
||||||
|
assert.match(ui.captureCharFrame(), /Help · Esc close/);
|
||||||
|
assert.match(ui.captureCharFrame(), /Shortcuts do not run while you edit a field/);
|
||||||
|
ui.mockInput.pressEscape(); await new Promise(resolve => setTimeout(resolve, 50));
|
||||||
|
// Legacy F-key compatibility aliases remain functional, undocumented in footer/help.
|
||||||
|
ui.mockInput.pressKey('F2'); await ui.renderOnce(); assert.match(ui.captureCharFrame(), /\[Actions\]/);
|
||||||
|
ui.mockInput.pressEscape(); await new Promise(resolve => setTimeout(resolve, 50));
|
||||||
|
ui.mockInput.pressKey('F3'); await ui.renderOnce(); assert.match(ui.captureCharFrame(), /PUBLIC 24/);
|
||||||
|
ui.mockInput.pressEscape(); await new Promise(resolve => setTimeout(resolve, 50));
|
||||||
|
ui.mockInput.pressKey('q');
|
||||||
|
assert.equal(await Promise.race([view.done.then(() => 'quit'), new Promise(resolve => setTimeout(resolve, 2000, 'open'))]), 'quit', 'plain q quits outside editable contexts');
|
||||||
|
} finally { view.close(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ordinary shortcut characters type as text in public, secret and multiline inputs', async () => {
|
||||||
|
const ui = await createTestRenderer({ width: 100, height: 30, exitOnCtrlC: false });
|
||||||
|
const view = new OpenTuiScreen(ui.renderer);
|
||||||
|
try {
|
||||||
|
view.show([{ id: 'row', label: 'Row', detail: 'Detail', evidence: 'EVIDENCE' }], []);
|
||||||
|
await ui.renderOnce();
|
||||||
|
const publicField = view.input('Public field', '', false, false);
|
||||||
|
await ui.mockInput.typeText('a?ioq');
|
||||||
|
await ui.renderOnce();
|
||||||
|
assert.ok(!ui.captureCharFrame().includes('[Actions]'), 'a must not open the drawer from a public form');
|
||||||
|
assert.ok(!ui.captureCharFrame().includes('Technical details'), 'i must not open inspect from a public form');
|
||||||
|
assert.ok(!ui.captureCharFrame().includes('No status message yet'), 'o must not open the outcome overlay from a public form');
|
||||||
|
ui.mockInput.pressEnter();
|
||||||
|
assert.equal(await publicField, 'a?ioq');
|
||||||
|
const secret = view.input('Hidden field', '', true);
|
||||||
|
await ui.renderOnce();
|
||||||
|
assert.ok(!ui.captureCharFrame().includes('a?ioq'), 'prior public field value not left on screen');
|
||||||
|
await ui.mockInput.typeText('a?ioq');
|
||||||
|
ui.mockInput.pressEnter();
|
||||||
|
assert.equal(await secret, 'a?ioq');
|
||||||
|
await ui.renderOnce();
|
||||||
|
assert.ok(!ui.captureCharFrame().includes('a?ioq'), 'hidden value never rendered');
|
||||||
|
const draft = view.input('Multiline draft', '', false, true);
|
||||||
|
await ui.mockInput.typeText('a io q?');
|
||||||
|
ui.mockInput.pressKey('s', { ctrl: true });
|
||||||
|
assert.equal(await draft, 'a io q?');
|
||||||
} finally { view.close(); }
|
} finally { view.close(); }
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user