From 4fa4fd146961f91fdace04f3c6fb59e6300e2473 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 9 Sep 2026 23:52:15 -0400 Subject: [PATCH] feat(beehive): reconcile policy-blocked intents with informed unchanged retry Signed-off-by: Logan Johnson --- beehive/CHECKPOINT.md | 81 ++++++++++++++---- beehive/README.md | 37 +++++++- beehive/TESTING.md | 19 ++++- beehive/src/cli.ts | 23 ++++- beehive/src/client.ts | 6 ++ beehive/src/host.ts | 2 +- beehive/src/intents.ts | 21 ++++- beehive/test/intents.test.ts | 4 +- beehive/test/policy-tui.test.ts | 147 ++++++++++++++++++++++++++++++++ 9 files changed, 310 insertions(+), 30 deletions(-) create mode 100644 beehive/test/policy-tui.test.ts diff --git a/beehive/CHECKPOINT.md b/beehive/CHECKPOINT.md index 3e586c87fa..312c373d0b 100644 --- a/beehive/CHECKPOINT.md +++ b/beehive/CHECKPOINT.md @@ -5,6 +5,59 @@ Base `051c3a270be9c73da9ab06700bcab7d5552fceaa`; continuation starts at `023c9274767ef50fa0f5b37ef1883336f8be59fd`. Candidate is the commit containing this checkpoint (`git rev-parse HEAD`). No merge/release. +## Current published-candidate continuation 4341 — U1 policy recovery + +R1 was separately committed and pushed as +`64305a7a0020da0faa599b46be37b538b9533945`; origin branch verified at that exact +head before this UX checkpoint. No reset/reimplementation or additional writer. + +TUI now offers numbered `operations`, `reconcile`, and informed `retry `. +Reconcile explicitly reconnects, reads relay history and queries host outbox via +existing inspect messages; it does not replay blocked work or assert policy repair. +Host inspect republishes durable terminal receipts so a receipt lost before relay +storage remains recoverable. Retry sends the original retained envelope ONCE with +unchanged ID/signature/ciphertext/body/fingerprint/revision. The durable policy block +is deliberately NOT removed: socket success does not authorize blind future retry. +Only a matched terminal receipt resolves pending work. Completed/known rejection/ +UNKNOWN remain distinct, historical results are labelled separately from current +host inventory, and the confirmation explains policy repair, uncertain prior effect, +permanently invalid events and no remote cancellation. Repeated 1008 remains blocked. +No replacement ACK/control service, re-signing or host-precondition change. + +Acceptance is **actual TUI subprocess**, real package client/relay/host, fresh keys +and fixture runner, with test-owned loopback WS fault placement (not a model or +production policy test): +- Policy denial before Stop publication -> explicit retry under continued denial -> + two denials total; correction/reopen sends zero blocked commands; reconcile sends + zero blocked commands; numbered confirmed retry completes original Stop once. + Three total attempts all byte-identical, one Start plus original Stop in host + operations, final stopped/revision2. +- Committed Stop receipt dropped BEFORE relay storage -> 1008 block -> corrected + reopen queries host outbox and recovers original accepted result without a second + Stop attempt. Final revision2; completed entries reject further retry. +- Reconcile after completion does not duplicate effects. Intentional Quit in each + subprocess exits 0 with independent-host-lifetime message, no spurious UNKNOWN. +- Existing lost-Start/crash/receipt negatives and installed cross-conversation seams + remain green; predecessor independent seven crypto/receipt/perms negatives reused. + +Final semantic candidate: strict TypeScript + full installed-enabled package suite +**19/19 pass, zero skips**, 15.64s. Evidence in workspace +`WORK_LOGS/BEEHIVE_DESIGN_183FFAF0/CONTINUATION_4341_EVIDENCE/ux-check.log` and +`ux-full.log`; production-bound regression source `test/policy-tui.test.ts`. +Binary hashes freshly rechecked and match the two pins below. No dependency changes. +No repo-wide just ci, GitHub CI approval, production admission, real provider/OAuth, +PR/merge/release, or full delivery claim. Source candidate is the commit containing +this section; publication record with exact head follows in the workspace evidence. +Configured Logan Johnson author/committer + DCO verified before push; no crypto signer. + +Completed independent TOOLS_RECONNECT and INTENT_TUI reviews are consumed. The new +UX delta has self-review and executable acceptance, not yet a separate immutable +independent review. This is not a reason to leave the broader build waiting. +**ONE next executable step:** implement conservative same-identity two-host assignment / +Move with multi-host/agent selection, preserving remote-only normal configuration and +lifecycle and refusing unreachable-host takeover. Retention1000/pruning, profiles/key +lifecycle/Restart, broader harness parity and live-production gates remain unfinished. + ## Continuation 4341 — R1 reconciliation Recovered d12ed7b4e08501e2e5829da80689c9759b32bd49 plus the prior worker's @@ -74,15 +127,14 @@ repeated). Prior installed runtime evidence retained; this run also produced three verified fixture replies from agent `b727bb8ae1e277a522f8dbad00aae15cc7b71e39c57d43e7f5d532529e8087aa`. No real provider/admission calls, production activity, full repo CI or PR/merge/release. -Independent preceding broad-tool/reconnect review still in progress (evidence directory -exists; no completed report received). Existing B1/D1-D3 conclusions retained, not +Completed preceding broad-tool/reconnect review is consumed in continuation 4341 above. Existing B1/D1-D3 conclusions retained, not reopened or treated as approval of this new journal delta. Limits: relay URL is the community scope in this development topology, not a stable production community identifier. Publication echo observation is session-local; terminal host results persist. There is no relay per-event rejection/ACK protocol: policy failure -is conservatively UNKNOWN/blocked, not claimed failed; explicit guided recovery and -journal retention remain future UX work. Pre-upgrade host receipts lack fingerprints +is conservatively UNKNOWN/blocked, not claimed failed; guided recovery is now implemented in continuation 4341; +journal retention remains future work. Pre-upgrade host receipts lack fingerprints and cannot complete new journal intents. Arbitrary disk rollback, malicious local operator, simultaneous UI action coordination, log-full recovery and host SIGKILL recovery are not proved. Single-operation exclusive files prevent conflicting-ID @@ -91,9 +143,8 @@ process exit without cleanup, not machine power-loss proof. Stop-during-Start re covered by unchanged full-suite regressions; no claim that this new test automates the entire interactive crash workflow. -**ONE next executable step:** independent review and real interactive TUI recovery -acceptance of this client journal, especially policy-failure UNKNOWN recovery and -receipt ordering, before expanding to the two-host same-identity assignment/Move slice. +**Historical next step (completed in continuation 4341):** independent journal review +and real TUI policy-recovery acceptance. Current next step is two-host assignment/Move. Full remaining parity scope below remains active, not declared complete. ## Recovery 4200a3ab @@ -116,8 +167,8 @@ No recovered PID was used for cleanup and no private run artifacts were read. New implementation commit `998e919a9b26af0ec3b15eefba539661380eeed7` was pushed successfully as the first publication of `beehive/cbfd9440` to `https://github.com/block/buzz.git`. This checkpoint-only follow-up retains that -source. No GitHub or NIP-34 PR has been created; repo-wide `just ci` and independent -review remain outstanding. `buzz pr open --help` requires a NIP-34 repository +source. No GitHub or NIP-34 PR has been created; repo-wide `just ci` remains outstanding; subsequent independent +reviews are consumed in continuation 4341. `buzz pr open --help` requires a NIP-34 repository owner pubkey/id; neither was inferred from GitHub ownership. Any later Buzz PR must carry channel `f45d3304-dcf0-44e8-a46d-bcd63b235fbc`. @@ -139,7 +190,7 @@ run and single revision/operation survive. Stop still tears down the owned run; intentional host/client close cannot reconnect. This proves relay-persisted intent recovery, not recovery of controller intent that never reached the relay. The existing receipt outbox is retained, not ACK-pruned. No protocol change or -new remote/local lifecycle endpoint. New delta requires independent review. +new remote/local lifecycle endpoint. This delta subsequently received TOOLS_RECONNECT review; R1 is resolved above. ## Implemented: ordinary multi-conversation Buzz CLI tool authority @@ -205,7 +256,7 @@ B1 resolved through original/extended independent probes; strict/full12/12 and independently verified installed signed publication. Its retained invariants are host executable/env/key/relay ownership, childless shims, exact-model fencing, distinct tool/ACP/publication evidence and bounded resources. This ordinary-CLI -delta has NOT yet received independent review. Long-lived request/session cap +delta subsequently received TOOLS_RECONNECT independent review. Long-lived request/session cap recovery remains explicit subsequent work, not general MCP compatibility. ## Observable installed isolated outcome @@ -272,10 +323,10 @@ cryptographic signing key configured, so no invented signer/signature claim. ## Remaining product scope / ONE next executable step -**Next:** independently review and exercise the local client-journal recovery -implemented above, including policy-failure UNKNOWN handling, then proceed to -two-host identity assignment/Move. The bounded journal is not full recovery/parity -completion. Independent multi-tool and reconnect review remains pending. +**Next:** conservative same-identity two-host assignment/Move and multi-host/agent +selection. Journal and multi-tool/reconnect independent reviews are completed and +consumed above; R1 and U1 corrections have executable acceptance. The bounded +journal is not full recovery/parity completion. README retains the full parity matrix: reconnect/ACKs/crash recovery; multiple hosts/agents/setups and S1 two-host identity; reusable profiles/metadata/history; diff --git a/beehive/README.md b/beehive/README.md index f46d023996..a30cb82af0 100644 --- a/beehive/README.md +++ b/beehive/README.md @@ -37,7 +37,7 @@ On Block-managed machines, the public npm registry is policy-blocked. Append to the install command to use the approved mirror. No credentials or persistent registry config are needed. The committed lockfile is registry-agnostic. -TUI: `hosts`, `select `, `show`, `start`, `save`, `stop`, `quit`. +TUI: `operations`, `reconcile`, `retry `, `hosts`, `select `, `show`, `start`, `save`, `stop`, `quit`. Save asks for advertised model, workspace and independent behavior profile. Only one fixture model and the `default` profile are currently supported; profile authoring/versioning and useful multi-setup selection remain to build. @@ -46,6 +46,34 @@ restart. `quit` leaves the host running. Reopen TUI to inspect it; `stop` confir the owned process group is absent and leaves the agent assigned to this host. Ctrl-C on **host** stops its owned fixture runner before exiting. +### Recover an UNKNOWN operation + +`operations` lists numbered host/action/revision entries and historical results. +A terminal host rejection is **failed**; a matched accepted receipt is **completed**. +Neither a socket connection nor relay echo proves completion or current host state. +Use `hosts`/`show` for separately dated host inventory. + +After a relay policy close (1008), unresolved work stays **UNKNOWN**, with automatic +retry disabled even after Quit/reopen. Repair policy outside the TUI, then: + +1. `reconcile` reconnects and queries retained host receipts through the relay. + It does **not** resend policy-blocked operations or prove policy is repaired. +2. `operations` shows any recovered terminal result. If still UNKNOWN and retry + is appropriate, use `retry ` for the displayed operation, and confirm + `yes` after reading the warning. This attempts the original signed encrypted + event **once**, preserving its ID, fingerprint, body and revision preconditions. +3. A repeated denial stays blocked; another attempt requires another explicit + action. Even a successful socket send leaves UNKNOWN until a matched host receipt. + +Retry does not clear the durable automatic-retry block, cancel remote work, mint a +replacement ID, update stale revisions, or re-sign an expired/invalid event. If the +original event can never be admitted, continue reconciliation; there is no honest +terminal result to invent. The development envelope has no built-in expiry, but +external policy may still reject it permanently. Keep the retained intent. Journal +retention (1,000 operations), pruning/log-full and host-crash recovery remain unfinished. +Ordinary transient loss still has bounded automatic unchanged replay. Quit changes +only UI transport lifetime, never the pending operation or host lifecycle. + **Do not point fixture mode at real agents.** This seam deliberately passes no agent key, OAuth context, model, or instructions to the fixture. Executable trust is a local operator decision; remote messages cannot supply executable/args/env. @@ -100,8 +128,8 @@ assumes non-cloned, non-rollback, exclusively supervised installations. - Relay history max 10,000 envelopes, per-envelope limit, slow-reader disconnect. Log full closes publisher; no compaction. Outbox retained without ACK pruning; host auto-reconnect replays saved receipts and relay history with operation-ID - deduplication (eight attempts/lifetime, bounded backoff). Relay ACKs and durable - TUI pending intents/reconnect are not yet implemented. Lost connection means + deduplication (eight attempts/lifetime, bounded backoff). Per-event relay ACKs remain absent. Durable + TUI pending intents/reconnect are implemented with a local encrypted journal. Lost connection means unknown, not stopped or success; exhausted recovery needs operator attention. - Inventory has freshness, not liveness proof. Host starts no action based on peer agent presence. Only its locally bound agent is manageable. @@ -205,7 +233,8 @@ Installed isolated validation covers two channels/threads and a later prompt in the first thread, three signed replies under one agent identity, native channel reads, non-member mention rejection and owner-only incoming dispatch. No local reconfiguration or key generation occurs between prompts. This new surface still -needs independent delta review; see CHECKPOINT.md for exact evidence and limits. +has received independent tool/reconnect review; its R1 and later journal U1 +recovery corrections are recorded in CHECKPOINT.md with exact evidence and limits. Community admission/attestation and normal OAuth in the named host-service context remain explicit local/operator actions. No live Databricks request has occurred. diff --git a/beehive/TESTING.md b/beehive/TESTING.md index bb0bb46e4c..6b3acbae0a 100644 --- a/beehive/TESTING.md +++ b/beehive/TESTING.md @@ -28,6 +28,21 @@ full suite passed 16/16; after status wording refinement strict + targeted TUI/c 5/5 passed, then final source strict/full default 15 pass + 1 opt-in skip. These fixtures do not establish production community admission, hostile local storage/power loss or a polished manual recovery UX. See current CHECKPOINT. +## Policy recovery continuation 4341 + +`policy-tui.test.ts` drives actual Node TUI subprocesses over a fault proxy and the +real package relay/host with a fresh fixture runner. Before-publication Stop denial +remains UNKNOWN across reopen after repair; reconcile sends no blocked command; +numbered, informed retry sends the byte-identical envelope and resolves the original +Stop once. Repeated 1008 is bounded (one attempt per confirmation). A second journey +drops a committed receipt **before relay storage**, proving host outbox query rather +than merely cached relay history recovers it, with no extra lifecycle effect or +operation ID. Completed entries cannot be retried. Intentional Quit remains clean. +These are real client/relay/TUI subprocess seams, not a live model or production relay. +Independent d12ed7b4 crypto/receipt/perms and installed tool evidence remains reusable; +completed `INTENT_TUI_REVIEW_D12ED7B4.md` identifies U1, addressed by this delta. +The earlier pending/manual-recovery statements are historical, not a review wait. + ## Automated production seams - `slice.test.ts`: malformed/wrong-owner/tampered encryption envelopes; real @@ -98,8 +113,8 @@ local storage/power loss or a polished manual recovery UX. See current CHECKPOIN CLI error (returned without killing the session) all clean up the shim and any TERM-resistant in-group descendants. Tests never open existing owner keys/profiles/cache, production services or native -client resources. With installed opt-in: 18/18 pass; without: 17 pass/1 explicit -skip. The installed fixture relay does not establish actual community admission, +client resources. R1 checkpoint with installed opt-in: 18/18 pass; without: 17 pass/1 explicit +skip. Current policy-recovery validation is recorded in CHECKPOINT. The installed fixture relay does not establish actual community admission, current relay compatibility, provider/model attestation. Multi-thread delivery is isolated legacy-binary evidence only. ## Independent evidence and limits diff --git a/beehive/src/cli.ts b/beehive/src/cli.ts index ed6a60576a..d046f65cbc 100644 --- a/beehive/src/cli.ts +++ b/beehive/src/cli.ts @@ -93,24 +93,39 @@ async function main() { if (!prior || Number(m.body.observedAt) > Number(prior.body.observedAt)) inventory.set(m.host,m); } }, () => { - console.log(client.connected ? '\nManagement relay connected.' : '\nRelay disconnected: pending results UNKNOWN; reconnect attempts are bounded.'); + console.log(client.connected ? '\nManagement relay connected.' : '\nRelay disconnected: pending results UNKNOWN; automatic reconnect is bounded (disabled on policy refusal). Use reconcile after checking relay policy.'); for (const operation of client.status()) console.log(`${operation.request.host} ${operation.request.type}: ${operation.state} | ${operation.result ?? operation.publication}`); }); try { await client.ready; } catch (error) { client.close(); throw error; } const ui = createInterface({ input: stdin, output: stdout }); - console.log('Beehive | Hosts → assigned agent → selected-next / actual run\nCommands: operations, hosts, select , show, save, start, stop, quit. Closing this UI does not stop hosts.'); + console.log('Beehive | Hosts → assigned agent → selected-next / actual run\nCommands: operations, reconcile, retry , hosts, select , show, save, start, stop, quit. Closing this UI does not stop hosts.'); let selected = ''; try { for (;;) { const line = (await ui.question('beehive> ')).trim(); if (line === 'quit') break; - if (line === 'operations') { console.log(JSON.stringify(client.status(),null,2)); continue; } + if (line === 'operations') { + client.status().forEach((o, index) => console.log(`${index + 1}. ${o.request.host} ${o.request.type} at revision ${o.request.revision}: ${o.state} | ${o.result ?? o.publication}${o.retryAvailable ? ` | reconcile, then retry ${index + 1}` : ''}`)); + console.log('Historical results are not current host state. Reconcile queries receipts without retrying blocked work.'); continue; + } + if (line === 'reconcile') { client.reconcile(); console.log('Reconnecting/querying host results; blocked work stays blocked. Connection alone proves neither policy repair nor completion.'); continue; } + if (line.startsWith('retry ')) { + const number = line.slice(6); + const operation = /^[1-9][0-9]*$/.test(number) ? client.status()[Number(number) - 1] : undefined; + if (!operation?.retryAvailable) { console.log('Use operations to choose an unresolved policy-blocked operation.'); continue; } + console.log(`Retry original ${operation.request.host} ${operation.request.type} at revision ${operation.request.revision} ONCE, with unchanged signature, ID and preconditions. It may already have executed. Reconcile first. Repair relay policy before retry; expired/invalid signed events cannot be repaired here. This does not cancel remote work or enable automatic retries.`); + if ((await ui.question('Attempt unchanged operation after policy repair? [yes/no]: ')) === 'yes') { + try { client.retry(operation.request.id); console.log('Unchanged retry attempted; result UNKNOWN until host receipt. Automatic retry stays disabled.'); } + catch (error) { console.log(error instanceof Error ? error.message : 'Retry not sent'); } + } + continue; + } if (line === 'hosts') { for (const [name,m] of inventory) console.log(`${name}: ${m.body.phase} | ${m.body.readiness} | ${Date.now()-Number(m.body.observedAt) > 6000 ? 'STALE/UNKNOWN' : 'recent host report'}`); continue; } if (line.startsWith('select ')) { selected = line.slice(7); continue; } const current = inventory.get(selected); if (!current) { console.log('Select an advertised host first.'); continue; } if (line === 'show') { console.log(JSON.stringify(current,null,2)); continue; } - if (!['save','start','stop'].includes(line)) { console.log('Use hosts/select/show/save/start/stop/quit.'); continue; } + if (!['save','start','stop'].includes(line)) { console.log('Use operations/reconcile/retry /hosts/select/show/save/start/stop/quit.'); continue; } if (Date.now()-Number(current.body.observedAt) > 6000) { console.log('Host stale: status unknown; no action sent.'); continue; } let body: Record = {}; if (line === 'save') { diff --git a/beehive/src/client.ts b/beehive/src/client.ts index 617deb9696..64860a877d 100644 --- a/beehive/src/client.ts +++ b/beehive/src/client.ts @@ -44,6 +44,12 @@ export function connect(url: string, secret: string, receive: (m: Message) => vo ready, sendEnvelope(e: Envelope) { if (closed || socket.readyState !== WebSocket.OPEN) throw Error('Relay disconnected; result unknown'); socket.send(JSON.stringify(e)); }, send(m: Message) { if (closed || socket.readyState !== WebSocket.OPEN) throw Error('Relay disconnected; result unknown'); socket.send(JSON.stringify(seal(m,secret))); }, + /** Explicit transport reconciliation; opening is never an operation result. */ + reconnect() { + if (closed) throw Error('UI closed'); + clearTimeout(retry); attempts = 0; + const previous = socket; socket = dial(); previous.close(); + }, close() { closed = true; clearTimeout(retry); socket.close(); }, }; } diff --git a/beehive/src/host.ts b/beehive/src/host.ts index e1712af992..cc1474ee3b 100644 --- a/beehive/src/host.ts +++ b/beehive/src/host.ts @@ -93,7 +93,7 @@ export async function host(directory: string, url: string) { if (closing || m.host !== setup.host || !['inspect','save','start','stop'].includes(m.type)) return; // Serialized Stop processing durably resolves that Stop's own retraction. if (m.type === 'stop') retracted.delete(m.id); - if (m.type === 'inspect') { publish(inventory()); return; } + if (m.type === 'inspect') { for (const receipt of state.outbox) publish(receipt); publish(inventory()); return; } const fingerprint = digest(JSON.stringify(m)).toString('hex'); const previous = Object.hasOwn(state.operations,m.id) ? state.operations[m.id] : undefined; if (previous) { diff --git a/beehive/src/intents.ts b/beehive/src/intents.ts index 0212614fbc..019cf6bc06 100644 --- a/beehive/src/intents.ts +++ b/beehive/src/intents.ts @@ -1,7 +1,7 @@ import { existsSync, mkdirSync, readdirSync, statSync, lstatSync } from 'node:fs'; import { join } from 'node:path'; import { connect, validateRelayURL } from './client.ts'; -import { digest, fields, object, open, publicKey, seal, type Envelope, type Message } from './protocol.ts'; +import { digest, fields, message, object, open, publicKey, seal, type Envelope, type Message } from './protocol.ts'; import { readPrivate, writePrivate } from './storage.ts'; type Intent = { envelope: Envelope; request: Message; receipt?: Message; published: boolean; blocked?: boolean }; @@ -56,6 +56,11 @@ export function managementClient(root: string, url: string, secret: string, rece for (const intent of intents.values()) if (!intent.receipt && !intent.blocked) { try { transport.sendEnvelope(intent.envelope); } catch { break; } // Intent remains durable. } + // Query host outbox too: relay history alone may lack a lost terminal receipt. + const hosts = new Map([...intents.values()].filter(i => !i.receipt).map(i => [i.request.host, i.request])); + for (const request of hosts.values()) { + try { transport.send(message('inspect', request.host, request.agent)); } catch { break; } + } changed(); } const transport = connect(url, secret, m => { @@ -101,10 +106,22 @@ export function managementClient(root: string, url: string, secret: string, rece intents.set(request.id, { request: immutable, envelope, published: false }); replay(); }, + /** Read host results without lifting durable policy blocks or changing intent. */ + reconcile() { if (closed) throw Error('UI closed'); transport.reconnect(); }, + /** One informed attempt of the exact envelope. Keep the durable block even on + * success at the socket seam: only a matched terminal host receipt resolves it. */ + retry(id: string) { + if (closed) throw Error('UI closed'); + const intent = intents.get(id); + if (!intent || intent.receipt || !intent.blocked) throw Error('Only unresolved policy-blocked operations can be retried'); + transport.sendEnvelope(intent.envelope); + changed(); + }, status() { return [...intents.values()].map(i => ({ request: structuredClone(i.request), state: i.receipt ? (['accepted','saved; running configuration unchanged'].includes(String(i.receipt.body.result)) ? 'completed' : i.receipt.body.result === 'interrupted-reconcile-locally' ? 'unknown' : 'failed') : i.blocked ? 'unknown' : 'pending', - publication: i.blocked ? 'relay policy failure; automatic retry disabled' : i.receipt ? 'host terminal receipt' : i.published ? 'relay observed; not host admission' : 'unconfirmed', + retryAvailable: !i.receipt && !!i.blocked, + publication: i.receipt ? 'host terminal receipt' : i.blocked ? 'relay policy failure; automatic retry disabled; reconcile, then retry after policy repair' : i.published ? 'relay observed; not host admission' : 'unconfirmed', result: i.receipt?.body.result })); }, close() { closed = true; transport.close(); }, diff --git a/beehive/test/intents.test.ts b/beehive/test/intents.test.ts index f880b5250e..1c531ebf39 100644 --- a/beehive/test/intents.test.ts +++ b/beehive/test/intents.test.ts @@ -107,7 +107,7 @@ test('process exit before network effects leaves an exact prepared envelope; cor await ui.ready; await until(() => ui.status()[0]?.publication === 'relay observed; not host admission'); const stored = JSON.parse(readFileSync(join(dir, 'relay.json'), 'utf8')); - assert.deepEqual(stored, [prepared], 'exact signed/encrypted event reused'); + assert.deepEqual(stored.filter((e: unknown) => open(e, secret).type !== 'inspect'), [prepared], 'exact signed/encrypted event reused'); const sender = connect(url, secret, () => {}); await sender.ready; sender.send(message('receipt', 'unrelated-host', request.agent, 1, { operation: request.id, fingerprint: 'f'.repeat(64), result: 'accepted' })); await delay(80); sender.close(); @@ -116,7 +116,7 @@ test('process exit before network effects leaves an exact prepared envelope; cor await until(() => ui.status()[0]?.state === 'unknown'); ui.close(); let republished = 0; - server.on('connection', socket => socket.on('message', () => { republished++; })); + server.on('connection', socket => socket.on('message', data => { if (open(JSON.parse(String(data)), secret).type !== 'inspect') republished++; })); ui = managementClient(root, url, secret, () => {}); await ui.ready; await delay(150); assert.equal(ui.status()[0]?.state, 'unknown'); diff --git a/beehive/test/policy-tui.test.ts b/beehive/test/policy-tui.test.ts new file mode 100644 index 0000000000..e9839777e9 --- /dev/null +++ b/beehive/test/policy-tui.test.ts @@ -0,0 +1,147 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { mkdtempSync, realpathSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { setTimeout as delay } from 'node:timers/promises'; +import { WebSocket, WebSocketServer } from 'ws'; +import { host } from '../src/host.ts'; +import { relay } from '../src/relay.ts'; +import { connect } from '../src/client.ts'; +import { message, newKey, publicKey, open } from '../src/protocol.ts'; +import { writePrivate } from '../src/storage.ts'; + +async function until(predicate: () => boolean) { + for (let n = 0; n < 240; n++) { if (predicate()) return; await delay(25); } + throw Error('Missing real TUI recovery evidence'); +} + +test('actual TUI: policy repair, bounded unchanged retry, lost committed receipt reconciliation and Quit', async () => { + for (const loss of ['before-publication', 'after-commit'] as const) { + const dir = realpathSync(mkdtempSync(join(tmpdir(), 'beehive-policy-tui-'))); + const secret = newKey(); const agentSecret = newKey(); const agent = publicKey(agentSecret); + writePrivate(join(dir, 'identity.json'), { secret }); + writePrivate(join(dir, 'setup.json'), { host: 'policy-host', ownerSecret: secret, agentSecret, + runner: process.execPath, args: [resolve('test/runner.ts')], workspace: dir, mode: 'fixture' }); + const server = await relay(0, publicKey(secret), join(dir, 'relay.json')); + const address = server.address(); assert.ok(address && typeof address !== 'string'); + const url = `ws://127.0.0.1:${address.port}`; + const h = await host(dir, url); + const control = connect(url, secret, () => {}); await control.ready; + const journal = () => JSON.parse(readFileSync(join(dir, 'journal.json'), 'utf8')); + control.send(message('start', 'policy-host', agent, 0)); + await until(() => journal().phase === 'running'); + let deny = true; let denials = 0; + const attempts: string[] = []; + const upstreams = new Set(); + const proxy = new WebSocketServer({ host: '127.0.0.1', port: 0 }); + await new Promise(resolve => proxy.once('listening', resolve)); + proxy.on('connection', downstream => { + const upstream = new WebSocket(url); upstreams.add(upstream); + const queued: string[] = []; + upstream.on('open', () => { for (const data of queued) upstream.send(data); }); + downstream.on('message', raw => { + const data = String(raw); const m = open(JSON.parse(data), secret); + if (m.type === 'stop') { + attempts.push(data); + if (deny && loss === 'before-publication') { denials++; downstream.close(1008, 'Fixture policy'); return; } + } + if (upstream.readyState === WebSocket.OPEN) upstream.send(data); else queued.push(data); + }); + upstream.on('message', raw => { + const data = String(raw); const m = open(JSON.parse(data), secret); + if (downstream.readyState === WebSocket.OPEN) downstream.send(data); + }); + upstream.on('error', () => {}); downstream.on('error', () => {}); + downstream.on('close', () => { upstream.close(); upstreams.delete(upstream); }); + }); + if (loss === 'after-commit') { + // Lose the committed Stop receipt BEFORE relay storage, not just at the UI. + // Recovery must query the real host outbox; relay history is insufficient. + const hostSocket = [...server.clients][0]!; + const handlers = hostSocket.listeners('message'); hostSocket.removeAllListeners('message'); + hostSocket.on('message', (raw, binary) => { + const m = open(JSON.parse(String(raw)), secret); + if (deny && m.type === 'receipt' && attempts.length && m.body.operation === open(JSON.parse(attempts[0]!), secret).id) { + assert.equal(journal().phase, 'stopped'); denials++; + for (const socket of proxy.clients) socket.close(1008, 'Fixture committed receipt loss'); + return; + } + for (const handler of handlers) Reflect.apply(handler, hostSocket, [raw, binary]); + }); + } + const proxyAddress = proxy.address(); assert.ok(proxyAddress && typeof proxyAddress !== 'string'); + const proxyUrl = `ws://127.0.0.1:${proxyAddress.port}`; + const children: ReturnType[] = []; + function tui() { + const child = spawn(process.execPath, ['src/cli.ts', 'tui', join(dir, 'identity.json'), proxyUrl], { stdio: ['pipe','pipe','pipe'] }); + children.push(child); + let output = ''; child.stdout!.on('data', d => { output += String(d); }); child.stderr!.on('data', d => { output += String(d); }); + return { + get output() { return output; }, + async ready() { await until(() => output.includes('beehive> ')); }, + async command(line: string, expected = 'beehive> ') { + const offset = output.length; child.stdin!.write(`${line}\n`); + await until(() => output.slice(offset).includes(expected)); + }, + async quit() { + const offset = output.length; child.stdin!.write('quit\n'); + await until(() => child.exitCode !== null); + assert.equal(child.exitCode, 0, output); + assert.match(output.slice(offset), /UI closed; host lifetime is independent/); + assert.doesNotMatch(output.slice(offset), /UNKNOWN|disconnected/); + }, + }; + } + try { + let ui = tui(); await ui.ready(); + await ui.command('select policy-host'); + await ui.command('stop'); + await until(() => ui.output.includes('automatic retry disabled')); + const original = attempts[0]; assert.ok(original); + const operation = open(JSON.parse(original), secret); + if (loss === 'before-publication') { + assert.equal(journal().phase, 'running'); + await ui.command('reconcile'); await delay(150); + await ui.command('retry 1', '[yes/no]: '); await ui.command('yes'); + await until(() => denials === 2); await delay(250); + assert.equal(attempts.length, 2, 'repeated policy denial never loops'); + assert.equal(journal().phase, 'running'); + } else { + assert.equal(journal().phase, 'stopped'); assert.equal(journal().revision, 2); + } + await ui.quit(); deny = false; + const beforeReopen = attempts.length; + ui = tui(); await ui.ready(); await delay(200); + assert.equal(attempts.length, beforeReopen, 'reopen never republishes blocked intent'); + await ui.command('operations'); + if (loss === 'before-publication') { + assert.match(ui.output, /1\. policy-host stop at revision 1: unknown/); + await ui.command('reconcile'); await delay(150); + assert.equal(attempts.length, beforeReopen, 'query is not a retry'); + await ui.command('retry 1', '[yes/no]: '); await ui.command('yes'); + } + await until(() => ui.output.includes('policy-host stop: completed | accepted')); + assert.equal(journal().revision, 2); assert.equal(journal().phase, 'stopped'); + assert.equal(Object.keys(journal().operations).length, 2, 'one Start and original Stop only'); + assert.equal(journal().operations[operation.id].reply.body.result, 'accepted'); + assert.ok(attempts.every(e => e === original), 'ID, signature, ciphertext, body and preconditions are byte-identical'); + const completedAttempts = attempts.length; + await ui.command('retry 1'); + assert.match(ui.output, /Use operations to choose an unresolved policy-blocked operation/); + assert.equal(attempts.length, completedAttempts); + await ui.command('reconcile'); await delay(150); + assert.equal(journal().revision, 2, 'reconcile cannot duplicate lifecycle effects'); + await ui.quit(); + console.log(`${loss}: exact-envelope attempts=${attempts.length}, denials=${denials}, revision=2, original Stop accepted; Quit clean`); + } finally { + for (const child of children) if (child.exitCode === null) { child.kill(); await new Promise(resolve => child.once('exit', () => resolve())); } + control.close(); await h.close(); + for (const s of proxy.clients) s.terminate(); for (const s of upstreams) s.terminate(); + await new Promise(resolve => proxy.close(() => resolve())); + for (const s of server.clients) s.terminate(); await new Promise(resolve => server.close(() => resolve())); + rmSync(dir, { recursive: true, force: true }); + } + } +});