mirror of
https://github.com/block/buzz.git
synced 2026-11-09 02:41:57 +00:00
feat(beehive): reconcile policy-blocked intents with informed unchanged retry
Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
@@ -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 <number>`.
|
||||
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;
|
||||
|
||||
@@ -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 <host-name>`, `show`, `start`, `save`, `stop`, `quit`.
|
||||
TUI: `operations`, `reconcile`, `retry <number>`, `hosts`, `select <host-name>`, `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 <number>` 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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <host>, 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 <number>, hosts, select <host>, 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 <number>/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<string, unknown> = {};
|
||||
if (line === 'save') {
|
||||
|
||||
@@ -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(); },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(); },
|
||||
|
||||
@@ -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');
|
||||
|
||||
147
beehive/test/policy-tui.test.ts
Normal file
147
beehive/test/policy-tui.test.ts
Normal file
@@ -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<WebSocket>();
|
||||
const proxy = new WebSocketServer({ host: '127.0.0.1', port: 0 });
|
||||
await new Promise<void>(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<typeof spawn>[] = [];
|
||||
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<void>(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<void>(resolve => proxy.close(() => resolve()));
|
||||
for (const s of server.clients) s.terminate(); await new Promise<void>(resolve => server.close(() => resolve()));
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user