From b06c36d4ee211933ede01c9f83670dba24436482 Mon Sep 17 00:00:00 2001 From: sanil-23 Date: Tue, 28 Jul 2026 04:44:20 +0530 Subject: [PATCH 1/2] feat: stream a worker's live harness screen to the orchestrator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets the orchestrator watch a delegated task's actual terminal — the claude/codex session running it — instead of inferring progress from a status string every four seconds. Select a task in the Agents tab and its live screen replaces the transcript. The motivating case is the one the status plane structurally cannot show: a harness stopped on "Do you want to allow this command?" writes no transcript records, so it emits no status frames and reads as thinking until the task times out. On screen it is unmistakable in one frame. Modelled on mosh's State Synchronization Protocol. What crosses the wire is the screen — a grid of styled cells plus a cursor — carried as a diff from the state the viewer is known to hold, not a pty byte stream. Cost therefore tracks the screen rather than the output: sampling is timer-driven, an unchanged screen sends nothing, and a one-line paint on a 120x30 screen crosses as a one-row delta. Loss is self-healing — a frame that cannot be applied is never retried; the viewer resynchronises and the next full frame supersedes what was missed, which matters because mailbox ordering is not guaranteed. Streams are addressed by task id, not session id. A session serves one task at a time, so while a task runs the two identify the same thing — and the task id is the one both ends already hold, so nothing has to be published. It also makes authorization structural: the running-task record is keyed by (authenticated sender, task id), so resolving a subscription at all proves the caller dispatched it. The hub is a passive observer. There is no input and no resize message, so there is nothing to negotiate and no path from a subscriber into a session. Geometry belongs to the sender; a resize forces a full frame. Inbound envelopes now arrive over the relay's WebSocket rather than a GET poll — measured 1500ms to ~500ms. Acks stay on HTTPS (the stream is a fan-out, not a consumption), delivery is deduplicated by message id (a reconnect replays the mailbox snapshot, and a screen delta applied twice fails its base_seq check), and the fetch still runs when the socket is down, when the queue overflowed, when a frame will not parse, and on a 30s reconciliation. MEDULLA_INBOX_WS=0 restores pure polling. Ported onto main rather than replayed: main had since split transport, worker_loop, the hub runner/handle and the Agents tab into directory modules, replaced the `Relay` trait with the shared `Bridge` contract, and added workflow/system-info frame kinds. The 15 original commits could not apply through that, so this is one coherent change against the current structure, conforming to the newer module and test-layout rules. Verified on the ported tree: medulla 1460, medulla-tui 486, every integration suite green except the five pre-existing e2e_tinyplace failures that reproduce on an untouched main. Live against staging, two identities and a real pty: full frame at seq 1, delta applied at seq 2. --- src/sdk/src/bridge/routing.rs | 10 + src/sdk/src/bridge/tinyplace.rs | 4 + src/sdk/src/bridge/types.rs | 11 + src/sdk/src/daemon/capabilities/mod.rs | 1 + src/sdk/src/daemon/entry.rs | 15 +- src/sdk/src/daemon/listener/mod.rs | 313 +++++++++++++ src/sdk/src/daemon/listener/tests.rs | 218 +++++++++ src/sdk/src/daemon/mod.rs | 2 + src/sdk/src/daemon/providers/types.rs | 10 + src/sdk/src/daemon/runtime.rs | 23 + src/sdk/src/daemon/task_loop/run.rs | 13 + src/sdk/src/daemon/task_loop/workflow.rs | 1 + src/sdk/src/daemon/transport/mod.rs | 101 ++++- src/sdk/src/daemon/transport/types.rs | 9 + src/sdk/src/daemon/types.rs | 3 + src/sdk/src/hub/handle/mod.rs | 37 ++ src/sdk/src/hub/mod.rs | 2 + src/sdk/src/hub/runner/mod.rs | 8 + src/sdk/src/hub/runner/pump.rs | 60 ++- src/sdk/src/hub/runner/types.rs | 2 + src/sdk/src/hub/screens/mod.rs | 165 +++++++ src/sdk/src/hub/screens/tests.rs | 152 +++++++ src/sdk/src/runtime/backend/runtime.rs | 28 ++ src/sdk/src/runtime/mod.rs | 20 + src/sdk/src/sessions/manager/turns.rs | 1 + src/sdk/src/tinyplace/mod.rs | 9 + src/sdk/src/tinyplace/screen/apply.rs | 75 +++ src/sdk/src/tinyplace/screen/codec.rs | 38 ++ src/sdk/src/tinyplace/screen/diff.rs | 125 +++++ src/sdk/src/tinyplace/screen/mod.rs | 46 ++ src/sdk/src/tinyplace/screen/tests.rs | 414 +++++++++++++++++ src/sdk/src/tinyplace/screen/types.rs | 251 +++++++++++ src/sdk/tests/e2e_daemon_providers.rs | 6 + src/sdk/tests/e2e_daemon_router.rs | 1 + src/sdk/tests/live_inbox_push.rs | 219 +++++++++ src/tui/src/event_loop/cmd_dispatch/mod.rs | 18 + src/tui/src/ui/app/input.rs | 68 ++- src/tui/src/ui/app/keys/agents.rs | 3 +- src/tui/src/ui/app/keys/mod.rs | 4 + .../src/ui/app/render/agents/transcript.rs | 55 +++ src/tui/src/ui/app/state.rs | 1 + src/tui/src/ui/app/types.rs | 16 + src/tui/src/ui/mod.rs | 1 + src/tui/src/ui/screen/mod.rs | 93 ++++ src/tui/src/ui/screen/tests.rs | 108 +++++ src/tui/src/worker/executor/mod.rs | 7 + src/tui/src/worker/executor_tests/live.rs | 1 + src/tui/src/worker/executor_tests/mod.rs | 1 + src/tui/src/worker/mod.rs | 1 + src/tui/src/worker/stream/convert.rs | 82 ++++ src/tui/src/worker/stream/mod.rs | 30 ++ src/tui/src/worker/stream/router.rs | 147 ++++++ src/tui/src/worker/stream/sampler.rs | 234 ++++++++++ src/tui/src/worker/stream/tests.rs | 426 ++++++++++++++++++ src/tui/src/worker_loop/commands.rs | 19 +- src/tui/src/worker_loop/mod.rs | 21 +- src/tui/tests/e2e_screen_stream.rs | 305 +++++++++++++ src/tui/tests/live_screen_stream.rs | 355 +++++++++++++++ vendor/tinyplace | 2 +- 59 files changed, 4374 insertions(+), 17 deletions(-) create mode 100644 src/sdk/src/daemon/listener/mod.rs create mode 100644 src/sdk/src/daemon/listener/tests.rs create mode 100644 src/sdk/src/hub/screens/mod.rs create mode 100644 src/sdk/src/hub/screens/tests.rs create mode 100644 src/sdk/src/tinyplace/screen/apply.rs create mode 100644 src/sdk/src/tinyplace/screen/codec.rs create mode 100644 src/sdk/src/tinyplace/screen/diff.rs create mode 100644 src/sdk/src/tinyplace/screen/mod.rs create mode 100644 src/sdk/src/tinyplace/screen/tests.rs create mode 100644 src/sdk/src/tinyplace/screen/types.rs create mode 100644 src/sdk/tests/live_inbox_push.rs create mode 100644 src/tui/src/ui/screen/mod.rs create mode 100644 src/tui/src/ui/screen/tests.rs create mode 100644 src/tui/src/worker/stream/convert.rs create mode 100644 src/tui/src/worker/stream/mod.rs create mode 100644 src/tui/src/worker/stream/router.rs create mode 100644 src/tui/src/worker/stream/sampler.rs create mode 100644 src/tui/src/worker/stream/tests.rs create mode 100644 src/tui/tests/e2e_screen_stream.rs create mode 100644 src/tui/tests/live_screen_stream.rs diff --git a/src/sdk/src/bridge/routing.rs b/src/sdk/src/bridge/routing.rs index 5df44819..eca370b2 100644 --- a/src/sdk/src/bridge/routing.rs +++ b/src/sdk/src/bridge/routing.rs @@ -107,6 +107,16 @@ impl Bridge for RoutingBridge { messages } + async fn wait_for_inbox(&self, poll: std::time::Duration) { + // Only the remote side is worth blocking on: the local bus is + // in-process, so anything arriving there is already here by the time + // the next tick comes round. + match &self.remote { + Some(remote) => remote.wait_for_inbox(poll).await, + None => tokio::time::sleep(poll).await, + } + } + async fn request_contact(&self, peer: &str) -> Result<(), String> { if self.is_local(peer).await { return self.local.request_contact(peer).await; diff --git a/src/sdk/src/bridge/tinyplace.rs b/src/sdk/src/bridge/tinyplace.rs index d8cd797d..24b5e49d 100644 --- a/src/sdk/src/bridge/tinyplace.rs +++ b/src/sdk/src/bridge/tinyplace.rs @@ -45,6 +45,10 @@ impl Bridge for TinyplaceBridge { self.transport.drain_inbox(limit).await } + async fn wait_for_inbox(&self, poll: std::time::Duration) { + self.transport.wait_for_inbox(poll).await + } + async fn request_contact(&self, peer: &str) -> Result<(), String> { self.transport.request_contact(peer).await } diff --git a/src/sdk/src/bridge/types.rs b/src/sdk/src/bridge/types.rs index 32ebef68..07b5675f 100644 --- a/src/sdk/src/bridge/types.rs +++ b/src/sdk/src/bridge/types.rs @@ -74,6 +74,17 @@ pub trait Bridge: Send + Sync { /// Reset transport-specific session state for `peer`. async fn reset_session(&self, peer: &str); + + /// Wait until there may be inbound mail, or `poll` elapses. + /// + /// Defaulted to a plain sleep, which is exactly what every pump did before + /// there was anything to wait *on* — so a local or fake bridge needs no + /// changes. The tiny.place bridge overrides it to also return the moment + /// the relay's push channel delivers, which is what takes a pump's latency + /// floor off its poll interval. + async fn wait_for_inbox(&self, poll: std::time::Duration) { + tokio::time::sleep(poll).await; + } } /// A runtime-selected bridge without dynamic dispatch. diff --git a/src/sdk/src/daemon/capabilities/mod.rs b/src/sdk/src/daemon/capabilities/mod.rs index e1b7a3ce..ebc8d220 100644 --- a/src/sdk/src/daemon/capabilities/mod.rs +++ b/src/sdk/src/daemon/capabilities/mod.rs @@ -96,6 +96,7 @@ pub async fn probe_capabilities(options: ProbeOptions) -> AgentCapabilities { router: options.router.clone(), on_event: None, on_stdin: None, + on_session: None, }; let reply = match (options.run_task)(run_options).await { diff --git a/src/sdk/src/daemon/entry.rs b/src/sdk/src/daemon/entry.rs index c7875d60..58f64d2e 100644 --- a/src/sdk/src/daemon/entry.rs +++ b/src/sdk/src/daemon/entry.rs @@ -316,6 +316,16 @@ pub async fn run_daemon( let presence = spawn_presence_heartbeat(client.clone(), std::time::Duration::from_millis(poll_ms)); + // Best-effort push channel: it only shortens the wait between a peer + // sending and the serve loop looking, so a failure to open it costs latency + // and nothing else. Held to the end of the run; dropping it closes the + // socket and reverts to fetching. + let _push = crate::daemon::listener::ws_inbox_enabled(&env).then(|| { + transport.spawn_inbox_listener(Some(Arc::new(|line: &str| { + eprintln!("medulla daemon: {line}") + }))) + }); + if once { // Probe hook: accept pending contacts, drain the inbox once, wait for // every started task to settle, then exit. @@ -363,7 +373,10 @@ pub async fn run_daemon( log(&format!("periodic key maintenance failed: {e}")); } } - _ = tokio::time::sleep(poll) => { + // Returns early when the relay's push channel delivers, so a task + // frame is picked up at about a round trip rather than up to a poll + // interval later. The interval stays the correctness floor. + _ = transport.wait_for_inbox(poll) => { for message in transport.drain_inbox(50).await { let frame = decode_task_frame(&message.text); runtime.handle_message(message.from, message.text, frame); diff --git a/src/sdk/src/daemon/listener/mod.rs b/src/sdk/src/daemon/listener/mod.rs new file mode 100644 index 00000000..28b4a8bb --- /dev/null +++ b/src/sdk/src/daemon/listener/mod.rs @@ -0,0 +1,313 @@ +//! Envelopes delivered over the relay's push channel, instead of fetched. +//! +//! Every inbound path in medulla used to poll: `GET /messages`, sleep, repeat. +//! That put a floor under latency equal to the poll interval — 1s on a worker, +//! 1.5s on the hub — which a screen stream feels far more than a task frame +//! does. +//! +//! `GET /a2a/{id}/stream` upgrades to a WebSocket that opens with a snapshot of +//! the mailbox and then tails every envelope the relay accepts for us +//! (`PUT /messages` publishes to the same topic). So this holds that socket open +//! and hands the envelopes it carries straight to [`SignalTransport::drain_inbox`], +//! which decrypts and acknowledges them exactly as it did when it fetched them +//! itself. +//! +//! Three things make that safe, and none of them are optional: +//! +//! - **Acknowledgement still goes over HTTPS.** The stream is a fan-out, not a +//! consumption: the mailbox holds an envelope until `DELETE /messages/{id}`. +//! Reading one off the socket does not remove it. +//! - **Delivery is deduplicated by message id.** Reconnecting re-sends the +//! snapshot — up to 50 un-acked envelopes — and a reconciliation fetch can +//! race a delete that has not landed. Processing a task frame twice is merely +//! wasteful; processing a *screen delta* twice fails its `base_seq` check and +//! forces a resync, so this is what keeps the stream from sawing back and +//! forth. +//! - **The fetch never goes away.** It is skipped while the socket is healthy +//! and quiet, but still runs when the socket is down, when the delivery queue +//! overflowed, when a frame could not be parsed, and on a periodic +//! reconciliation regardless. Nothing is ever known *only* from the socket. +//! +//! `MEDULLA_INBOX_WS=0` disables the whole thing and restores pure polling. + +use std::collections::{HashMap, HashSet, VecDeque}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use tokio::sync::Notify; +use tokio::task::JoinHandle; + +use ::tinyplace::types::MessageEnvelope; +use ::tinyplace::TinyPlaceClient; + +/// How long to wait before re-opening a socket that closed or refused. +const REOPEN_DELAY: Duration = Duration::from_secs(5); + +/// How many consecutive failed opens before giving up for good. +/// +/// A backend that does not serve this endpoint would otherwise be reconnected +/// against forever. Polling still works, so giving up degrades latency rather +/// than function. +const MAX_CONSECUTIVE_FAILURES: u32 = 5; + +/// How many delivered envelopes to hold before falling back to fetching. +/// +/// Bounded because a drain loop that stalls must not let the queue grow without +/// limit. Overflow loses nothing: the envelopes are still in the mailbox until +/// they are acknowledged, so the fetch this forces picks them up. +const MAX_QUEUED: usize = 256; + +/// How many recently handled message ids to remember for deduplication. +/// +/// Comfortably more than the 50-envelope snapshot a reconnect replays, which is +/// the case it exists for. +const SEEN_CAPACITY: usize = 512; + +/// How often to fetch even while the socket looks healthy. +/// +/// Insurance, not the main path: it bounds how long an envelope could sit +/// unnoticed if the stream ever silently missed one. +pub const RECONCILE_INTERVAL: Duration = Duration::from_secs(30); + +/// Whether the push listener should be started at all. +/// +/// On by default; `MEDULLA_INBOX_WS=0` opts out, which is the escape hatch if +/// the socket ever misbehaves against a particular relay. +pub fn ws_inbox_enabled(env: &HashMap) -> bool { + !matches!( + env.get("MEDULLA_INBOX_WS").map(|v| v.trim()), + Some("0") | Some("false") | Some("off") + ) +} + +/// Envelopes the relay pushed, waiting to be decrypted. +/// +/// Cheap to clone; every clone shares one queue and one waiter. +#[derive(Clone, Default)] +pub struct PushInbox { + notify: Arc, + listening: Arc, + queued: Arc>>, + /// Set when the socket could not be trusted to have delivered everything — + /// a full queue, an unparseable frame, or a closed stream. + must_fetch: Arc, +} + +impl PushInbox { + /// An inbox nothing has been delivered to yet. + pub fn new() -> Self { + PushInbox::default() + } + + /// Queue envelopes the socket delivered and wake any waiting drain. + /// + /// Beyond [`MAX_QUEUED`] the remainder is dropped and a fetch is forced + /// instead — they are still in the mailbox, so nothing is lost by declining + /// to hold them here. + fn deliver(&self, envelopes: Vec) { + if envelopes.is_empty() { + return; + } + { + let mut queued = self.queued.lock().expect("push inbox lock"); + for envelope in envelopes { + if queued.len() >= MAX_QUEUED { + self.must_fetch.store(true, Ordering::Relaxed); + break; + } + queued.push_back(envelope); + } + } + self.notify.notify_one(); + } + + /// Wake a waiting drain without delivering anything, and make it fetch. + /// + /// Used whenever the socket cannot vouch for what it carried: a frame that + /// would not parse, or a stream that just closed. + fn nudge(&self) { + self.must_fetch.store(true, Ordering::Relaxed); + self.notify.notify_one(); + } + + /// Take everything delivered so far. + pub fn take(&self) -> Vec { + self.queued + .lock() + .expect("push inbox lock") + .drain(..) + .collect() + } + + /// Whether a fetch is owed, clearing the flag. + pub fn take_must_fetch(&self) -> bool { + self.must_fetch.swap(false, Ordering::Relaxed) + } + + /// Whether a socket is currently open and delivering. + /// + /// Distinguishes "quiet because nothing is happening" from "quiet because + /// the push channel is down and we are back to polling". + pub fn is_listening(&self) -> bool { + self.listening.load(Ordering::Relaxed) + } + + fn set_listening(&self, value: bool) { + self.listening.store(value, Ordering::Relaxed); + } + + /// Wait for a delivery, or for `poll` to elapse — whichever comes first. + /// + /// The timeout is not a fallback that can be dropped once the socket works: + /// it is the correctness floor. + pub async fn wait(&self, poll: Duration) { + tokio::select! { + _ = self.notify.notified() => {} + _ = tokio::time::sleep(poll) => {} + } + } +} + +/// Message ids already handled, so a redelivery is not processed twice. +/// +/// Bounded and insertion-ordered: the oldest id is forgotten once the set is +/// full, which is safe because a redelivery that old has long since been +/// acknowledged and will not be sent again. +#[derive(Clone, Default)] +pub struct SeenIds { + order: Arc, HashSet)>>, +} + +impl SeenIds { + /// An empty set. + pub fn new() -> Self { + SeenIds::default() + } + + /// Record `id`, reporting whether it is new. + /// + /// An empty id is always treated as new: the relay rejects those, so one + /// appearing here is malformed rather than a duplicate, and collapsing all + /// of them onto one another would drop real traffic. + pub fn insert(&self, id: &str) -> bool { + if id.is_empty() { + return true; + } + let mut guard = self.order.lock().expect("seen ids lock"); + let (order, set) = &mut *guard; + if !set.insert(id.to_string()) { + return false; + } + order.push_back(id.to_string()); + while order.len() > SEEN_CAPACITY { + if let Some(oldest) = order.pop_front() { + set.remove(&oldest); + } + } + true + } +} + +/// A running listener, stopped when this guard drops. +/// +/// A bare [`JoinHandle`] would be wrong here: dropping one *detaches* the task +/// rather than aborting it, so a listener tied to a session's lifetime would +/// outlive it and hold a socket open for a drain loop that no longer exists. +pub struct ListenerGuard(JoinHandle<()>); + +impl ListenerGuard { + /// Stop the listener now rather than at drop. + pub fn abort(&self) { + self.0.abort(); + } +} + +impl Drop for ListenerGuard { + fn drop(&mut self) { + self.0.abort(); + } +} + +/// Pull the envelopes out of one stream frame. +/// +/// Two shapes cross this socket: the snapshot the relay opens with, carrying the +/// mailbox as `data.messages`, and each live relay event, carrying one envelope +/// as `data`. Anything else — a keepalive, a frame kind added later — yields +/// nothing, and the caller treats that as a reason to fetch rather than as an +/// empty mailbox. +pub fn envelopes_in_frame(frame: &serde_json::Value) -> Option> { + match frame.get("type").and_then(|t| t.as_str())? { + "snapshot" => { + let messages = frame.get("data")?.get("messages")?; + serde_json::from_value::>(messages.clone()).ok() + } + "a2a.message" => { + let envelope = + serde_json::from_value::(frame.get("data")?.clone()).ok()?; + Some(vec![envelope]) + } + _ => None, + } +} + +/// Hold a push socket open for `agent_id`, delivering what it carries to `inbox`. +/// +/// Returns immediately; the work happens on the guarded task, and dropping the +/// guard closes the socket. Errors are not surfaced — the drain loop's fetch is +/// unaffected by this failing, which is the point. +pub fn spawn_inbox_listener( + client: TinyPlaceClient, + agent_id: String, + inbox: PushInbox, + log: Option, +) -> ListenerGuard { + ListenerGuard(tokio::spawn(async move { + let mut failures = 0u32; + loop { + match client.a2a.stream(&agent_id).connect().await { + Ok(mut connection) => { + failures = 0; + inbox.set_listening(true); + if let Some(log) = &log { + log("inbox: push channel open"); + } + while let Some(frame) = connection.recv().await { + match frame.as_ref().ok().and_then(envelopes_in_frame) { + Some(envelopes) => inbox.deliver(envelopes), + // A frame we could not read might have carried mail. + // Fetching is the only honest response. + None => inbox.nudge(), + } + } + inbox.set_listening(false); + if let Some(log) = &log { + log("inbox: push channel closed — fetching until it reopens"); + } + } + Err(error) => { + failures += 1; + if failures >= MAX_CONSECUTIVE_FAILURES { + // Say so once, with the reason. A silent fallback to + // fetching is indistinguishable from a relay that is + // simply quiet, and those want different fixes. + if let Some(log) = &log { + log(&format!( + "inbox: push channel unavailable after {failures} attempts ({error}) — fetching only" + )); + } + inbox.set_listening(false); + inbox.nudge(); + return; + } + } + } + // A socket that just dropped may have done so holding mail. + inbox.nudge(); + tokio::time::sleep(REOPEN_DELAY).await; + } + })) +} + +#[cfg(test)] +mod tests; diff --git a/src/sdk/src/daemon/listener/tests.rs b/src/sdk/src/daemon/listener/tests.rs new file mode 100644 index 00000000..c22ddae7 --- /dev/null +++ b/src/sdk/src/daemon/listener/tests.rs @@ -0,0 +1,218 @@ +//! Unit tests for the push inbox: what the socket's frames decode to, what the +//! delivery queue guarantees, and the deduplication that makes redelivery safe. +//! +//! The socket itself is not exercised here — that needs a relay, and the live +//! suites are where one belongs. What is testable offline is everything that +//! decides whether an envelope is processed, processed twice, or missed. + +use std::collections::HashMap; +use std::time::Duration; + +use super::{envelopes_in_frame, ws_inbox_enabled, PushInbox, SeenIds}; + +fn env(pairs: &[(&str, &str)]) -> HashMap { + pairs + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect() +} + +/// A relay envelope as it appears inside a stream frame. +fn envelope(id: &str) -> serde_json::Value { + serde_json::json!({ + "id": id, + "from": "peerA", + "to": "me", + "timestamp": "2026-01-01T00:00:00Z", + "deviceId": 1, + "type": "CIPHERTEXT", + "body": "Y2lwaGVy", + }) +} + +#[test] +fn the_push_channel_is_on_unless_explicitly_disabled() { + assert!(ws_inbox_enabled(&env(&[]))); + assert!(ws_inbox_enabled(&env(&[("MEDULLA_INBOX_WS", "1")]))); + for off in ["0", "false", "off", " off "] { + assert!( + !ws_inbox_enabled(&env(&[("MEDULLA_INBOX_WS", off)])), + "{off} should disable it" + ); + } +} + +// --- decoding what the socket carries -------------------------------------- + +#[test] +fn a_snapshot_frame_yields_the_whole_mailbox() { + // The relay opens every stream with the un-acked backlog. + let frame = serde_json::json!({ + "type": "snapshot", + "data": { "to": "me", "messages": [envelope("m1"), envelope("m2")] }, + "sentAt": "2026-01-01T00:00:00Z", + }); + let envelopes = envelopes_in_frame(&frame).expect("a snapshot decodes"); + assert_eq!(envelopes.len(), 2); + assert_eq!(envelopes[0].id, "m1"); + assert_eq!(envelopes[1].id, "m2"); +} + +#[test] +fn a_live_frame_yields_the_one_relayed_envelope() { + let frame = serde_json::json!({ + "type": "a2a.message", + "data": envelope("m3"), + "sentAt": "2026-01-01T00:00:00Z", + }); + let envelopes = envelopes_in_frame(&frame).expect("a live event decodes"); + assert_eq!(envelopes.len(), 1); + assert_eq!(envelopes[0].id, "m3"); + assert_eq!(envelopes[0].body, "Y2lwaGVy"); +} + +#[test] +fn an_empty_snapshot_is_an_empty_mailbox_not_a_failure() { + // Distinct from an unreadable frame: this one positively says "nothing + // here", so it must not force a fetch. + let frame = serde_json::json!({ + "type": "snapshot", + "data": { "to": "me", "messages": [] }, + }); + assert_eq!(envelopes_in_frame(&frame).map(|e| e.len()), Some(0)); +} + +#[test] +fn an_unreadable_frame_yields_nothing_so_the_caller_fetches() { + // A keepalive, a frame kind added later, or a malformed payload. None may + // be mistaken for an empty mailbox. + for frame in [ + serde_json::json!({ "type": "some.future.event", "data": {} }), + serde_json::json!({ "data": { "messages": [] } }), + serde_json::json!({ "type": "snapshot" }), + serde_json::json!({ "type": "a2a.message", "data": "not an envelope" }), + serde_json::json!("not an object"), + ] { + assert!( + envelopes_in_frame(&frame).is_none(), + "should not decode: {frame}" + ); + } +} + +// --- the delivery queue ---------------------------------------------------- + +#[tokio::test] +async fn a_delivery_wakes_a_waiter_and_can_be_taken() { + let inbox = PushInbox::new(); + let deliverer = inbox.clone(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(10)).await; + deliverer.deliver(vec![serde_json::from_value(envelope("m1")).unwrap()]); + }); + + tokio::time::timeout(Duration::from_secs(5), inbox.wait(Duration::from_secs(60))) + .await + .expect("the delivery should end the wait, not the poll"); + let taken = inbox.take(); + assert_eq!(taken.len(), 1); + assert!(inbox.take().is_empty(), "taking twice must not duplicate"); +} + +#[tokio::test] +async fn a_delivery_with_nobody_waiting_is_not_lost() { + // An envelope arriving mid-drain must not wait out a whole poll interval. + let inbox = PushInbox::new(); + inbox.deliver(vec![serde_json::from_value(envelope("m1")).unwrap()]); + tokio::time::timeout(Duration::from_secs(5), inbox.wait(Duration::from_secs(60))) + .await + .expect("the earlier delivery should still be pending"); + assert_eq!(inbox.take().len(), 1); +} + +#[tokio::test] +async fn the_poll_still_ends_the_wait_when_nothing_arrives() { + // The timeout is the correctness floor, not a fallback. + let inbox = PushInbox::new(); + tokio::time::timeout( + Duration::from_secs(5), + inbox.wait(Duration::from_millis(20)), + ) + .await + .expect("the poll must still fire on its own"); +} + +#[test] +fn an_overflowing_queue_forces_a_fetch_rather_than_growing() { + // A stalled drain must not let this grow without bound. Nothing is lost: + // the envelopes are still in the mailbox until they are acknowledged. + let inbox = PushInbox::new(); + let flood: Vec<_> = (0..1_000) + .map(|n| serde_json::from_value(envelope(&format!("m{n}"))).unwrap()) + .collect(); + inbox.deliver(flood); + + assert!(inbox.take().len() <= super::MAX_QUEUED); + assert!( + inbox.take_must_fetch(), + "overflow must leave a fetch owed so the remainder is picked up" + ); +} + +#[test] +fn a_nudge_owes_a_fetch_without_delivering_anything() { + let inbox = PushInbox::new(); + inbox.nudge(); + assert!(inbox.take().is_empty()); + assert!(inbox.take_must_fetch()); + assert!(!inbox.take_must_fetch(), "the flag is cleared by taking it"); +} + +#[test] +fn a_fresh_inbox_is_not_listening_and_owes_nothing() { + let inbox = PushInbox::new(); + assert!(!inbox.is_listening()); + assert!(!inbox.take_must_fetch()); +} + +// --- deduplication --------------------------------------------------------- + +#[test] +fn the_same_message_is_only_accepted_once() { + // The case this exists for: a reconnect replays the snapshot, so an + // envelope legitimately arrives twice. Handing a screen delta to the fold + // twice would fail its base_seq check and force a needless resync. + let seen = SeenIds::new(); + assert!(seen.insert("m1")); + assert!(!seen.insert("m1")); + assert!(seen.insert("m2")); +} + +#[test] +fn an_empty_id_is_never_treated_as_a_duplicate() { + // The relay rejects empty ids, so one appearing here is malformed rather + // than a repeat — collapsing them all onto each other would drop real + // traffic. + let seen = SeenIds::new(); + assert!(seen.insert("")); + assert!(seen.insert("")); +} + +#[test] +fn the_seen_set_is_bounded_and_forgets_the_oldest() { + // A long-lived daemon must not accumulate every id it ever saw. Forgetting + // is safe: an envelope that old was acknowledged long ago and will not be + // redelivered. + let seen = SeenIds::new(); + for n in 0..(super::SEEN_CAPACITY + 10) { + assert!(seen.insert(&format!("m{n}"))); + } + assert!( + seen.insert("m0"), + "the oldest id should have been forgotten" + ); + assert!( + !seen.insert(&format!("m{}", super::SEEN_CAPACITY + 5)), + "a recent id is still remembered" + ); +} diff --git a/src/sdk/src/daemon/mod.rs b/src/sdk/src/daemon/mod.rs index 955ba032..f27a3bd2 100644 --- a/src/sdk/src/daemon/mod.rs +++ b/src/sdk/src/daemon/mod.rs @@ -26,6 +26,7 @@ pub mod capabilities; pub mod dir_context; pub mod embedded; +pub mod listener; pub mod mappers; pub mod pairing; pub mod providers; @@ -42,5 +43,6 @@ mod types; mod tests; pub use entry::run_daemon; +pub use listener::{spawn_inbox_listener, ws_inbox_enabled, ListenerGuard, PushInbox, SeenIds}; pub use status::{status_detail, work_detail, TOOL_PREFIX}; pub use types::{DaemonConfig, DaemonRuntime, LogFn, NowFn, SendFn}; diff --git a/src/sdk/src/daemon/providers/types.rs b/src/sdk/src/daemon/providers/types.rs index 55d73be2..f8718239 100644 --- a/src/sdk/src/daemon/providers/types.rs +++ b/src/sdk/src/daemon/providers/types.rs @@ -22,6 +22,14 @@ pub type OnEvent = Box; /// A one-shot registration of a child-stdin sender for `input` forwarding. pub type OnStdin = Box) + Send>; +/// Called once with the worker-local session id, as soon as the executor has +/// opened (or claimed) the session that will run the task. +/// +/// Reported early rather than with the result: the point of knowing it is to +/// watch the task *while* it runs, which a session id delivered at the end +/// cannot serve. +pub type OnSession = Box; + /// A PATH-lookup predicate (injectable for tests). pub type ExistsOnPath = Box bool + Send + Sync>; @@ -117,6 +125,8 @@ pub struct RunTaskOptions { pub on_event: Option, /// Register a stdin channel for `input`-frame forwarding into the child. pub on_stdin: Option, + /// Reports the session serving this task, once it exists. + pub on_session: Option, } /// The outcome of a headless run. diff --git a/src/sdk/src/daemon/runtime.rs b/src/sdk/src/daemon/runtime.rs index c0b6ffaf..587032de 100644 --- a/src/sdk/src/daemon/runtime.rs +++ b/src/sdk/src/daemon/runtime.rs @@ -127,6 +127,29 @@ impl DaemonRuntime { } /// The map key for a running task: `sender + taskId`. + /// The worker-local session running `task_id` for `from`, if any. + /// + /// The key is `(authenticated sender, task id)`, which is what makes this + /// safe to expose: a peer can only ever resolve a task it dispatched + /// itself, so screen subscriptions need no ownership check of their own. + /// `None` once the task settles — the record is removed then, so a stream + /// keyed on it ends with the work rather than outliving it. + pub fn session_for_task(&self, from: &str, task_id: &str) -> Option { + self.inner + .running + .lock() + .unwrap() + .get(&Self::task_key(from, task_id)) + .and_then(|task| task.session_id.clone()) + } + + /// Record the session an executor opened for a running task. + pub(super) fn record_task_session(&self, key: &str, session_id: String) { + if let Some(task) = self.inner.running.lock().unwrap().get_mut(key) { + task.session_id = Some(session_id); + } + } + pub(super) fn task_key(from: &str, task_id: &str) -> String { format!("{from} {task_id}") } diff --git a/src/sdk/src/daemon/task_loop/run.rs b/src/sdk/src/daemon/task_loop/run.rs index b57b5bf2..27d34df9 100644 --- a/src/sdk/src/daemon/task_loop/run.rs +++ b/src/sdk/src/daemon/task_loop/run.rs @@ -90,6 +90,7 @@ impl DaemonRuntime { correlation_id: correlation.clone(), stdin: None, pending_input: Vec::new(), + session_id: None, }, ); self.inner.admitted.fetch_add(1, Ordering::SeqCst); @@ -177,6 +178,16 @@ impl DaemonRuntime { }) as Box) + Send> }; + // Reported as soon as the executor opens a session, not with the + // result: the point of knowing it is to watch the task while it runs. + let on_session = { + let this = self.clone(); + let key = key.clone(); + Box::new(move |session_id: String| { + this.record_task_session(&key, session_id); + }) as Box + }; + let options = RunTaskOptions { // The *authenticated* sender, never anything from the frame body: a // frame cannot be trusted to name its own author, and this value @@ -201,6 +212,7 @@ impl DaemonRuntime { router: self.inner.config.router.clone(), on_event: Some(on_event), on_stdin: Some(on_stdin), + on_session: Some(on_session), }; // Consume status details in order while the task runs. @@ -331,6 +343,7 @@ impl DaemonRuntime { router: self.inner.config.router.clone(), on_event: None, on_stdin: None, + on_session: None, }; let result = (self.inner.run_task)(options).await; match result { diff --git a/src/sdk/src/daemon/task_loop/workflow.rs b/src/sdk/src/daemon/task_loop/workflow.rs index b27f947a..b9da274c 100644 --- a/src/sdk/src/daemon/task_loop/workflow.rs +++ b/src/sdk/src/daemon/task_loop/workflow.rs @@ -71,6 +71,7 @@ impl HarnessDispatch for RuntimeDispatch { // harness's token-level chatter as well would double-report it. on_event: None, on_stdin: None, + on_session: None, }; let result = (inner.run_task)(options).await.map_err(RunError::Worker)?; diff --git a/src/sdk/src/daemon/transport/mod.rs b/src/sdk/src/daemon/transport/mod.rs index 93632d60..a2d05b4a 100644 --- a/src/sdk/src/daemon/transport/mod.rs +++ b/src/sdk/src/daemon/transport/mod.rs @@ -94,6 +94,11 @@ impl SignalTransport { our_agent_id, our_ed25519_pub: *signer.public_key(), lock: Arc::new(Mutex::new(())), + push: crate::daemon::listener::PushInbox::new(), + seen: crate::daemon::listener::SeenIds::new(), + last_fetch: Arc::new(std::sync::Mutex::new( + std::time::Instant::now() - crate::daemon::listener::RECONCILE_INTERVAL, + )), } } @@ -102,6 +107,59 @@ impl SignalTransport { &self.our_agent_id } + /// Open the relay's push channel, so envelopes are delivered here instead + /// of fetched on the poll interval. + /// + /// Best-effort: the socket carries no state this transport depends on — the + /// mailbox still holds every envelope until it is acknowledged — so a + /// failure to open it costs latency and nothing else. Dropping the returned + /// guard closes it and reverts to fetching. + pub fn spawn_inbox_listener( + &self, + log: Option, + ) -> crate::daemon::listener::ListenerGuard { + crate::daemon::listener::spawn_inbox_listener( + self.client.clone(), + self.our_agent_id.clone(), + self.push.clone(), + log, + ) + } + + /// Whether the push channel is currently open. + pub fn is_push_listening(&self) -> bool { + self.push.is_listening() + } + + /// Wait until envelopes have been delivered, or `poll` elapses. + /// + /// Replaces a bare `sleep(poll)` in a drain loop. The timeout remains the + /// correctness floor — a delivery is an optimisation, never the sole way + /// something is learned. + pub async fn wait_for_inbox(&self, poll: std::time::Duration) { + self.push.wait(poll).await; + } + + /// Whether this drain should fetch the mailbox over HTTPS. + /// + /// Skipped only when the socket is delivering, owes nothing, and was + /// reconciled recently. Every other case fetches, which is what keeps the + /// socket an optimisation rather than a second source of truth. + fn should_fetch(&self) -> bool { + if !self.push.is_listening() || self.push.take_must_fetch() { + return true; + } + let mut last = self.last_fetch.lock().expect("last fetch lock"); + if last.elapsed() >= crate::daemon::listener::RECONCILE_INTERVAL { + *last = std::time::Instant::now(); + return true; + } + // The socket is up, owes nothing, and was reconciled recently — so the + // mailbox is quiet and asking again would just be the poll this + // replaced. + false + } + /// Drop the local Signal session with `peer` so the next send re-runs X3DH /// (a fresh `PREKEY_BUNDLE`). Used by senders to recover from a one-sided /// session — e.g. after the peer restarted and lost its ratchet state, our @@ -298,19 +356,42 @@ impl SignalTransport { /// Destructively read the inbox (up to `limit`): decrypt each message, hand /// back the plaintext, and acknowledge (delete) every delivered message so /// the relay does not redeliver it. + /// + /// Envelopes the push socket already delivered are taken from it rather than + /// fetched, which is what removes the poll interval from the latency of a + /// task frame or a screen delta. The fetch is skipped only while the socket + /// is healthy, has nothing outstanding, and has been reconciled against + /// recently — so an envelope is never known *only* from the socket. + /// + /// Every envelope is deduplicated by message id before decryption. A + /// reconnect replays the mailbox snapshot and a reconciliation fetch can + /// race a delete that has not landed, so the same envelope legitimately + /// arrives twice; handing a screen delta to the fold twice would fail its + /// `base_seq` check and force a needless resync. pub async fn drain_inbox(&self, limit: i64) -> Vec { let _guard = self.lock.lock().await; - let response = match self - .client - .messages - .list(&self.our_agent_id, Some(limit)) - .await - { - Ok(response) => response, - Err(_) => return Vec::new(), - }; + + let mut envelopes = self.push.take(); + if self.should_fetch() { + match self + .client + .messages + .list(&self.our_agent_id, Some(limit)) + .await + { + Ok(response) => envelopes.extend(response.messages), + // A failed fetch is not fatal when the socket is up: it has + // delivered what it delivered, and the next drain retries. + Err(_) if !envelopes.is_empty() => {} + Err(_) => return Vec::new(), + } + } + let mut out = Vec::new(); - for message in response.messages { + for message in envelopes { + if !self.seen.insert(&message.id) { + continue; // already handled; the ack for it is already in flight + } match self.decrypt(&message).await { Ok(text) => out.push(InboundMessage { from: message.from.clone(), diff --git a/src/sdk/src/daemon/transport/types.rs b/src/sdk/src/daemon/transport/types.rs index a4a3c6d8..b95a7478 100644 --- a/src/sdk/src/daemon/transport/types.rs +++ b/src/sdk/src/daemon/transport/types.rs @@ -17,4 +17,13 @@ pub struct SignalTransport { pub(super) our_ed25519_pub: [u8; 32], /// Serializes ratchet-touching ops (encrypt/decrypt) on this wallet. pub(super) lock: Arc>, + /// Envelopes the relay pushed over the stream socket, waiting to be + /// decrypted by the next drain. + pub(super) push: crate::daemon::listener::PushInbox, + /// Message ids already handled, so a snapshot replay or a reconciliation + /// fetch racing a delete cannot process one twice. + pub(super) seen: crate::daemon::listener::SeenIds, + /// When the mailbox was last fetched over HTTPS, so reconciliation can run + /// on a schedule even while the socket looks healthy. + pub(super) last_fetch: Arc>, } diff --git a/src/sdk/src/daemon/types.rs b/src/sdk/src/daemon/types.rs index 2afe0f6d..e19ad5e0 100644 --- a/src/sdk/src/daemon/types.rs +++ b/src/sdk/src/daemon/types.rs @@ -92,6 +92,9 @@ pub(super) struct RunningTask { pub(super) stdin: Option>, /// Input buffered before the child's stdin became available. pub(super) pending_input: Vec, + /// The worker-local session this task is running in, once the executor has + /// opened one. What a screen subscription naming this task resolves to. + pub(super) session_id: Option, /// Stops this task. Held per-task rather than only in the global controller /// map so an `abort` frame can cancel exactly the task it names. pub(super) abort: super::providers::Abort, diff --git a/src/sdk/src/hub/handle/mod.rs b/src/sdk/src/hub/handle/mod.rs index a08b3585..66b374e1 100644 --- a/src/sdk/src/hub/handle/mod.rs +++ b/src/sdk/src/hub/handle/mod.rs @@ -74,6 +74,43 @@ pub(super) fn cache_system_info_if_current( } impl HubHandle { + /// The worker screens this hub is holding, for the view that renders them. + pub fn screens(&self) -> super::ScreenStore { + self.runner.screens() + } + + /// Ask `worker` to start streaming the screen of the session running + /// `task_id`, and to send a full frame. + /// + /// Always a resync: the hub may hold a stale screen from an earlier + /// subscription, and a delta against that would apply to the wrong base. + pub async fn watch(&self, worker: &str, task_id: &str) -> Result<(), String> { + let body = + crate::tinyplace::encode_screen_message(&crate::tinyplace::ScreenMessage::Subscribe { + task_id: task_id.to_string(), + max_fps: 1, + resync: true, + }); + (self.log)(&format!("hub: watching task {task_id} on {worker}")); + self.relay.send(worker, &body).await + } + + /// Ask `worker` to stop streaming `task_id`, and drop what we hold. + /// + /// Forgotten locally even when the request fails to send: a pane left + /// showing a screen that will never update again reads as a hung worker + /// rather than an ended subscription. + pub async fn unwatch(&self, worker: &str, task_id: &str) -> Result<(), String> { + let body = crate::tinyplace::encode_screen_message( + &crate::tinyplace::ScreenMessage::Unsubscribe { + task_id: task_id.to_string(), + }, + ); + let sent = self.relay.send(worker, &body).await; + self.runner.screens().forget(worker, task_id); + sent + } + /// Build a handle from its wiring. pub(super) fn new(wiring: HandleWiring) -> Self { HubHandle { diff --git a/src/sdk/src/hub/mod.rs b/src/sdk/src/hub/mod.rs index cb019e99..92d47d4a 100644 --- a/src/sdk/src/hub/mod.rs +++ b/src/sdk/src/hub/mod.rs @@ -16,6 +16,7 @@ mod probe; mod relay; mod roster; mod runner; +mod screens; mod socket; mod types; @@ -28,4 +29,5 @@ pub use handle::HubHandle; pub use relay::Relay; pub use roster::HubWorker; pub use runner::TaskRunner; +pub use screens::{ScreenStore, WatchedScreen}; pub use types::{stderr_log, HubLog, RosterSink, RunError, TaskOutcome, TaskRequest}; diff --git a/src/sdk/src/hub/runner/mod.rs b/src/sdk/src/hub/runner/mod.rs index ba5306b9..e20b1ec4 100644 --- a/src/sdk/src/hub/runner/mod.rs +++ b/src/sdk/src/hub/runner/mod.rs @@ -134,6 +134,11 @@ impl TaskRunner { Self::build(relay, poll, ACK_WINDOW, idle_window, None, None) } + /// The screens this hub is holding, for the view that renders them. + pub fn screens(&self) -> super::ScreenStore { + self.screens.clone() + } + fn build( relay: Arc, poll: Duration, @@ -145,6 +150,7 @@ impl TaskRunner { let waiters: Waiters = Arc::new(Mutex::new(HashMap::new())); let system_info_waiters: SystemInfoWaiters = Arc::new(Mutex::new(HashMap::new())); let capabilities_waiters: CapabilitiesWaiters = Arc::new(Mutex::new(HashMap::new())); + let screens = super::ScreenStore::new(); let pump = tokio::spawn(pump::pump_loop( relay.clone(), waiters.clone(), @@ -153,9 +159,11 @@ impl TaskRunner { poll, log, activity, + screens.clone(), )); TaskRunner { relay, + screens, waiters, system_info_waiters, capabilities_waiters, diff --git a/src/sdk/src/hub/runner/pump.rs b/src/sdk/src/hub/runner/pump.rs index feaac100..dfbddd98 100644 --- a/src/sdk/src/hub/runner/pump.rs +++ b/src/sdk/src/hub/runner/pump.rs @@ -22,6 +22,13 @@ use super::{CapabilitiesWaiters, SystemInfoWaiters, Waiters}; /// How many inbound messages to drain per pump tick. const DRAIN_LIMIT: i64 = 50; +/// Frames per second asked for when subscribing to a worker's screen. +/// +/// One, because the pump learns of a frame no faster than the relay pushes it: +/// a higher rate would spend a worker's ratchet advances and this hub's drains +/// on frames that arrive in a burst and are stale on arrival. +const DEFAULT_SCREEN_FPS: u8 = 1; + /// Route one decoded frame from `from` to its waiter, keyed by `correlationId` /// (falling back to `taskId`). Any frame pokes the waiter's `activity` (sign of /// life); `reply`/`error` then settle and remove it; `status` forwards; `ack` @@ -181,6 +188,47 @@ async fn expected_sender( /// The pump: drain the inbox, decode each message, route it, then sleep. Runs /// until the owning [`TaskRunner`](super::TaskRunner) is dropped (which aborts it). +/// Fold one screen frame into the store, asking the worker to resynchronise +/// when it cannot be applied. +/// +/// The resync request is sent from here rather than left to the store because +/// the store has no transport — and a swallowed `NeedsResync` is the one +/// failure that looks like nothing at all: the pane simply stops updating, with +/// no error anywhere. +/// +/// Anything other than a frame is ignored. The hub is the viewer; a subscribe +/// or an ack arriving here is a peer with the protocol backwards. +async fn route_screen( + relay: &dyn Relay, + screens: &crate::hub::ScreenStore, + from: &str, + message: crate::tinyplace::ScreenMessage, + log: &Option, +) { + let crate::tinyplace::ScreenMessage::Frame(frame) = message else { + return; + }; + let task_id = frame.task_id.clone(); + if screens.apply(from, &frame, crate::clock::now_millis()) + == crate::tinyplace::ApplyOutcome::NeedsResync + { + if let Some(log) = log { + log(&format!( + "hub ← screen {task_id} from {from}: out of step at seq {} — resyncing", + frame.seq + )); + } + let body = + crate::tinyplace::encode_screen_message(&crate::tinyplace::ScreenMessage::Subscribe { + task_id, + max_fps: DEFAULT_SCREEN_FPS, + resync: true, + }); + let _ = relay.send(from, &body).await; + } +} + +#[allow(clippy::too_many_arguments)] pub(super) async fn pump_loop( relay: Arc, waiters: Waiters, @@ -189,9 +237,16 @@ pub(super) async fn pump_loop( poll: Duration, log: Option, activity: Option, + screens: crate::hub::ScreenStore, ) { loop { for msg in relay.drain_inbox(DRAIN_LIMIT).await { + // Screen frames are claimed first — they share this channel with + // task frames and would otherwise be dropped as unrecognised. + if let Some(screen) = crate::tinyplace::parse_screen_message(&msg.text) { + route_screen(relay.as_ref(), &screens, &msg.from, screen, &log).await; + continue; + } if let Some(frame) = decode_task_frame(&msg.text) { route_frame( &waiters, @@ -205,6 +260,9 @@ pub(super) async fn pump_loop( .await; } } - tokio::time::sleep(poll).await; + // Not a bare sleep: this also returns the moment the relay's push + // channel delivers, so a worker's reply or screen frame is routed at + // about a round trip instead of up to a full poll interval later. + relay.wait_for_inbox(poll).await; } } diff --git a/src/sdk/src/hub/runner/types.rs b/src/sdk/src/hub/runner/types.rs index 044b5082..584e0782 100644 --- a/src/sdk/src/hub/runner/types.rs +++ b/src/sdk/src/hub/runner/types.rs @@ -61,6 +61,8 @@ pub(super) struct AbortGuard { /// dispatches; dropping it aborts the pump. pub struct TaskRunner { pub(super) relay: Arc, + /// The worker screens this hub is watching, filled by the pump. + pub(super) screens: crate::hub::ScreenStore, pub(super) waiters: Waiters, /// System-information probes waiting for a worker response. pub(super) system_info_waiters: SystemInfoWaiters, diff --git a/src/sdk/src/hub/screens/mod.rs b/src/sdk/src/hub/screens/mod.rs new file mode 100644 index 00000000..74833049 --- /dev/null +++ b/src/sdk/src/hub/screens/mod.rs @@ -0,0 +1,165 @@ +//! What the hub currently sees of its workers' screens. +//! +//! The receiving half of `medulla.screen.v1`. The pump folds inbound frames in +//! here and the Agents view reads them out, which is the same shape as +//! [`ActivityLog`](super::ActivityLog): the hub already sees everything, so +//! recording it needs no backend change and no new protocol. +//! +//! Deliberately a small, bounded cache rather than a history. A screen answers +//! "what is this worker doing *now*"; there is nothing to be learned from the +//! one it showed a minute ago, and a hub watching a fan-out must not accumulate +//! a framebuffer per worker forever. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use crate::tinyplace::{apply_frame, ApplyOutcome, ScreenFrame, ScreenGrid, ScreenView}; + +/// How many screens to keep. Beyond this the least recently updated is dropped. +/// +/// A watcher looks at one screen at a time; this is sized so switching between a +/// handful of workers does not force a resync every time, not so a fleet can be +/// held resident. +const CAPACITY: usize = 8; + +/// One worker session's screen, as the hub currently holds it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WatchedScreen { + /// The worker's tiny.place address. + pub worker: String, + /// The task whose session this shows. + pub task_id: String, + /// The synchronised screen. + pub grid: ScreenGrid, + /// The sequence `grid` reflects. + pub seq: i64, + /// Epoch ms when the last frame was applied — what "last frame 0.4s ago" + /// reads from, and the only way to tell a still screen from a dead stream. + pub updated_at: i64, +} + +/// The hub's live screens, keyed by worker and session. +/// +/// Cheap to clone; every clone reads and writes the same map. +#[derive(Clone, Default)] +pub struct ScreenStore { + screens: Arc>>, +} + +impl ScreenStore { + /// An empty store. + pub fn new() -> Self { + ScreenStore::default() + } + + /// Fold `frame` from `worker` into the screen it belongs to. + /// + /// Returns [`ApplyOutcome::NeedsResync`] when the frame cannot be applied + /// against what is held — a gap, a resize, or a first delta with no full + /// frame before it. The caller must then ask the worker to resynchronise; + /// the store deliberately does not, because it has no transport and + /// swallowing the condition here would leave a view silently frozen. + /// + /// A rejected frame leaves the existing screen untouched, so what is on + /// display stays a screen the worker really showed rather than a partly + /// patched one. + pub fn apply(&self, worker: &str, frame: &ScreenFrame, now: i64) -> ApplyOutcome { + let key = (worker.to_string(), frame.task_id.clone()); + let mut screens = self.screens.lock().expect("screen store lock"); + + let mut view = screens.get(&key).map(|held| ScreenView { + grid: held.grid.clone(), + seq: held.seq, + }); + let outcome = apply_frame(&mut view, frame); + if outcome == ApplyOutcome::Applied { + if let Some(view) = view { + screens.insert( + key, + WatchedScreen { + worker: worker.to_string(), + task_id: frame.task_id.clone(), + grid: view.grid, + seq: view.seq, + updated_at: now, + }, + ); + evict_stalest(&mut screens); + } + } + outcome + } + + /// The screen held for one worker session, if any. + pub fn get(&self, worker: &str, task_id: &str) -> Option { + self.screens + .lock() + .expect("screen store lock") + .get(&(worker.to_string(), task_id.to_string())) + .cloned() + } + + /// Every screen held for `worker`, most recently updated first. + pub fn for_worker(&self, worker: &str) -> Vec { + let mut found: Vec = self + .screens + .lock() + .expect("screen store lock") + .values() + .filter(|held| held.worker == worker) + .cloned() + .collect(); + found.sort_by_key(|held| std::cmp::Reverse(held.updated_at)); + found + } + + /// Everything held, most recently updated first. + pub fn snapshot(&self) -> Vec { + let mut all: Vec = self + .screens + .lock() + .expect("screen store lock") + .values() + .cloned() + .collect(); + all.sort_by_key(|held| std::cmp::Reverse(held.updated_at)); + all + } + + /// Drop a screen — after unsubscribing, or when its worker goes away. + pub fn forget(&self, worker: &str, task_id: &str) { + self.screens + .lock() + .expect("screen store lock") + .remove(&(worker.to_string(), task_id.to_string())); + } + + /// How many screens are held. + pub fn len(&self) -> usize { + self.screens.lock().expect("screen store lock").len() + } + + /// Whether nothing is held. + pub fn is_empty(&self) -> bool { + self.len() == 0 + } +} + +/// Drop the least recently updated screen while over capacity. +fn evict_stalest(screens: &mut HashMap<(String, String), WatchedScreen>) { + while screens.len() > CAPACITY { + let stalest = screens + .iter() + .min_by_key(|(_, held)| held.updated_at) + .map(|(key, _)| key.clone()); + match stalest { + Some(key) => { + screens.remove(&key); + } + None => return, + } + } +} + +#[cfg(test)] +mod tests; diff --git a/src/sdk/src/hub/screens/tests.rs b/src/sdk/src/hub/screens/tests.rs new file mode 100644 index 00000000..4889858e --- /dev/null +++ b/src/sdk/src/hub/screens/tests.rs @@ -0,0 +1,152 @@ +//! Unit tests for the hub's screen cache. + +use super::{ScreenStore, WatchedScreen}; +use crate::tinyplace::{build_frame, ApplyOutcome, FrameDecision, ScreenGrid, ScreenRun}; + +/// A one-row grid holding `text`. +fn grid(text: &str) -> ScreenGrid { + ScreenGrid { + cols: text.len() as u16, + rows: 1, + lines: vec![vec![ScreenRun::plain(text)]], + cursor: (0, 0), + hide_cursor: false, + } +} + +/// The frame carrying `next` to a viewer holding `previous`. +fn frame( + previous: Option<&ScreenGrid>, + next: &ScreenGrid, + task: &str, + seq: i64, + base: i64, +) -> crate::tinyplace::ScreenFrame { + match build_frame(previous, next, task, seq, base) { + FrameDecision::Send(frame) => frame, + FrameDecision::Unchanged => panic!("expected a frame"), + } +} + +#[test] +fn a_full_frame_establishes_a_screen() { + let store = ScreenStore::new(); + let screen = grid("hello"); + let outcome = store.apply("workerA", &frame(None, &screen, "w_1", 1, 0), 100); + + assert_eq!(outcome, ApplyOutcome::Applied); + let held = store.get("workerA", "w_1").expect("a screen"); + assert_eq!(held.grid, screen); + assert_eq!(held.seq, 1); + assert_eq!(held.updated_at, 100); +} + +#[test] +fn a_delta_advances_the_held_screen() { + let store = ScreenStore::new(); + let first = grid("hello"); + let second = grid("world"); + store.apply("workerA", &frame(None, &first, "w_1", 1, 0), 100); + let outcome = store.apply("workerA", &frame(Some(&first), &second, "w_1", 2, 1), 200); + + assert_eq!(outcome, ApplyOutcome::Applied); + let held = store.get("workerA", "w_1").expect("a screen"); + assert_eq!(held.grid, second); + assert_eq!(held.seq, 2); + assert_eq!(held.updated_at, 200); +} + +#[test] +fn a_gap_asks_to_resync_and_leaves_the_held_screen_alone() { + // What is on display must stay a screen the worker really showed, never a + // partly patched one. + let store = ScreenStore::new(); + let first = grid("hello"); + let second = grid("world"); + store.apply("workerA", &frame(None, &first, "w_1", 1, 0), 100); + + // A delta claiming to follow seq 7 when the store holds seq 1. + let mut orphan = frame(Some(&first), &second, "w_1", 8, 7); + orphan.base_seq = 7; + let outcome = store.apply("workerA", &orphan, 200); + + assert_eq!(outcome, ApplyOutcome::NeedsResync); + let held = store.get("workerA", "w_1").expect("the screen survives"); + assert_eq!(held.grid, first, "the stale screen is kept intact"); + assert_eq!(held.seq, 1); + assert_eq!(held.updated_at, 100, "a rejected frame is not an update"); +} + +#[test] +fn a_first_delta_with_nothing_held_asks_to_resync() { + let store = ScreenStore::new(); + let first = grid("hello"); + let second = grid("world"); + let outcome = store.apply("workerA", &frame(Some(&first), &second, "w_1", 2, 1), 100); + assert_eq!(outcome, ApplyOutcome::NeedsResync); + assert!(store.is_empty(), "nothing should be recorded"); +} + +#[test] +fn screens_are_keyed_by_worker_and_session() { + // Two workers can each run a session called `w_1`; they must not collide. + let store = ScreenStore::new(); + let a = grid("from A"); + let b = grid("from B"); + store.apply("workerA", &frame(None, &a, "w_1", 1, 0), 100); + store.apply("workerB", &frame(None, &b, "w_1", 1, 0), 100); + + assert_eq!(store.len(), 2); + assert_eq!(store.get("workerA", "w_1").unwrap().grid, a); + assert_eq!(store.get("workerB", "w_1").unwrap().grid, b); +} + +#[test] +fn for_worker_returns_only_that_workers_screens_newest_first() { + let store = ScreenStore::new(); + store.apply("workerA", &frame(None, &grid("one"), "w_1", 1, 0), 100); + store.apply("workerA", &frame(None, &grid("two"), "w_2", 1, 0), 300); + store.apply("workerB", &frame(None, &grid("other"), "w_1", 1, 0), 200); + + let mine = store.for_worker("workerA"); + assert_eq!(mine.len(), 2); + assert_eq!(mine[0].task_id, "w_2", "most recently updated first"); + assert_eq!(mine[1].task_id, "w_1"); +} + +#[test] +fn the_store_is_bounded_and_drops_the_stalest() { + // A hub watching a fan-out must not hold a framebuffer per worker forever. + let store = ScreenStore::new(); + for n in 0..12 { + store.apply( + "workerA", + &frame(None, &grid("x"), &format!("w_{n}"), 1, 0), + // Ascending, so `w_0` is the stalest throughout. + 100 + n as i64, + ); + } + assert_eq!(store.len(), 8, "capped at CAPACITY"); + assert!(store.get("workerA", "w_0").is_none(), "stalest evicted"); + assert!(store.get("workerA", "w_11").is_some(), "newest kept"); +} + +#[test] +fn forgetting_a_screen_removes_only_that_one() { + let store = ScreenStore::new(); + store.apply("workerA", &frame(None, &grid("one"), "w_1", 1, 0), 100); + store.apply("workerA", &frame(None, &grid("two"), "w_2", 1, 0), 100); + + store.forget("workerA", "w_1"); + assert!(store.get("workerA", "w_1").is_none()); + assert!(store.get("workerA", "w_2").is_some()); +} + +#[test] +fn an_empty_store_reports_empty() { + let store = ScreenStore::new(); + assert!(store.is_empty()); + assert!(store.snapshot().is_empty()); + assert!(store.for_worker("nobody").is_empty()); + assert_eq!(store.get("nobody", "w_1"), None::); +} diff --git a/src/sdk/src/runtime/backend/runtime.rs b/src/sdk/src/runtime/backend/runtime.rs index 229f47c2..9ff25224 100644 --- a/src/sdk/src/runtime/backend/runtime.rs +++ b/src/sdk/src/runtime/backend/runtime.rs @@ -143,6 +143,34 @@ impl Runtime for BackendRuntime { }) } + fn worker_screens(&self) -> Vec { + let handle = self.hub.lock().unwrap().clone(); + match handle { + Some(h) => h.screens().snapshot(), + None => Vec::new(), + } + } + + fn watch_task( + &self, + worker: String, + task_id: String, + watch: bool, + ) -> crate::runtime::BoxFuture<'static, anyhow::Result<()>> { + let handle = self.hub.lock().unwrap().clone(); + Box::pin(async move { + let Some(hub) = handle else { + return Ok(()); + }; + let result = if watch { + hub.watch(&worker, &task_id).await + } else { + hub.unwatch(&worker, &task_id).await + }; + result.map_err(|e| anyhow::anyhow!(e)) + }) + } + fn workers(&self) -> Vec { let handle = self.hub.lock().unwrap().clone(); match handle { diff --git a/src/sdk/src/runtime/mod.rs b/src/sdk/src/runtime/mod.rs index 94f281c8..a16e597b 100644 --- a/src/sdk/src/runtime/mod.rs +++ b/src/sdk/src/runtime/mod.rs @@ -136,6 +136,26 @@ pub trait Runtime: Send + Sync { Vec::new() } + /// The worker screens this runtime's hub is currently holding. Empty when + /// the runtime has no hub, or when nothing is being watched. + fn worker_screens(&self) -> Vec { + Vec::new() + } + + /// Start or stop streaming the screen of the session running `task_id` on + /// `worker`. + /// + /// A no-op success without a hub, so a runtime that cannot watch anything + /// is not something every caller has to special-case. + fn watch_task( + &self, + _worker: String, + _task_id: String, + _watch: bool, + ) -> BoxFuture<'static, anyhow::Result<()>> { + Box::pin(async { Ok(()) }) + } + /// The managed worker-peer registry snapshot (`worker.list`). Empty when the /// runtime has no worker surface. fn workers(&self) -> Vec { diff --git a/src/sdk/src/sessions/manager/turns.rs b/src/sdk/src/sessions/manager/turns.rs index 28a45e7b..96c27c1e 100644 --- a/src/sdk/src/sessions/manager/turns.rs +++ b/src/sdk/src/sessions/manager/turns.rs @@ -344,6 +344,7 @@ impl SessionManager { router: self.inner.config.router.clone(), on_event: None, on_stdin: None, + on_session: None, }; let result = (self.inner.run_task)(options).await?; Ok(TurnOutcome { diff --git a/src/sdk/src/tinyplace/mod.rs b/src/sdk/src/tinyplace/mod.rs index ef2e5726..d11cbe60 100644 --- a/src/sdk/src/tinyplace/mod.rs +++ b/src/sdk/src/tinyplace/mod.rs @@ -9,6 +9,8 @@ //! - [`frames`] — the `medulla-tinyplace/1` task frame protocol (delegated work //! over encrypted DMs). //! - [`control`] — owner→machine harness control frames (session-targeted input). +//! - [`screen`] — the `medulla.screen.v1` protocol, streaming a worker's live +//! terminal to a watching orchestrator as mosh-style synchronised state. //! - [`consumer`] — receiver-side fold of the SDK's v2 harness stream into a live //! [`consumer::SessionView`]. //! - [`status`] — the derived session-status state machine over SDK events. @@ -25,6 +27,7 @@ pub mod control; pub mod env; pub mod frames; pub mod runtime; +pub mod screen; pub mod service; pub mod status; pub mod system_info; @@ -56,6 +59,12 @@ pub use runtime::{ spawn_mailbox_poll, spawn_presence_heartbeat, AcquiredIdentity, FileSessionStore, IdentityLock, MailboxItem, MailboxPoll, RuntimeError, RuntimeResult, IDENTITY_FILE, }; +pub use screen::{ + apply_frame, build_frame, changed_rows, coalesce_runs, encode_screen_message, + parse_screen_message, ApplyOutcome, Color, FrameDecision, RowUpdate, RunStyle, ScreenFrame, + ScreenGrid, ScreenMessage, ScreenRun, ScreenView, ATTR_BOLD, ATTR_INVERSE, ATTR_ITALIC, + ATTR_UNDERLINE, SCREEN_PROTO, +}; pub use status::{ initial_status, reduce_status, tick_status, SemanticEvent, SessionStatusState, StatusStep, DEFAULT_IDLE_AFTER_MS, STATE_ERRORED, STATE_IDLE, STATE_RUNNING, STATE_RUNNING_TOOL, diff --git a/src/sdk/src/tinyplace/screen/apply.rs b/src/sdk/src/tinyplace/screen/apply.rs new file mode 100644 index 00000000..c3711781 --- /dev/null +++ b/src/sdk/src/tinyplace/screen/apply.rs @@ -0,0 +1,75 @@ +//! Viewer side: fold an incoming frame into the screen held for a session. +//! +//! The counterpart to [`diff`](super::diff), and equally pure — the hub's store +//! and renderer sit above this. Every rejection path returns +//! [`ApplyOutcome::NeedsResync`] rather than a partial write, so a view is +//! always either current or visibly stale; it is never quietly wrong. + +use super::types::{ApplyOutcome, ScreenFrame, ScreenGrid, ScreenView}; + +/// Rebuild a whole grid from a full frame. +fn grid_from_full(frame: &ScreenFrame) -> ScreenGrid { + let mut lines = vec![Vec::new(); frame.rows as usize]; + for row in &frame.rows_changed { + if let Some(slot) = lines.get_mut(row.y as usize) { + *slot = row.runs.clone(); + } + } + ScreenGrid { + cols: frame.cols, + rows: frame.rows, + lines, + cursor: frame.cursor, + hide_cursor: frame.hide_cursor, + } +} + +/// Fold `frame` into `view`, replacing or patching it in place. +/// +/// A full frame always applies and resets the view. A delta applies only when +/// the view holds exactly `frame.base_seq` at the frame's geometry; anything +/// else — no view yet, a sequence gap, a resize, or a row index outside the +/// grid — leaves the view untouched and asks for a resynchronise. +/// +/// The sequence check is what makes the mailbox's lack of ordering guarantees +/// survivable: a dropped, duplicated, or reordered delta all fail the same way +/// and recover through the same full frame. +pub fn apply_frame(view: &mut Option, frame: &ScreenFrame) -> ApplyOutcome { + if frame.full { + *view = Some(ScreenView { + grid: grid_from_full(frame), + seq: frame.seq, + }); + return ApplyOutcome::Applied; + } + + let Some(current) = view.as_mut() else { + return ApplyOutcome::NeedsResync; + }; + if current.seq != frame.base_seq { + return ApplyOutcome::NeedsResync; + } + // A sender that follows the protocol never sends a delta across a resize. + // One that does not is not to be trusted with row indices. + if current.grid.cols != frame.cols || current.grid.rows != frame.rows { + return ApplyOutcome::NeedsResync; + } + // Validate before mutating: a frame naming a row outside the grid is + // malformed, and half-applying it would leave a view that looks current + // while showing something that was never on screen. + if frame + .rows_changed + .iter() + .any(|row| row.y as usize >= current.grid.lines.len()) + { + return ApplyOutcome::NeedsResync; + } + + for row in &frame.rows_changed { + current.grid.lines[row.y as usize] = row.runs.clone(); + } + current.grid.cursor = frame.cursor; + current.grid.hide_cursor = frame.hide_cursor; + current.seq = frame.seq; + ApplyOutcome::Applied +} diff --git a/src/sdk/src/tinyplace/screen/codec.rs b/src/sdk/src/tinyplace/screen/codec.rs new file mode 100644 index 00000000..3fd32bd5 --- /dev/null +++ b/src/sdk/src/tinyplace/screen/codec.rs @@ -0,0 +1,38 @@ +//! Serializing screen messages into encrypted DM bodies, and recognising them +//! on the way back. +//! +//! This channel carries four protocols — task frames, harness control frames, +//! session envelopes, and these — so decoding is first a question of *whose* +//! body this is. [`parse_screen_message`] answers only for its own and never +//! panics: inbound bodies are untrusted, and a body that is not ours must fall +//! through to the next parser rather than being mangled into one. + +use super::types::{ScreenEnvelope, ScreenMessage, SCREEN_PROTO}; + +/// Serialize `message` for an encrypted DM body, stamping the version tag. +pub fn encode_screen_message(message: &ScreenMessage) -> String { + let envelope = ScreenEnvelope { + screen_version: SCREEN_PROTO.to_string(), + message: message.clone(), + }; + serde_json::to_string(&envelope).expect("ScreenEnvelope always serializes") +} + +/// Decode a DM body into a [`ScreenMessage`], or `None` when the body is not one +/// of ours — plain text, another protocol, or a malformed frame. +/// +/// The cheap checks come first: anything that is not a JSON object carrying our +/// exact version tag is rejected before the full deserialize is attempted, so +/// the common case (someone else's frame) costs almost nothing. +pub fn parse_screen_message(body: &str) -> Option { + if !body.trim_start().starts_with('{') { + return None; + } + let value: serde_json::Value = serde_json::from_str(body).ok()?; + if value.get("screen_version").and_then(|v| v.as_str()) != Some(SCREEN_PROTO) { + return None; + } + serde_json::from_value::(value) + .ok() + .map(|envelope| envelope.message) +} diff --git a/src/sdk/src/tinyplace/screen/diff.rs b/src/sdk/src/tinyplace/screen/diff.rs new file mode 100644 index 00000000..0da7fef4 --- /dev/null +++ b/src/sdk/src/tinyplace/screen/diff.rs @@ -0,0 +1,125 @@ +//! Sender side: turn a pair of screens into the smallest frame that carries the +//! newer one, and coalesce raw cells into the runs a frame is made of. +//! +//! Everything here is pure. The sampler that decides *when* to call it, and the +//! transport that carries the result, live in the app crate — which is what lets +//! the whole diff be tested against literal grids with no pty and no network. + +use super::types::{FrameDecision, RowUpdate, RunStyle, ScreenFrame, ScreenGrid, ScreenRun}; + +/// Merge a row of styled cells into runs, dropping trailing unstyled blanks. +/// +/// A 120-column row is typically two or three runs; emitting one per cell would +/// inflate every frame for no visual difference. Trailing blanks are trimmed +/// only when they are *unstyled* — a harness status bar is a run of styled +/// spaces, and trimming that would erase the bar. This mirrors the coalescing +/// the worker's own renderer already does when painting to ratatui. +pub fn coalesce_runs(cells: I) -> Vec +where + I: IntoIterator, +{ + let mut runs: Vec = Vec::new(); + for (text, style) in cells { + match runs.last_mut() { + Some(run) if run.style == style => run.text.push_str(&text), + _ => runs.push(ScreenRun::new(text, style)), + } + } + // Only the final run can be trailing blanks, since any styled run after it + // would have kept it from being last. + if let Some(last) = runs.last_mut() { + if last.style == RunStyle::default() { + let trimmed = last.text.trim_end(); + if trimmed.is_empty() { + runs.pop(); + } else if trimmed.len() != last.text.len() { + last.text.truncate(trimmed.len()); + } + } + } + runs +} + +/// The rows in which `next` differs from `previous`. +/// +/// Returns `None` when a diff is not meaningful — the grids are different sizes, +/// so row indices do not refer to the same rows in both. Callers must send a +/// full frame in that case; [`build_frame`] does. +pub fn changed_rows(previous: &ScreenGrid, next: &ScreenGrid) -> Option> { + if !previous.same_size(next) { + return None; + } + let mut changed = Vec::new(); + for (y, runs) in next.lines.iter().enumerate() { + // A short `previous.lines` reads as "this row is new", which is the + // conservative answer for a malformed grid. + if previous.lines.get(y) != Some(runs) { + changed.push(RowUpdate { + y: y as u16, + runs: runs.clone(), + }); + } + } + Some(changed) +} + +/// Every row of `grid`, as a full-frame row list. +fn all_rows(grid: &ScreenGrid) -> Vec { + grid.lines + .iter() + .enumerate() + .map(|(y, runs)| RowUpdate { + y: y as u16, + runs: runs.clone(), + }) + .collect() +} + +/// Build the frame that carries `next` to a viewer holding `previous`. +/// +/// `previous` is the last screen the viewer is known to hold and `base_seq` its +/// sequence number; pass `None` on the first frame of a stream or whenever the +/// viewer has asked to resynchronise. A full frame is produced when there is no +/// previous screen or when the geometry changed — a delta across a resize is not +/// merely stale, it addresses rows that no longer exist. +/// +/// Returns [`FrameDecision::Unchanged`] when nothing at all moved, including the +/// cursor, so the caller can skip the send entirely. +pub fn build_frame( + previous: Option<&ScreenGrid>, + next: &ScreenGrid, + task_id: &str, + seq: i64, + base_seq: i64, +) -> FrameDecision { + let delta = + previous.and_then(|previous| changed_rows(previous, next).map(|rows| (previous, rows))); + + let (full, rows_changed) = match delta { + Some((previous, rows)) => { + // Nothing moved at all — not the content, not the cursor. The + // sampler sends nothing rather than a frame that would apply as a + // no-op. + if rows.is_empty() + && previous.cursor == next.cursor + && previous.hide_cursor == next.hide_cursor + { + return FrameDecision::Unchanged; + } + (false, rows) + } + None => (true, all_rows(next)), + }; + + FrameDecision::Send(ScreenFrame { + task_id: task_id.to_string(), + seq, + base_seq, + full, + cols: next.cols, + rows: next.rows, + cursor: next.cursor, + hide_cursor: next.hide_cursor, + rows_changed, + }) +} diff --git a/src/sdk/src/tinyplace/screen/mod.rs b/src/sdk/src/tinyplace/screen/mod.rs new file mode 100644 index 00000000..c7bf0070 --- /dev/null +++ b/src/sdk/src/tinyplace/screen/mod.rs @@ -0,0 +1,46 @@ +//! The `medulla.screen.v1` protocol: streaming a worker's live terminal to a +//! watching orchestrator as synchronised *state* rather than a byte stream. +//! +//! Modelled on mosh's State Synchronization Protocol. The worker already runs a +//! terminal emulator over each harness pty, so what crosses the wire is the +//! resulting screen — a grid of styled cells plus a cursor — carried as a diff +//! from the state the viewer is known to hold. Three properties follow, and they +//! are the reason this is affordable over an encrypted mailbox: +//! +//! - **Cost tracks the screen, not the output.** A harness dumping a build log +//! costs the same as an idle one, because only the state at each sample +//! instant is ever sent. +//! - **Loss is self-healing.** A frame that cannot be applied is not retried; +//! the viewer asks to resynchronise and the next full frame supersedes +//! everything missed. Dropped, duplicated and reordered frames share one +//! recovery path, which matters because mailbox ordering is not guaranteed. +//! - **Geometry belongs to the sender.** The viewer is a passive observer: it +//! never resizes the session and never types into it. There is no `input` and +//! no `resize` message, so there is nothing to negotiate and nothing to fight +//! over. +//! +//! Split by responsibility: [`types`] holds the wire model, [`diff`] is the +//! sender's cell-coalescing and row diffing, [`apply`] is the viewer's fold, and +//! [`codec`] is the version-tagged envelope this shares the DM channel with +//! three other protocols through. +//! +//! Everything here is pure and free of `vt100`, `portable-pty` and the network, +//! so it is testable against literal grids; the sampler, transport and renderer +//! that drive it live in the app crate. + +pub mod apply; +pub mod codec; +pub mod diff; +pub mod types; + +#[cfg(test)] +mod tests; + +pub use apply::apply_frame; +pub use codec::{encode_screen_message, parse_screen_message}; +pub use diff::{build_frame, changed_rows, coalesce_runs}; +pub use types::{ + ApplyOutcome, Color, FrameDecision, RowUpdate, RunStyle, ScreenEnvelope, ScreenFrame, + ScreenGrid, ScreenMessage, ScreenRun, ScreenView, ATTR_BOLD, ATTR_INVERSE, ATTR_ITALIC, + ATTR_UNDERLINE, SCREEN_PROTO, +}; diff --git a/src/sdk/src/tinyplace/screen/tests.rs b/src/sdk/src/tinyplace/screen/tests.rs new file mode 100644 index 00000000..36ef5cf5 --- /dev/null +++ b/src/sdk/src/tinyplace/screen/tests.rs @@ -0,0 +1,414 @@ +//! Unit tests for the screen protocol: coalescing, diffing, the viewer's fold, +//! and the version-tagged envelope. +//! +//! The property that matters most is the last group's: a viewer that applies +//! every frame a sender emits ends up holding exactly the sender's screen. The +//! diff is only worth having if that holds across resizes and cursor-only +//! moves, so it is asserted as a sequence rather than frame by frame. + +use super::*; + +/// A style with just an attribute set, for making rows visibly differ. +fn styled(attrs: u8) -> RunStyle { + RunStyle { + attrs, + ..RunStyle::default() + } +} + +/// A grid of `lines`, sized to fit them, cursor at the origin. +fn grid(cols: u16, lines: Vec>) -> ScreenGrid { + ScreenGrid { + cols, + rows: lines.len() as u16, + lines, + cursor: (0, 0), + hide_cursor: false, + } +} + +/// One row of one unstyled run. +fn row(text: &str) -> Vec { + vec![ScreenRun::plain(text)] +} + +fn cells(text: &str, style: RunStyle) -> Vec<(String, RunStyle)> { + text.chars().map(|c| (c.to_string(), style)).collect() +} + +// --- coalescing ------------------------------------------------------------ + +#[test] +fn adjacent_cells_of_one_style_become_a_single_run() { + let runs = coalesce_runs(cells("hello", RunStyle::default())); + assert_eq!(runs, vec![ScreenRun::plain("hello")]); +} + +#[test] +fn a_style_change_splits_the_run() { + let mut input = cells("ab", RunStyle::default()); + input.extend(cells("c", styled(ATTR_BOLD))); + let runs = coalesce_runs(input); + assert_eq!( + runs, + vec![ + ScreenRun::plain("ab"), + ScreenRun::new("c", styled(ATTR_BOLD)), + ] + ); +} + +#[test] +fn unstyled_trailing_blanks_are_dropped() { + // Most of a terminal row is trailing blanks; carrying them would dominate + // every frame. + let runs = coalesce_runs(cells("hi ", RunStyle::default())); + assert_eq!(runs, vec![ScreenRun::plain("hi")]); +} + +#[test] +fn an_entirely_blank_row_coalesces_to_nothing() { + assert!(coalesce_runs(cells(" ", RunStyle::default())).is_empty()); +} + +#[test] +fn styled_trailing_blanks_survive() { + // A harness status bar is a run of styled spaces. Trimming it erases the bar. + let runs = coalesce_runs(cells(" ", styled(ATTR_INVERSE))); + assert_eq!(runs, vec![ScreenRun::new(" ", styled(ATTR_INVERSE))]); +} + +// --- diffing --------------------------------------------------------------- + +#[test] +fn the_first_frame_of_a_stream_is_full() { + let next = grid(10, vec![row("a"), row("b")]); + let FrameDecision::Send(frame) = build_frame(None, &next, "w_1", 1, 0) else { + panic!("expected a frame"); + }; + assert!(frame.full); + assert_eq!(frame.rows_changed.len(), 2); +} + +#[test] +fn only_changed_rows_are_sent() { + let previous = grid(10, vec![row("a"), row("b"), row("c")]); + let next = grid(10, vec![row("a"), row("CHANGED"), row("c")]); + let FrameDecision::Send(frame) = build_frame(Some(&previous), &next, "w_1", 2, 1) else { + panic!("expected a frame"); + }; + assert!(!frame.full); + assert_eq!(frame.base_seq, 1); + assert_eq!(frame.rows_changed.len(), 1); + assert_eq!(frame.rows_changed[0].y, 1); + assert_eq!(frame.rows_changed[0].runs, row("CHANGED")); +} + +#[test] +fn an_unchanged_screen_sends_nothing() { + // The property that decouples wire cost from harness repainting. + let previous = grid(10, vec![row("a"), row("b")]); + let next = previous.clone(); + assert_eq!( + build_frame(Some(&previous), &next, "w_1", 2, 1), + FrameDecision::Unchanged + ); +} + +#[test] +fn a_cursor_move_alone_is_still_worth_a_frame() { + let previous = grid(10, vec![row("a")]); + let mut next = previous.clone(); + next.cursor = (0, 4); + let FrameDecision::Send(frame) = build_frame(Some(&previous), &next, "w_1", 2, 1) else { + panic!("a cursor move is a visible change"); + }; + assert!(frame.rows_changed.is_empty()); + assert_eq!(frame.cursor, (0, 4)); +} + +#[test] +fn a_resize_forces_a_full_frame() { + // Row indices do not survive a resize: a delta across one would address rows + // that no longer mean the same thing. + let previous = grid(10, vec![row("a"), row("b")]); + let next = grid(20, vec![row("a"), row("b")]); + let FrameDecision::Send(frame) = build_frame(Some(&previous), &next, "w_1", 2, 1) else { + panic!("expected a frame"); + }; + assert!(frame.full, "a resize must not be sent as a delta"); + assert_eq!(frame.cols, 20); +} + +#[test] +fn changed_rows_refuses_grids_of_different_sizes() { + let a = grid(10, vec![row("x")]); + let b = grid(10, vec![row("x"), row("y")]); + assert!(changed_rows(&a, &b).is_none()); +} + +// --- applying -------------------------------------------------------------- + +#[test] +fn a_full_frame_applies_to_an_empty_view() { + let next = grid(10, vec![row("a"), row("b")]); + let FrameDecision::Send(frame) = build_frame(None, &next, "w_1", 7, 0) else { + panic!("expected a frame"); + }; + let mut view = None; + assert_eq!(apply_frame(&mut view, &frame), ApplyOutcome::Applied); + let view = view.expect("a full frame always establishes a view"); + assert_eq!(view.seq, 7); + assert_eq!(view.grid, next); +} + +#[test] +fn a_delta_against_no_view_asks_to_resync() { + let previous = grid(10, vec![row("a")]); + let next = grid(10, vec![row("b")]); + let FrameDecision::Send(frame) = build_frame(Some(&previous), &next, "w_1", 2, 1) else { + panic!("expected a frame"); + }; + let mut view = None; + assert_eq!(apply_frame(&mut view, &frame), ApplyOutcome::NeedsResync); + assert!(view.is_none(), "a rejected frame must not half-apply"); +} + +#[test] +fn a_sequence_gap_asks_to_resync_without_touching_the_view() { + let base = grid(10, vec![row("a")]); + let FrameDecision::Send(full) = build_frame(None, &base, "w_1", 1, 0) else { + panic!("expected a frame"); + }; + let mut view = None; + apply_frame(&mut view, &full); + + // A delta claiming to follow seq 5 when the view holds seq 1. + let next = grid(10, vec![row("b")]); + let FrameDecision::Send(mut delta) = build_frame(Some(&base), &next, "w_1", 6, 5) else { + panic!("expected a frame"); + }; + delta.base_seq = 5; + assert_eq!(apply_frame(&mut view, &delta), ApplyOutcome::NeedsResync); + assert_eq!( + view.expect("view survives").grid, + base, + "the stale view must be left intact, not partly patched" + ); +} + +#[test] +fn a_row_outside_the_grid_asks_to_resync_rather_than_panicking() { + // Inbound frames are untrusted; a bad row index must not index out of bounds. + let base = grid(10, vec![row("a")]); + let FrameDecision::Send(full) = build_frame(None, &base, "w_1", 1, 0) else { + panic!("expected a frame"); + }; + let mut view = None; + apply_frame(&mut view, &full); + + let hostile = ScreenFrame { + task_id: "w_1".into(), + seq: 2, + base_seq: 1, + full: false, + cols: 10, + rows: 1, + cursor: (0, 0), + hide_cursor: false, + rows_changed: vec![RowUpdate { + y: 99, + runs: row("boom"), + }], + }; + assert_eq!(apply_frame(&mut view, &hostile), ApplyOutcome::NeedsResync); + assert_eq!(view.expect("view survives").grid, base); +} + +// --- the round trip -------------------------------------------------------- + +#[test] +fn a_viewer_applying_every_frame_ends_up_holding_the_senders_screen() { + // The whole point of the protocol, asserted across the cases that are easy + // to get wrong: a plain edit, a cursor-only move, and a resize. + let states = vec![ + grid(10, vec![row("one"), row("two"), row("three")]), + grid(10, vec![row("one"), row("TWO!"), row("three")]), + { + let mut g = grid(10, vec![row("one"), row("TWO!"), row("three")]); + g.cursor = (2, 5); + g + }, + grid(24, vec![row("wider"), row("now"), row("reflowed")]), + { + let mut g = grid(24, vec![row("wider"), row("now"), row("reflowed")]); + g.hide_cursor = true; + g + }, + ]; + + let mut view: Option = None; + let mut sent: Option = None; + let mut seq = 0i64; + + for state in &states { + let base_seq = seq; + seq += 1; + match build_frame(sent.as_ref(), state, "w_1", seq, base_seq) { + FrameDecision::Unchanged => { + seq -= 1; // nothing went out, so the sequence did not advance + } + FrameDecision::Send(frame) => { + assert_eq!( + apply_frame(&mut view, &frame), + ApplyOutcome::Applied, + "every frame the sender emits must apply in order" + ); + sent = Some(state.clone()); + } + } + assert_eq!( + &view.as_ref().expect("a view by now").grid, + state, + "viewer diverged from the sender" + ); + } +} + +#[test] +fn a_resync_recovers_a_viewer_that_missed_a_frame() { + let first = grid(10, vec![row("a"), row("b")]); + let second = grid(10, vec![row("a"), row("CHANGED")]); + let third = grid(10, vec![row("a"), row("AGAIN")]); + + let mut view = None; + let FrameDecision::Send(f1) = build_frame(None, &first, "w_1", 1, 0) else { + panic!("expected a frame"); + }; + apply_frame(&mut view, &f1); + + // The frame carrying `second` is lost in the mail, so the viewer never + // reaches seq 2 — and the next delta is unusable. + let FrameDecision::Send(f3) = build_frame(Some(&second), &third, "w_1", 3, 2) else { + panic!("expected a frame"); + }; + assert_eq!(apply_frame(&mut view, &f3), ApplyOutcome::NeedsResync); + + // The viewer asks to resync; the sender answers with a full frame and the + // gap is closed without anything being retransmitted. + let FrameDecision::Send(resync) = build_frame(None, &third, "w_1", 4, 0) else { + panic!("expected a frame"); + }; + assert_eq!(apply_frame(&mut view, &resync), ApplyOutcome::Applied); + assert_eq!(view.expect("recovered").grid, third); +} + +// --- the envelope ---------------------------------------------------------- + +#[test] +fn messages_round_trip_through_the_envelope() { + let cases = vec![ + ScreenMessage::Subscribe { + task_id: "w_1".into(), + max_fps: 1, + resync: true, + }, + ScreenMessage::Unsubscribe { + task_id: "w_1".into(), + }, + ScreenMessage::Ack { + task_id: "w_1".into(), + seq: 418, + }, + ]; + for message in cases { + let body = encode_screen_message(&message); + assert_eq!(parse_screen_message(&body), Some(message)); + } +} + +#[test] +fn a_frame_round_trips_with_its_styles_intact() { + let mut source = grid( + 10, + vec![vec![ + ScreenRun::new("run", styled(ATTR_BOLD | ATTR_UNDERLINE)), + ScreenRun::new( + "ning", + RunStyle { + fg: Color::Idx(4), + bg: Color::Rgb(1, 2, 3), + attrs: ATTR_INVERSE, + }, + ), + ]], + ); + source.cursor = (0, 7); + let FrameDecision::Send(frame) = build_frame(None, &source, "w_1", 1, 0) else { + panic!("expected a frame"); + }; + let body = encode_screen_message(&ScreenMessage::Frame(frame)); + let Some(ScreenMessage::Frame(decoded)) = parse_screen_message(&body) else { + panic!("a frame must survive the envelope"); + }; + let mut view = None; + apply_frame(&mut view, &decoded); + assert_eq!(view.expect("applied").grid, source); +} + +#[test] +fn the_encoded_body_carries_the_version_tag() { + let body = encode_screen_message(&ScreenMessage::Unsubscribe { + task_id: "w_1".into(), + }); + let value: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert_eq!(value["screen_version"], SCREEN_PROTO); + assert_eq!(value["kind"], "unsubscribe"); +} + +#[test] +fn parse_rejects_bodies_that_are_not_ours() { + // This channel carries four protocols; a body that is not ours must fall + // through to the next parser rather than being mangled into one. + assert!(parse_screen_message("plain text dm").is_none()); + assert!(parse_screen_message(" not { json").is_none()); + assert!(parse_screen_message(r#"{"proto":"medulla-tinyplace/1","kind":"task"}"#).is_none()); + assert!(parse_screen_message( + r#"{"control_version":"tinyplace.harness.control.v1","kind":"input","text":"x"}"# + ) + .is_none()); + // Right tag, unknown kind. + assert!(parse_screen_message(&format!( + r#"{{"screen_version":"{SCREEN_PROTO}","kind":"teleport"}}"# + )) + .is_none()); + // Right tag, missing required fields. + assert!(parse_screen_message(&format!( + r#"{{"screen_version":"{SCREEN_PROTO}","kind":"ack"}}"# + )) + .is_none()); +} + +#[test] +fn default_colours_and_attributes_stay_off_the_wire() { + // Most of a screen is unstyled; serializing those fields would dominate a + // frame that is supposed to be a few hundred bytes. + let body = encode_screen_message(&ScreenMessage::Frame(ScreenFrame { + task_id: "w_1".into(), + seq: 1, + base_seq: 0, + full: true, + cols: 4, + rows: 1, + cursor: (0, 0), + hide_cursor: false, + rows_changed: vec![RowUpdate { + y: 0, + runs: row("bare"), + }], + })); + assert!(!body.contains("\"fg\""), "got: {body}"); + assert!(!body.contains("\"bg\""), "got: {body}"); + assert!(!body.contains("\"attrs\""), "got: {body}"); + assert!(body.contains("\"t\":\"bare\""), "got: {body}"); +} diff --git a/src/sdk/src/tinyplace/screen/types.rs b/src/sdk/src/tinyplace/screen/types.rs new file mode 100644 index 00000000..204a1187 --- /dev/null +++ b/src/sdk/src/tinyplace/screen/types.rs @@ -0,0 +1,251 @@ +//! Data model for the `medulla.screen.v1` wire protocol: the synchronised +//! terminal state, the frames that carry it, and their serde shapes. +//! +//! These types are deliberately independent of `vt100`. The SDK stays free of +//! the terminal-emulator and pty crates (that wiring lives in the app crate), so +//! a screen crossing the wire is described here in its own vocabulary and the +//! app crate converts at the boundary. + +use serde::{Deserialize, Serialize}; + +/// Wire version tag stamped on every screen message body. +pub const SCREEN_PROTO: &str = "medulla.screen.v1"; + +/// Bold attribute bit for [`RunStyle::attrs`]. +pub const ATTR_BOLD: u8 = 1 << 0; +/// Italic attribute bit for [`RunStyle::attrs`]. +pub const ATTR_ITALIC: u8 = 1 << 1; +/// Underline attribute bit for [`RunStyle::attrs`]. +pub const ATTR_UNDERLINE: u8 = 1 << 2; +/// Inverse-video attribute bit for [`RunStyle::attrs`]. +pub const ATTR_INVERSE: u8 = 1 << 3; + +/// A cell colour. +/// +/// Mirrors the three forms a terminal emulator reports — inherit, palette index, +/// or direct RGB — without depending on the emulator's own type. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Color { + /// Inherit the viewer's own palette. Never forced to a colour we picked. + #[default] + Default, + /// An index into the 256-colour palette. + Idx(u8), + /// A direct 24-bit colour. + Rgb(u8, u8, u8), +} + +impl Color { + /// Whether this is the inherit-from-viewer default. + /// + /// Used to keep default colours out of the serialized frame, which is most + /// of a typical screen. + pub fn is_default(&self) -> bool { + matches!(self, Color::Default) + } +} + +/// The visual style shared by a run of adjacent cells. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct RunStyle { + /// Foreground colour. + #[serde(default, skip_serializing_if = "Color::is_default")] + pub fg: Color, + /// Background colour. + #[serde(default, skip_serializing_if = "Color::is_default")] + pub bg: Color, + /// Bitset of `ATTR_*` flags. + #[serde(default, skip_serializing_if = "is_zero")] + pub attrs: u8, +} + +/// Whether an attribute bitset is empty, so it can be omitted from the wire. +fn is_zero(value: &u8) -> bool { + *value == 0 +} + +impl RunStyle { + /// Whether `flag` (an `ATTR_*` constant) is set. + pub fn has(&self, flag: u8) -> bool { + self.attrs & flag != 0 + } +} + +/// A run of adjacent cells sharing one style. +/// +/// Rows are carried as runs rather than cells because a terminal row is mostly +/// long stretches of one style: a 120-column row is typically two or three runs, +/// not 120 cells. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ScreenRun { + /// The run's text. + #[serde(rename = "t")] + pub text: String, + /// The style every cell in the run shares. + #[serde(flatten)] + pub style: RunStyle, +} + +impl ScreenRun { + /// Build a run from its text and style. + pub fn new(text: impl Into, style: RunStyle) -> Self { + ScreenRun { + text: text.into(), + style, + } + } + + /// Build an unstyled run. + pub fn plain(text: impl Into) -> Self { + ScreenRun::new(text, RunStyle::default()) + } +} + +/// One row replaced wholesale. +/// +/// Rows are the diff unit: a changed row is resent entire rather than as a +/// within-row patch. A terminal repaints by line, so sub-row deltas would cost +/// more to describe than the row costs to send. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RowUpdate { + /// Zero-based row index, from the top of the screen. + pub y: u16, + /// The row's full new contents. + pub runs: Vec, +} + +/// A complete terminal screen: the state this protocol synchronises. +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct ScreenGrid { + /// Width in cells. + pub cols: u16, + /// Height in cells. + pub rows: u16, + /// One entry per row, top to bottom. Always `rows` long for a well-formed + /// grid. + pub lines: Vec>, + /// Cursor position as `(row, col)`. + pub cursor: (u16, u16), + /// Whether the harness has hidden its cursor. + pub hide_cursor: bool, +} + +impl ScreenGrid { + /// Whether `other` has the same dimensions. + /// + /// Row indices are only meaningful between grids of one size, so this gates + /// whether a diff may be taken at all. + pub fn same_size(&self, other: &ScreenGrid) -> bool { + self.cols == other.cols && self.rows == other.rows + } +} + +/// A screen frame: either a whole grid or the rows that changed since the state +/// the sender believes the receiver holds. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ScreenFrame { + /// The task whose session this frame shows. + /// + /// Deliberately the *task* id and not the worker's session id. A session + /// serves one task at a time — the worker's `claim_idle` refuses a busy one + /// under a single lock — so the two identify the same thing while a task + /// runs, and the task id is the one both ends already hold. It also makes + /// authorization structural: a worker resolves a task by + /// `(authenticated sender, task id)`, so a peer can only ever name its own. + pub task_id: String, + /// Monotonic sequence number for the state this frame produces. + pub seq: i64, + /// The sequence number this frame is a diff *from*. + /// + /// Mosh's `ack_num`. Ignored when `full` is set; otherwise the receiver must + /// hold exactly this sequence or ask to resynchronise. + pub base_seq: i64, + /// Whether `rows_changed` is the entire screen rather than a delta. + pub full: bool, + /// The sender's width in cells. Authoritative: the viewer adapts. + pub cols: u16, + /// The sender's height in cells. + pub rows: u16, + /// Cursor position as `(row, col)`. + pub cursor: (u16, u16), + /// Whether the harness has hidden its cursor. + pub hide_cursor: bool, + /// The rows this frame replaces — every row when `full`, otherwise only + /// those that differ. + pub rows_changed: Vec, +} + +/// Everything that can cross this protocol, in both directions. +/// +/// There is no `input` and no `resize`: the viewer never reaches back into the +/// session, and the sender's geometry is authoritative. Both are additive later +/// if that changes. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum ScreenMessage { + /// Viewer → sender: start (or restart) a stream. + Subscribe { + /// The task whose session to watch. + task_id: String, + /// The most frames per second the viewer wants. + max_fps: u8, + /// Ask for a full frame rather than a delta — sent on first subscribe + /// and whenever the viewer's state has diverged. + resync: bool, + }, + /// Viewer → sender: stop streaming this session. + Unsubscribe { + /// The task to stop watching. + task_id: String, + }, + /// Viewer → sender: the highest sequence the viewer holds, which the next + /// diff may be taken from. + Ack { + /// The task being acknowledged. + task_id: String, + /// The sequence the viewer now holds. + seq: i64, + }, + /// Sender → viewer: new screen state. + Frame(ScreenFrame), +} + +/// A screen message with its version tag, as it appears on the wire. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ScreenEnvelope { + /// Wire version tag ([`SCREEN_PROTO`]). + pub screen_version: String, + /// The message itself. + #[serde(flatten)] + pub message: ScreenMessage, +} + +/// The viewer's copy of a session's screen, and how current it is. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ScreenView { + /// The synchronised screen. + pub grid: ScreenGrid, + /// The sequence number `grid` reflects. + pub seq: i64, +} + +/// What the sampler decided to put on the wire. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FrameDecision { + /// The screen is byte-identical to the last frame sent. Send nothing — this + /// is what decouples wire cost from how much the harness is repainting. + Unchanged, + /// Send this frame. + Send(ScreenFrame), +} + +/// The result of folding a frame into a viewer's state. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ApplyOutcome { + /// The frame was applied; the view is current. + Applied, + /// The frame could not be applied against the state held. The viewer must + /// send `subscribe { resync: true }` and wait for a full frame. + NeedsResync, +} diff --git a/src/sdk/tests/e2e_daemon_providers.rs b/src/sdk/tests/e2e_daemon_providers.rs index efe2b86e..c704410b 100644 --- a/src/sdk/tests/e2e_daemon_providers.rs +++ b/src/sdk/tests/e2e_daemon_providers.rs @@ -65,6 +65,7 @@ async fn run( sink.lock().unwrap().push(ev.event.kind.clone()); })), on_stdin: None, + on_session: None, }; let result = run_provider_task(options).await; let events = kinds.lock().unwrap().clone(); @@ -180,6 +181,7 @@ async fn spawn_failure_for_missing_binary() { router: None, on_event: None, on_stdin: None, + on_session: None, }; let err = run_provider_task(options) .await @@ -210,6 +212,7 @@ async fn abort_before_start_returns_immediately() { router: None, on_event: None, on_stdin: None, + on_session: None, }; let err = run_provider_task(options).await.expect_err("aborted"); assert!(err.contains("aborted before start"), "got: {err}"); @@ -244,6 +247,7 @@ async fn abort_mid_run_kills_child() { router: None, on_event: None, on_stdin: None, + on_session: None, }; let err = run_provider_task(options).await.expect_err("aborted"); assert!(err.contains("aborted"), "got: {err}"); @@ -280,6 +284,7 @@ async fn stdin_input_reaches_child_and_echoes_in_reply() { on_stdin: Some(Box::new(move |tx| { *register.lock().unwrap() = Some(tx); })), + on_session: None, }; // Feed stdin shortly after the run starts. let feeder = stdin_tx.clone(); @@ -324,6 +329,7 @@ async fn stdin_is_immediate_eof_for_batch_cli() { agent: None, extra_args: Vec::new(), skip_permissions: false, + on_session: None, abort: Abort::new(), router: None, on_event: None, diff --git a/src/sdk/tests/e2e_daemon_router.rs b/src/sdk/tests/e2e_daemon_router.rs index dc4d3430..96368bd9 100644 --- a/src/sdk/tests/e2e_daemon_router.rs +++ b/src/sdk/tests/e2e_daemon_router.rs @@ -57,6 +57,7 @@ fn router_options( router, on_event: None, on_stdin: None, + on_session: None, } } diff --git a/src/sdk/tests/live_inbox_push.rs b/src/sdk/tests/live_inbox_push.rs new file mode 100644 index 00000000..4995fbcf --- /dev/null +++ b/src/sdk/tests/live_inbox_push.rs @@ -0,0 +1,219 @@ +//! Live verification of the relay's push channel, against a real backend. +//! +//! Every other test in this repo is offline and deterministic, and these are +//! neither — so they are `#[ignore]`d and never run as part of `cargo test`. +//! Run them deliberately, against the endpoint the local identity is configured +//! for: +//! +//! ```sh +//! cargo test -p medulla --test live_inbox_push -- --ignored --nocapture +//! ``` +//! +//! They need an onboarded tiny.place identity in `~/.tinyplace/config.json` +//! (`TINYPLACE_API_URL` selects the relay). `/a2a/{id}/stream` is owner-gated, +//! so a freshly generated key is not enough — the identity must own its +//! directory card. +//! +//! A note on what is *not* here. Routing a Signal-encrypted body to self does +//! not work, so these probes send plain envelopes through `client.messages` +//! rather than `SignalTransport::send`. One transport holds one session record +//! per peer, and when the peer is self the sending and receiving ratchets +//! collide on that record: a second send advances it past what the first +//! message needs, and the read fails with `MAC verification failed`. The +//! transport's own recovery clears the poisoned session, so nothing is left +//! broken — but a self-addressed encrypted round trip cannot be made to pass, +//! and proving one needs a second identity. +//! +//! What is actually being proven here, because none of it is provable offline: +//! that the upgrade authenticates at all, that the endpoint exists and is +//! reachable, and that a message relayed over HTTPS while the socket is open +//! comes back down it. The unit tests cover what the doorbell does with a ring; +//! only this covers whether a ring ever arrives. + +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; + +use base64::Engine as _; +use medulla::daemon::transport::SignalTransport; +use medulla::tinyplace::{load_or_create_identity, resolve_endpoint}; +use tinyplace::types::MessageEnvelope; +use tinyplace::{Signer, TinyPlaceClient, TinyPlaceClientOptions}; + +/// How long to allow for a socket to open and a frame to land. Generous: this +/// crosses the public internet, and a slow answer is not the failure under test. +const PATIENCE: Duration = Duration::from_secs(20); + +/// The configured identity plus a client for its relay. +/// +/// Skips (rather than fails) when there is no local identity: a machine that has +/// never onboarded has nothing to verify, and failing there would just be noise. +fn live_client() -> Option<(TinyPlaceClient, Arc, String)> { + let env: HashMap = std::env::vars().collect(); + let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); + let config_file = medulla::tinyplace::config_path(&env, &home); + if !config_file.exists() { + eprintln!( + "skipping: no tiny.place identity at {}", + config_file.display() + ); + return None; + } + let (signer, config) = load_or_create_identity(&config_file, &env).ok()?; + let base_url = resolve_endpoint(&env, &config); + let signer = Arc::new(signer); + let client = TinyPlaceClient::new(TinyPlaceClientOptions { + base_url: base_url.clone(), + signer: Some(signer.clone() as Arc), + ..Default::default() + }); + eprintln!("relay: {base_url}"); + eprintln!("agent: {}", signer.agent_id()); + Some((client, signer, base_url)) +} + +/// Put a plain note-to-self in the mailbox. +/// +/// The relay exempts a self-addressed message from the contact gate, so this +/// needs no second identity and no accepted edge. The body is not encrypted — +/// nothing here decrypts it, and what is under test is whether the *relay* +/// pushes an envelope, not what the envelope contains. +async fn send_note_to_self(client: &TinyPlaceClient, agent_id: &str, marker: &str) { + let envelope = MessageEnvelope { + id: format!("live-push-{marker}"), + from: agent_id.to_string(), + to: agent_id.to_string(), + timestamp: String::new(), + device_id: 1, + // The relay validates this against a fixed set; an empty string is + // rejected with `invalid envelope type`. Nothing here decrypts the + // body, so the label is all that matters. + envelope_type: "CIPHERTEXT".to_string(), + // The relay decodes the body before storing it, so a plain string is + // rejected with `body must be base64 ciphertext`. + body: base64::engine::general_purpose::STANDARD.encode(format!("live push probe {marker}")), + content_hint: None, + signal: None, + }; + client + .messages + .send(envelope) + .await + .expect("the relay should accept a note-to-self"); +} + +/// Delete everything currently in the mailbox, so a probe leaves nothing behind. +async fn drain_mailbox(client: &TinyPlaceClient, agent_id: &str) { + if let Ok(response) = client.messages.list(agent_id, Some(50)).await { + for message in response.messages { + let _ = client.messages.acknowledge(&message.id, agent_id).await; + } + } +} + +#[tokio::test] +#[ignore = "hits a live relay; run explicitly with --ignored"] +async fn the_stream_authenticates_and_pushes_a_relayed_envelope() { + let Some((client, signer, _)) = live_client() else { + return; + }; + let agent_id = signer.agent_id(); + + // Start clean, so the snapshot below is not a pile of old mail. + drain_mailbox(&client, &agent_id).await; + + // 1. The upgrade authenticates. This is the step that was entirely broken + // before the SDK bump, and whose auth form I could not verify offline. + let mut connection = tokio::time::timeout(PATIENCE, client.a2a.stream(&agent_id).connect()) + .await + .expect("the upgrade should not hang") + .expect("the upgrade should authenticate and connect"); + eprintln!("✓ upgrade authenticated"); + + // 2. The relay opens with a snapshot frame. Its arrival is what the doorbell + // reacts to on connect. + let snapshot = tokio::time::timeout(PATIENCE, connection.recv()) + .await + .expect("a snapshot should arrive") + .expect("the stream should not close first") + .expect("the snapshot should be valid json"); + eprintln!("✓ snapshot frame: type={:?}", snapshot.get("type")); + assert!( + snapshot.get("type").and_then(|t| t.as_str()).is_some(), + "every frame carries a string `type`: {snapshot}" + ); + + // 3. The live tail. A message relayed over HTTPS *while the socket is open* + // must come back down it — this is the whole premise, and the only part + // that cannot be inferred from the snapshot. + send_note_to_self(&client, &agent_id, "tail").await; + let pushed = tokio::time::timeout(PATIENCE, connection.recv()) + .await + .expect("a relayed envelope should be pushed within the timeout") + .expect("the stream should still be open") + .expect("the pushed frame should be valid json"); + eprintln!("✓ live tail frame: type={:?}", pushed.get("type")); + + let _ = connection.close().await; + drain_mailbox(&client, &agent_id).await; +} + +#[tokio::test] +#[ignore = "hits a live relay; run explicitly with --ignored"] +async fn the_listener_rings_the_doorbell_for_a_relayed_message() { + // The same thing one layer up: not "does a frame arrive" but "does medulla's + // mailbox loop stop waiting because of it". + let Some((client, signer, _)) = live_client() else { + return; + }; + let agent_id = signer.agent_id(); + let identity_dir = medulla::tinyplace::config_path( + &std::env::vars().collect::>(), + &dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")), + ) + .parent() + .map(PathBuf::from) + .expect("the config file has a parent directory"); + + drain_mailbox(&client, &agent_id).await; + + let transport = SignalTransport::new(client.clone(), &signer, &identity_dir); + let _listener = transport.spawn_inbox_listener(Some(Arc::new(|line: &str| { + eprintln!(" listener: {line}"); + }))); + + // The socket has to come up before any of this means anything. + let deadline = tokio::time::Instant::now() + PATIENCE; + while !transport.is_push_listening() && tokio::time::Instant::now() < deadline { + tokio::time::sleep(Duration::from_millis(100)).await; + } + assert!( + transport.is_push_listening(), + "the push channel should be open within {PATIENCE:?}" + ); + eprintln!("✓ push channel reports listening"); + + // Consume the ring the connect snapshot already caused, so what is measured + // below is the *new* message and not the one that was pending. + transport.wait_for_inbox(Duration::from_millis(200)).await; + + // With a poll interval of ten minutes, returning at all can only be the + // doorbell — there is no timeout that could have fired. + // + // Timed from *before* the send, so the figure is the honest end-to-end + // "relayed → the loop knows". Measuring after the send completes reports + // microseconds, because the push routinely beats the HTTPS response and the + // ring is already pending by then. + let started = std::time::Instant::now(); + send_note_to_self(&client, &agent_id, "doorbell").await; + tokio::time::timeout(PATIENCE, transport.wait_for_inbox(Duration::from_secs(600))) + .await + .expect("the doorbell should ring long before a ten-minute poll"); + eprintln!( + "✓ doorbell rang {:?} after the send began (poll was 600s)", + started.elapsed() + ); + + drain_mailbox(&client, &agent_id).await; +} diff --git a/src/tui/src/event_loop/cmd_dispatch/mod.rs b/src/tui/src/event_loop/cmd_dispatch/mod.rs index 81130d79..0b102427 100644 --- a/src/tui/src/event_loop/cmd_dispatch/mod.rs +++ b/src/tui/src/event_loop/cmd_dispatch/mod.rs @@ -178,6 +178,24 @@ pub(super) fn run_cmd( let _ = tx.send(msg); }); } + Cmd::WatchTask { stop, start } => { + let rt = runtime.clone(); + let tx = msg_tx.clone(); + tokio::spawn(async move { + // Stop first: a worker streaming a task nobody is looking at + // spends a sample, a ratchet advance and a send every tick. + if let Some((worker, task_id)) = stop { + let _ = rt.watch_task(worker, task_id, false).await; + } + if let Some((worker, task_id)) = start { + if let Err(e) = rt.watch_task(worker, task_id.clone(), true).await { + // Worth saying: the pane would otherwise just stay + // empty, which reads as a worker doing nothing. + let _ = tx.send(AppMsg::Status(format!("Cannot watch {task_id}: {e}"))); + } + } + }); + } Cmd::WorkerOp(op) => { let rt = runtime.clone(); let tx = msg_tx.clone(); diff --git a/src/tui/src/ui/app/input.rs b/src/tui/src/ui/app/input.rs index 14128b51..291125f0 100644 --- a/src/tui/src/ui/app/input.rs +++ b/src/tui/src/ui/app/input.rs @@ -5,7 +5,8 @@ use crossterm::event::{Event, KeyEventKind, MouseButton, MouseEventKind}; -use crate::ui::agents::{agent_row_model, AgentRow}; +use super::rail::RailRow; +use crate::ui::agents::{agent_row_model, AgentRole, AgentRow}; use crate::ui::composer::Draft; use super::types::{ @@ -212,6 +213,10 @@ impl App { // A click is a focus gesture: the arrows should now // continue from the row that was just picked. self.focus_agents_rail(); + // Clicking a task is the request to watch it. + if let Some(cmd) = self.retarget_watch() { + return Some(cmd); + } } } } @@ -334,3 +339,64 @@ impl App { }); } } + +impl App { + /// Point the live screen subscription at whatever is selected now. + /// + /// Returns a command only when the target changed, so this is safe to call + /// after every selection move: every subscribe carries `resync: true`, and + /// re-issuing one per keystroke would make the worker resend a full frame + /// each time — the expensive thing the protocol exists to avoid. + /// + /// Only a selected *task* subscribes. A worker lane names no task, and + /// streams are addressed by task: there is nothing to ask for, and guessing + /// one of a worker's tasks would watch work the operator did not point at. + pub(super) fn retarget_watch(&mut self) -> Option { + let desired = self.watch_target(); + if desired == self.watching { + return None; + } + let stop = self.watching.take(); + self.watching = desired.clone(); + Some(Cmd::WatchTask { + stop, + start: desired, + }) + } + + /// The `(worker address, task id)` the current selection asks to watch. + fn watch_target(&self) -> Option<(String, String)> { + // Only on the Agents tab: leaving it releases the subscription. + if self.tab() != "Agents" { + return None; + } + let rows = self.rail_rows(); + let row = rows.get(self.agent_index.min(rows.len().saturating_sub(1)))?; + let RailRow::Agent(AgentRow::Sub { + task, lane_index, .. + }) = row + else { + return None; + }; + let lanes = self.lanes(); + let lane = lanes.get(*lane_index)?; + // `Agent` is main's name for a roster agent / delegated task / peer + // session — the tiers above it (orchestrator, reasoning, compress) run + // no watchable harness. + if lane.role != AgentRole::Agent { + return None; + } + let agent_id = lane.agent_id.as_deref()?; + // Streams are addressed to the worker's tiny.place address; a lane + // carries its roster id. A worker added by address is registered under + // it, so that is the fallback. + let address = self + .runtime + .workers() + .into_iter() + .find(|w| w.id == agent_id) + .map(|w| w.address) + .unwrap_or_else(|| agent_id.to_string()); + Some((address, task.task_id.clone())) + } +} diff --git a/src/tui/src/ui/app/keys/agents.rs b/src/tui/src/ui/app/keys/agents.rs index 19f1882f..2712cb75 100644 --- a/src/tui/src/ui/app/keys/agents.rs +++ b/src/tui/src/ui/app/keys/agents.rs @@ -63,7 +63,8 @@ impl App { self.agent_scroll = 0; self.chat_scroll = 0; self.move_agent_index(matches!(k.code, KeyCode::Up)); - AgentsKey::Handled(None) + // Arrowing onto a task watches it, exactly as clicking does. + AgentsKey::Handled(self.retarget_watch()) } // Enter is "I have found the row I wanted; let me type". KeyCode::Enter => { diff --git a/src/tui/src/ui/app/keys/mod.rs b/src/tui/src/ui/app/keys/mod.rs index 1cfb337c..e9fa1c80 100644 --- a/src/tui/src/ui/app/keys/mod.rs +++ b/src/tui/src/ui/app/keys/mod.rs @@ -290,6 +290,10 @@ impl App { self.agent_scroll = 0; self.chat_scroll = 0; self.move_agent_index(matches!(k.code, KeyCode::Up)); + // Arrowing onto a task watches it, exactly as clicking does. + if let Some(cmd) = self.retarget_watch() { + return Some(cmd); + } } // Agents steering: cancel the selected running task, answer a pending // question through the modal prompt. Enter answers it inline too. diff --git a/src/tui/src/ui/app/render/agents/transcript.rs b/src/tui/src/ui/app/render/agents/transcript.rs index a9ae6a77..8deb0ef5 100644 --- a/src/tui/src/ui/app/render/agents/transcript.rs +++ b/src/tui/src/ui/app/render/agents/transcript.rs @@ -25,6 +25,15 @@ use super::types::Selection; impl App { /// Draw the transcript or declaration for whatever the cursor is on. pub(super) fn draw_agents_pane(&mut self, f: &mut Frame, area: Rect, selection: &Selection) { + // A watched screen supersedes the transcript. The transcript says what a + // worker reported; the screen shows what it is actually doing — + // including the states it never reports at all, like a harness stopped + // on a permission dialog, which writes no transcript records and so + // reads as "thinking" until the task times out. + if let Some(screen) = self.selected_screen(selection) { + self.draw_worker_screen(f, area, &screen); + return; + } let lane = selection.lane(); let pane_width = ((area.width as usize).saturating_sub(4)).max(24); let on_orchestrator = selection.on_orchestrator; @@ -238,3 +247,49 @@ impl App { f.render_widget(Paragraph::new(Text::from(out)), inner); } } + +impl App { + /// The screen held for whatever the cursor is on, if it is being watched. + /// + /// Only a selected *task* resolves one: streams are addressed by task id, + /// which is the requester's own key, so a lane on its own names nothing to + /// look up and guessing one of a worker's tasks would show work the + /// operator did not point at. + pub(super) fn selected_screen( + &self, + selection: &Selection, + ) -> Option { + let task = selection.task.as_ref()?; + self.runtime + .worker_screens() + .into_iter() + .find(|held| held.task_id == task.task_id) + } + + /// Draw a watched worker's screen into `area`. + /// + /// No cursor is drawn and no scrollback is offered, matching the worker's + /// own pane: this is a window, not a keyboard, and the protocol never + /// synchronised history in the first place. + fn draw_worker_screen( + &mut self, + f: &mut Frame, + area: Rect, + screen: &medulla::hub::WatchedScreen, + ) { + let age = medulla::clock::now_millis().saturating_sub(screen.updated_at); + let block = self.panel(crate::ui::screen::screen_title( + &screen.task_id, + screen.seq, + age, + )); + let inner = block.inner(area); + f.render_widget(block, area); + // Clipped, never rewrapped. The grid is a framebuffer: reflowing it to + // a narrower pane would not be the screen the worker is showing. + f.render_widget( + Paragraph::new(Text::from(crate::ui::screen::grid_lines(&screen.grid))), + inner, + ); + } +} diff --git a/src/tui/src/ui/app/state.rs b/src/tui/src/ui/app/state.rs index b74e2220..a6d5f6ee 100644 --- a/src/tui/src/ui/app/state.rs +++ b/src/tui/src/ui/app/state.rs @@ -61,6 +61,7 @@ impl App { contexts: Vec::new(), context_index: 0, agent_index: 0, + watching: None, agents_focus: super::types::AgentsFocus::default(), agent_scroll: 0, chat_scroll: 0, diff --git a/src/tui/src/ui/app/types.rs b/src/tui/src/ui/app/types.rs index f0d55d26..c02c23ec 100644 --- a/src/tui/src/ui/app/types.rs +++ b/src/tui/src/ui/app/types.rs @@ -350,6 +350,16 @@ pub enum Cmd { InspectContext, /// Apply a worker fleet mutation. WorkerOp(WorkerOp), + /// Retarget the live screen subscription: stop watching one task, start + /// watching another. Both halves ride one command so the change is atomic + /// from the loop's point of view — a stop that landed without its start + /// would leave the pane blank with nothing on the way. + WatchTask { + /// The `(worker address, task id)` to stop streaming, if any. + stop: Option<(String, String)>, + /// The `(worker address, task id)` to start streaming, if any. + start: Option<(String, String)>, + }, /// Load the persona-memory status + directives for the Memory tab. LoadMemory, /// Fetch account-level usage from the backend for the Usage tab. @@ -598,6 +608,12 @@ pub struct App { pub(super) contexts: Vec, pub(super) context_index: usize, pub(super) agent_index: usize, + /// The `(worker address, task id)` whose screen is currently subscribed. + /// + /// Held so a selection change can stop the old stream as well as start the + /// new one: a subscription nobody is looking at costs the worker a sample, + /// a ratchet advance and a send on every tick. + pub(super) watching: Option<(String, String)>, /// Which half of the Agents tab the keyboard is driving. pub(super) agents_focus: AgentsFocus, pub(super) agent_scroll: usize, diff --git a/src/tui/src/ui/mod.rs b/src/tui/src/ui/mod.rs index e3f4a470..2faeaac4 100644 --- a/src/tui/src/ui/mod.rs +++ b/src/tui/src/ui/mod.rs @@ -23,6 +23,7 @@ pub(crate) mod layout; pub mod login; pub(crate) mod multi_pane; pub mod onboarding; +pub mod screen; pub(crate) mod selection; pub mod theme; pub mod welcome; diff --git a/src/tui/src/ui/screen/mod.rs b/src/tui/src/ui/screen/mod.rs new file mode 100644 index 00000000..90a8e5ce --- /dev/null +++ b/src/tui/src/ui/screen/mod.rs @@ -0,0 +1,93 @@ +//! Rendering a remote worker's synchronised screen into a ratatui pane. +//! +//! The orchestrator's counterpart to [`crate::worker::screen`], which does the +//! same job for a session running locally. The two differ only in what they +//! start from: the worker has emulator cells, the hub has the wire model those +//! cells were coalesced into. They must agree about what a screen *looks* like, +//! so the styling rules here deliberately mirror that module's — most of all +//! the inverse-video one, where disagreeing would show as invisible text in a +//! harness status bar on one view and not the other. +//! +//! No state and no I/O, so the mapping is testable against literal grids. + +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; + +use medulla::tinyplace::{ + Color as WireColor, RunStyle, ScreenGrid, ScreenRun, ATTR_BOLD, ATTR_INVERSE, ATTR_ITALIC, + ATTR_UNDERLINE, +}; + +/// Convert a wire colour to a ratatui one. +/// +/// `Default` becomes [`Color::Reset`] so unstyled text inherits the *viewer's* +/// palette rather than one the sending machine picked. +pub fn wire_color(color: WireColor) -> Color { + match color { + WireColor::Default => Color::Reset, + WireColor::Idx(i) => Color::Indexed(i), + WireColor::Rgb(r, g, b) => Color::Rgb(r, g, b), + } +} + +/// The ratatui style for one run. +/// +/// Inverse is applied by swapping foreground and background rather than with +/// [`Modifier::REVERSED`] — terminals disagree about how REVERSED composes with +/// an explicit background, which shows up as invisible text in status bars. The +/// wire format carries it as a flag precisely so the decision can be made here, +/// at paint time, exactly as the local renderer makes it. +pub fn run_style(style: &RunStyle) -> Style { + let (fg, bg) = if style.has(ATTR_INVERSE) { + (wire_color(style.bg), wire_color(style.fg)) + } else { + (wire_color(style.fg), wire_color(style.bg)) + }; + let mut out = Style::default().fg(fg).bg(bg); + if style.has(ATTR_BOLD) { + out = out.add_modifier(Modifier::BOLD); + } + if style.has(ATTR_ITALIC) { + out = out.add_modifier(Modifier::ITALIC); + } + if style.has(ATTR_UNDERLINE) { + out = out.add_modifier(Modifier::UNDERLINED); + } + out +} + +/// One row of runs as a styled line. +fn row_line(runs: &[ScreenRun]) -> Line<'static> { + Line::from( + runs.iter() + .map(|run| Span::styled(run.text.clone(), run_style(&run.style))) + .collect::>(), + ) +} + +/// Convert a synchronised screen into ratatui lines, top to bottom. +/// +/// Rows arrive already coalesced into runs by the sender, so there is nothing to +/// merge here — a row is a handful of spans, not a span per cell. +pub fn grid_lines(grid: &ScreenGrid) -> Vec> { + grid.lines.iter().map(|runs| row_line(runs)).collect() +} + +/// The pane title for a watched screen: which harness, whose session, how fresh. +/// +/// Staleness is stated rather than implied. A screen that has not changed and a +/// stream that has died look identical, and only one of them means the worker +/// needs attention. +pub fn screen_title(session_id: &str, seq: i64, age_ms: i64) -> String { + let age = if age_ms < 1_000 { + format!("{age_ms}ms") + } else if age_ms < 60_000 { + format!("{:.1}s", age_ms as f64 / 1_000.0) + } else { + format!("{}m", age_ms / 60_000) + }; + format!("{session_id} · seq {seq} · {age} ago") +} + +#[cfg(test)] +mod tests; diff --git a/src/tui/src/ui/screen/tests.rs b/src/tui/src/ui/screen/tests.rs new file mode 100644 index 00000000..61cf656c --- /dev/null +++ b/src/tui/src/ui/screen/tests.rs @@ -0,0 +1,108 @@ +//! Unit tests for the remote-screen pane renderer. +//! +//! The rules here have to match `crate::worker::screen`'s, since the two render +//! the same screen from different sides. Where that matters — inverse video, +//! and inheriting the viewer's palette — it is asserted rather than assumed. + +use super::*; + +fn styled(attrs: u8) -> RunStyle { + RunStyle { + attrs, + ..RunStyle::default() + } +} + +#[test] +fn default_colours_inherit_the_viewers_palette() { + // Not the sending machine's: a hub rendering a worker's screen must not + // force colours the worker never chose. + assert_eq!(wire_color(WireColor::Default), Color::Reset); + assert_eq!(wire_color(WireColor::Idx(4)), Color::Indexed(4)); + assert_eq!(wire_color(WireColor::Rgb(1, 2, 3)), Color::Rgb(1, 2, 3)); +} + +#[test] +fn inverse_swaps_the_colours_rather_than_setting_reversed() { + // The same rule the worker's own renderer applies. REVERSED composes + // inconsistently with an explicit background across terminals, which shows + // as invisible text in a harness status bar. + let style = run_style(&RunStyle { + fg: WireColor::Idx(1), + bg: WireColor::Idx(7), + attrs: ATTR_INVERSE, + }); + assert_eq!(style.fg, Some(Color::Indexed(7))); + assert_eq!(style.bg, Some(Color::Indexed(1))); + assert!(!style.add_modifier.contains(Modifier::REVERSED)); +} + +#[test] +fn attributes_become_modifiers() { + let style = run_style(&styled(ATTR_BOLD | ATTR_ITALIC | ATTR_UNDERLINE)); + assert!(style.add_modifier.contains(Modifier::BOLD)); + assert!(style.add_modifier.contains(Modifier::ITALIC)); + assert!(style.add_modifier.contains(Modifier::UNDERLINED)); +} + +#[test] +fn each_row_becomes_one_line_and_each_run_one_span() { + let grid = ScreenGrid { + cols: 6, + rows: 2, + lines: vec![ + vec![ + ScreenRun::plain("ab"), + ScreenRun::new("cd", styled(ATTR_BOLD)), + ], + vec![ScreenRun::plain("ef")], + ], + cursor: (0, 0), + hide_cursor: false, + }; + let lines = grid_lines(&grid); + assert_eq!(lines.len(), 2); + assert_eq!(lines[0].spans.len(), 2); + assert_eq!(lines[0].spans[0].content, "ab"); + assert_eq!(lines[0].spans[1].content, "cd"); + assert!(lines[0].spans[1] + .style + .add_modifier + .contains(Modifier::BOLD)); + assert_eq!(lines[1].spans.len(), 1); +} + +#[test] +fn an_empty_row_renders_as_an_empty_line_not_a_missing_one() { + // Blank rows are dropped to nothing by the sender's coalescing. They still + // have to occupy their row, or everything below shifts up. + let grid = ScreenGrid { + cols: 4, + rows: 3, + lines: vec![ + vec![ScreenRun::plain("top")], + Vec::new(), + vec![ScreenRun::plain("end")], + ], + cursor: (0, 0), + hide_cursor: false, + }; + let lines = grid_lines(&grid); + assert_eq!(lines.len(), 3, "the blank row still takes a line"); + assert!(lines[1].spans.is_empty()); + assert_eq!(lines[2].spans[0].content, "end"); +} + +#[test] +fn an_empty_grid_renders_nothing_without_panicking() { + assert!(grid_lines(&ScreenGrid::default()).is_empty()); +} + +#[test] +fn the_title_states_how_stale_the_screen_is() { + // A still screen and a dead stream look identical; only the age tells them + // apart, so it is in the title rather than inferred. + assert_eq!(screen_title("w_3", 418, 400), "w_3 · seq 418 · 400ms ago"); + assert_eq!(screen_title("w_3", 418, 2_500), "w_3 · seq 418 · 2.5s ago"); + assert_eq!(screen_title("w_3", 418, 185_000), "w_3 · seq 418 · 3m ago"); +} diff --git a/src/tui/src/worker/executor/mod.rs b/src/tui/src/worker/executor/mod.rs index c2cff77b..3c1787c4 100644 --- a/src/tui/src/worker/executor/mod.rs +++ b/src/tui/src/worker/executor/mod.rs @@ -145,6 +145,13 @@ impl PtySessionExecutor { }; } let id = opened.id; + // Tell the daemon which session serves this task, as soon as it exists. + // A screen subscription names a task, so until this lands there is + // nothing for it to resolve — and the whole point is to watch the task + // while it runs, not to learn where it ran once it is over. + if let Some(report) = options.on_session { + report(id.clone()); + } // Latch the tailer *before* typing. Locating is lazy, and a resumed tail // takes its start offset from the file's length at the moment it diff --git a/src/tui/src/worker/executor_tests/live.rs b/src/tui/src/worker/executor_tests/live.rs index a8682560..51a80978 100644 --- a/src/tui/src/worker/executor_tests/live.rs +++ b/src/tui/src/worker/executor_tests/live.rs @@ -107,6 +107,7 @@ fn live_options( router: None, on_event: None, on_stdin: None, + on_session: None, } } diff --git a/src/tui/src/worker/executor_tests/mod.rs b/src/tui/src/worker/executor_tests/mod.rs index d673d01a..c1fbf935 100644 --- a/src/tui/src/worker/executor_tests/mod.rs +++ b/src/tui/src/worker/executor_tests/mod.rs @@ -93,6 +93,7 @@ fn options( router: None, on_event: None, on_stdin: None, + on_session: None, } } diff --git a/src/tui/src/worker/mod.rs b/src/tui/src/worker/mod.rs index e2a46091..879d665f 100644 --- a/src/tui/src/worker/mod.rs +++ b/src/tui/src/worker/mod.rs @@ -19,6 +19,7 @@ pub mod executor; mod executor_tests; pub mod pty; pub mod screen; +pub mod stream; pub mod trust; pub use app::{WorkerApp, WorkerWiring}; diff --git a/src/tui/src/worker/stream/convert.rs b/src/tui/src/worker/stream/convert.rs new file mode 100644 index 00000000..409f5e9a --- /dev/null +++ b/src/tui/src/worker/stream/convert.rs @@ -0,0 +1,82 @@ +//! The boundary between the emulator's screen and the wire model. +//! +//! The SDK's screen protocol is deliberately free of `vt100` — that is what +//! keeps the diff, the fold and the codec testable without a pty. So the +//! translation lives here, in the crate that already owns the emulator, and is +//! the only place the two vocabularies meet. +//! +//! It is a pure function of a [`ScreenSnapshot`], so it can be exercised against +//! literal cells with no child process involved. + +use medulla::tinyplace::{coalesce_runs, Color, RunStyle, ScreenGrid, ScreenRun}; + +use super::super::pty::{ScreenCell, ScreenSnapshot}; + +/// Map an emulator colour onto the wire's. +/// +/// `Default` is carried through as `Default` rather than resolved to a concrete +/// colour: the viewer should inherit its own palette for unstyled text, exactly +/// as the local renderer does. +pub fn wire_color(color: vt100::Color) -> Color { + match color { + vt100::Color::Default => Color::Default, + vt100::Color::Idx(i) => Color::Idx(i), + vt100::Color::Rgb(r, g, b) => Color::Rgb(r, g, b), + } +} + +/// The wire style for one emulator cell. +/// +/// Note that `inverse` is carried as a flag rather than applied by swapping +/// foreground and background here. The local renderer swaps them at paint time +/// because terminals disagree about how REVERSED composes with an explicit +/// background — but that is a rendering decision, and baking it into the wire +/// format would leave the viewer unable to tell an inverted cell from a +/// deliberately colour-swapped one. +pub fn wire_style(cell: &ScreenCell) -> RunStyle { + let mut attrs = 0u8; + if cell.bold { + attrs |= medulla::tinyplace::ATTR_BOLD; + } + if cell.italic { + attrs |= medulla::tinyplace::ATTR_ITALIC; + } + if cell.underline { + attrs |= medulla::tinyplace::ATTR_UNDERLINE; + } + if cell.inverse { + attrs |= medulla::tinyplace::ATTR_INVERSE; + } + RunStyle { + fg: wire_color(cell.fg), + bg: wire_color(cell.bg), + attrs, + } +} + +/// Convert one row of cells into coalesced runs. +fn wire_row(cells: &[ScreenCell]) -> Vec { + coalesce_runs( + cells + .iter() + .map(|cell| (cell.text.clone(), wire_style(cell))), + ) +} + +/// Convert an emulator snapshot into the grid the protocol synchronises. +/// +/// Dimensions are taken from the snapshot itself rather than from the session's +/// configured size, so the grid always describes what was actually read — a +/// snapshot taken mid-resize describes the screen it came from, not the one the +/// pty is about to become. +pub fn wire_grid(snapshot: &ScreenSnapshot) -> ScreenGrid { + let rows = snapshot.cells.len() as u16; + let cols = snapshot.cells.first().map(|row| row.len()).unwrap_or(0) as u16; + ScreenGrid { + cols, + rows, + lines: snapshot.cells.iter().map(|row| wire_row(row)).collect(), + cursor: snapshot.cursor, + hide_cursor: snapshot.hide_cursor, + } +} diff --git a/src/tui/src/worker/stream/mod.rs b/src/tui/src/worker/stream/mod.rs new file mode 100644 index 00000000..cd1e33a3 --- /dev/null +++ b/src/tui/src/worker/stream/mod.rs @@ -0,0 +1,30 @@ +//! Streaming a live harness session's screen to a watching orchestrator. +//! +//! The worker half of the `medulla.screen.v1` protocol, whose diff, fold and +//! codec live in the SDK ([`medulla::tinyplace::screen`]). What is here is the +//! part that cannot be: the emulator lives in this crate, so both the +//! translation out of `vt100` and the timer that samples it do too. +//! +//! - [`convert`] — emulator cells to the wire's grid, the one place the two +//! vocabularies meet. +//! - [`sampler`] — [`SessionStream`], the pure frame decision, plus the task and +//! registry that drive it. +//! - [`router`] — the inbound half: who may watch what, and starting or stopping +//! the stream that answers. +//! +//! The worker is the sender and the orchestrator is a passive observer: nothing +//! here accepts input or resizes a session, so a subscription can only ever +//! read. That is why the module needs no trust gate of its own — though *which* +//! sessions a given peer may watch is a question for the caller that owns the +//! subscribe frame, not for the sampler. + +pub mod convert; +pub mod router; +pub mod sampler; + +#[cfg(test)] +mod tests; + +pub use convert::{wire_color, wire_grid, wire_style}; +pub use router::ScreenRouter; +pub use sampler::{sample_interval, send_fn, spawn_session_stream, SessionStream, StreamRegistry}; diff --git a/src/tui/src/worker/stream/router.rs b/src/tui/src/worker/stream/router.rs new file mode 100644 index 00000000..138b01d4 --- /dev/null +++ b/src/tui/src/worker/stream/router.rs @@ -0,0 +1,147 @@ +//! Serving inbound `medulla.screen.v1` messages: resolving which session a +//! subscriber may watch, and starting or stopping the stream that answers. +//! +//! This is the half of the protocol the worker *receives*. The vocabulary is +//! deliberately tiny — subscribe, unsubscribe, acknowledge — because the viewer +//! is a passive observer: there is no message here that can type into a session +//! or resize one, so the worst a subscriber can do is read a screen. +//! +//! Which leaves one question, and the answer is structural rather than a check. +//! A subscription names a **task**, and the daemon's running-task record is +//! keyed by `(authenticated sender, task id)` — so resolving one at all proves +//! the caller dispatched it. A peer cannot name another's task, because the key +//! it would need includes an identity it does not have. There is no ownership +//! field to keep in sync and no comparison to get wrong. +//! +//! Addressing by task rather than by session is sound because a session serves +//! one task at a time: `claim_idle` refuses a busy session and marks the one it +//! returns, under a single lock, and discrete work always opens a fresh +//! session. The record also disappears when the task settles, so a stream ends +//! with the work rather than outliving it. + +use medulla::daemon::{DaemonRuntime, LogFn, SendFn}; +use medulla::tinyplace::ScreenMessage; + +use super::super::pty::PtyManager; +use super::sampler::StreamRegistry; + +/// Serves screen subscriptions for one worker. +/// +/// Holds the live streams, so dropping it drops them. +pub struct ScreenRouter { + sessions: PtyManager, + runtime: DaemonRuntime, + registry: StreamRegistry, + send: SendFn, + log: Option, +} + +impl ScreenRouter { + /// Build a router over the worker's live sessions, task records and + /// encrypted transport. + pub fn new(sessions: PtyManager, runtime: DaemonRuntime, send: SendFn) -> Self { + ScreenRouter { + sessions, + runtime, + registry: StreamRegistry::new(), + send, + log: None, + } + } + + /// Narrate refusals and subscriptions to `log`. + /// + /// Worth having: a refused subscribe is otherwise indistinguishable from a + /// message that never arrived, and those call for opposite investigations. + pub fn with_log(mut self, log: LogFn) -> Self { + self.log = Some(log); + self + } + + fn log(&self, line: &str) { + if let Some(log) = &self.log { + log(line); + } + } + + /// How many tasks are currently being streamed. + pub fn active(&self) -> usize { + self.registry.len() + } + + /// Handle one screen message from the authenticated peer `from`. + /// + /// A task that cannot be resolved is answered with silence. Replying would + /// distinguish "not yours" from "no such task", which tells an unauthorized + /// caller what is running on this machine. + pub fn handle(&mut self, from: &str, message: ScreenMessage) { + // Streams whose task has settled leave a finished sampler behind; clear + // them so a long-lived worker's registry tracks reality. + self.registry.prune(); + + match message { + ScreenMessage::Subscribe { + task_id, + max_fps, + resync, + } => self.subscribe(from, &task_id, max_fps, resync), + ScreenMessage::Unsubscribe { task_id } => { + // Resolved like a subscribe: without it, any peer could cancel + // another's stream by naming its task id. + if self.runtime.session_for_task(from, &task_id).is_some() { + self.registry.unsubscribe(&task_id); + self.log(&format!("screen: {from} unsubscribed from {task_id}")); + } + } + // The sampler chains from the last frame it actually sent, so an + // acknowledgement is not needed to decide what to send next. Kept in + // the protocol as the viewer's liveness signal and accepted here so + // it is never mistaken for an unknown message. + ScreenMessage::Ack { .. } => {} + // We are the sender; a frame arriving here is either a loopback or a + // peer that has the protocol backwards. + ScreenMessage::Frame(_) => {} + } + } + + /// Start, restart, or leave alone the stream for `task_id`. + fn subscribe(&mut self, from: &str, task_id: &str, max_fps: u8, resync: bool) { + let Some(session_id) = self.runtime.session_for_task(from, task_id) else { + self.log(&format!( + "screen: refused {from} on {task_id} — no such running task for this sender" + )); + return; + }; + // A headless run has a record but no pty, so there is nothing to watch + // even though the task is real. + if self.sessions.row(&session_id).is_none() { + self.log(&format!( + "screen: refused {from} on {task_id} — that task has no watchable session" + )); + return; + } + // An already-running stream is left alone unless the viewer says it has + // lost track. Restarting on every subscribe would force a full frame + // each time, which is the expensive thing this protocol exists to avoid. + if self.registry.contains(task_id) && !resync { + return; + } + self.registry.subscribe( + &self.sessions, + task_id, + &session_id, + from, + max_fps, + self.send.clone(), + ); + self.log(&format!( + "screen: streaming {task_id} (session {session_id}) to {from} at {max_fps} fps{}", + if resync { " (resync)" } else { "" } + )); + } + + /// Drop every subscription — used on shutdown. + pub fn clear(&mut self) { + self.registry.clear(); + } +} diff --git a/src/tui/src/worker/stream/sampler.rs b/src/tui/src/worker/stream/sampler.rs new file mode 100644 index 00000000..a1847de3 --- /dev/null +++ b/src/tui/src/worker/stream/sampler.rs @@ -0,0 +1,234 @@ +//! The sampler: what turns a live emulator into a frame stream. +//! +//! [`SessionStream`] is the whole decision, and it is pure — it takes a snapshot +//! and returns the frame to send, if any. The tokio task that reads the +//! emulator on a timer and hands frames to the transport is a thin shell around +//! it ([`spawn_session_stream`]), so the interesting behaviour is testable +//! without a pty, a runtime, or a network. +//! +//! Sampling is deliberately driven by a **timer, not by pty output**. A harness +//! repainting a spinner at 60 Hz would otherwise emit sixty frames a second, +//! each one an HTTPS request and a ratchet advance on the lock that also +//! serialises task dispatch. Sampling makes wire cost a function of the +//! subscribed rate alone, however much the harness is churning — which is the +//! property that makes this affordable over a mailbox at all. + +use std::sync::Arc; +use std::time::Duration; + +use medulla::daemon::SendFn; +use medulla::tinyplace::{ + build_frame, encode_screen_message, FrameDecision, ScreenFrame, ScreenGrid, ScreenMessage, +}; + +use super::super::pty::{PtyManager, ScreenSnapshot}; +use super::convert::wire_grid; + +/// The slowest rate a subscription may request. +const MIN_FPS: u8 = 1; +/// The fastest rate a subscription may request. +/// +/// Every frame costs a send, a ratchet advance and an acknowledge on the +/// viewer's side, so this is a ceiling on how much of the shared transport one +/// watcher may consume — not a rendering limit. +const MAX_FPS: u8 = 10; + +/// How often to sample for a requested rate, clamped to `[MIN_FPS, MAX_FPS]`. +pub fn sample_interval(max_fps: u8) -> Duration { + let fps = max_fps.clamp(MIN_FPS, MAX_FPS); + Duration::from_millis(1_000 / u64::from(fps)) +} + +/// One session's outbound stream state. +/// +/// Holds the last screen it put on the wire, so the next frame can be a diff +/// against it. A stream begins owing a full frame, and owes another whenever the +/// viewer says it has lost track. +pub struct SessionStream { + task_id: String, + /// Sequence of the last frame actually sent. Frames the sampler skips do not + /// advance it, so the chain the viewer follows has no gaps in it. + seq: i64, + /// The screen the viewer is believed to hold. + last_sent: Option, + /// Whether the next frame must be a full one. + owes_full: bool, +} + +impl SessionStream { + /// Start a stream for `session_id`. The first frame it produces is full. + pub fn new(task_id: impl Into) -> Self { + SessionStream { + task_id: task_id.into(), + seq: 0, + last_sent: None, + owes_full: true, + } + } + + /// The sequence of the last frame sent. + pub fn seq(&self) -> i64 { + self.seq + } + + /// Note that the viewer has lost track: the next frame will be full. + /// + /// Called for `subscribe { resync: true }`, which is both how a stream + /// starts and how a viewer recovers from a gap. + pub fn request_resync(&mut self) { + self.owes_full = true; + } + + /// Decide what to send for `snapshot`, advancing the stream if anything goes + /// out. + /// + /// Returns `None` when the screen is unchanged — the common case on an idle + /// session, and the reason a watched-but-quiet worker costs nothing. + pub fn tick(&mut self, snapshot: &ScreenSnapshot) -> Option { + let grid = wire_grid(snapshot); + // A resync throws away what the viewer was believed to hold, which makes + // `build_frame` produce a full frame from a clean base. + let previous = if self.owes_full { + None + } else { + self.last_sent.as_ref() + }; + let base_seq = if self.owes_full { 0 } else { self.seq }; + + match build_frame(previous, &grid, &self.task_id, self.seq + 1, base_seq) { + FrameDecision::Unchanged => None, + FrameDecision::Send(frame) => { + self.seq += 1; + self.last_sent = Some(grid); + self.owes_full = false; + Some(frame) + } + } + } +} + +/// Drive one subscription: sample the session on a timer and send what changes. +/// +/// Ends when the session disappears — the harness exited, or the manager forgot +/// it — so a subscription cannot outlive the thing it is watching. Dropping the +/// returned handle aborts it, which is how `unsubscribe` is served. +pub fn spawn_session_stream( + sessions: PtyManager, + task_id: String, + session_id: String, + subscriber: String, + max_fps: u8, + send: SendFn, +) -> tokio::task::JoinHandle<()> { + let interval = sample_interval(max_fps); + tokio::spawn(async move { + // Frames are addressed by task — what the subscriber named and holds — + // while sampling reads the session the task is running in. + let mut stream = SessionStream::new(task_id); + loop { + // Read the emulator, never the pty: `screen_rows` hands back an + // owned copy so the sampler never holds the parser lock across the + // send, which would stall the reader thread feeding it. + let Some(snapshot) = sessions.screen_rows(&session_id) else { + return; // the session is gone; so is the subscription + }; + if let Some(frame) = stream.tick(&snapshot) { + let body = encode_screen_message(&ScreenMessage::Frame(frame)); + send(subscriber.clone(), body).await; + } + tokio::time::sleep(interval).await; + } + }) +} + +/// The live subscriptions this worker is serving, keyed by task. +/// +/// One viewer per task: a second `subscribe` replaces the first rather than +/// fanning out, since two watchers would double the transport cost for no new +/// information. +#[derive(Default)] +pub struct StreamRegistry { + streams: std::collections::HashMap>, +} + +impl StreamRegistry { + /// An empty registry. + pub fn new() -> Self { + StreamRegistry::default() + } + + /// Start (or restart) the stream for `session_id`. + /// + /// Restarting is how a resync is served: the replacement stream begins + /// owing a full frame, which is exactly what the viewer asked for. + pub fn subscribe( + &mut self, + sessions: &PtyManager, + task_id: &str, + session_id: &str, + subscriber: &str, + max_fps: u8, + send: SendFn, + ) { + self.unsubscribe(task_id); + let handle = spawn_session_stream( + sessions.clone(), + task_id.to_string(), + session_id.to_string(), + subscriber.to_string(), + max_fps, + send, + ); + self.streams.insert(task_id.to_string(), handle); + } + + /// Stop streaming `task_id`, if it was being streamed. + pub fn unsubscribe(&mut self, task_id: &str) { + if let Some(handle) = self.streams.remove(task_id) { + handle.abort(); + } + } + + /// How many sessions are being streamed. + pub fn len(&self) -> usize { + self.streams.len() + } + + /// Whether `task_id` already has a live stream. + pub fn contains(&self, task_id: &str) -> bool { + self.streams + .get(task_id) + .is_some_and(|handle| !handle.is_finished()) + } + + /// Whether nothing is being streamed. + pub fn is_empty(&self) -> bool { + self.streams.is_empty() + } + + /// Drop every subscription — used on shutdown. + pub fn clear(&mut self) { + for (_, handle) in self.streams.drain() { + handle.abort(); + } + } +} + +/// Reap subscriptions whose task has ended, so a registry driven by a long-lived +/// worker does not accumulate handles for sessions that exited. +impl StreamRegistry { + /// Forget every stream whose task has finished. + pub fn prune(&mut self) { + self.streams.retain(|_, handle| !handle.is_finished()); + } +} + +/// Build a [`SendFn`] over a closure, matching the shape the daemon runtime +/// already takes so a caller can hand both the same transport. +pub fn send_fn(f: F) -> SendFn +where + F: Fn(String, String) -> Fut + Send + Sync + 'static, + Fut: std::future::Future + Send + 'static, +{ + Arc::new(move |to, body| Box::pin(f(to, body)) as _) +} diff --git a/src/tui/src/worker/stream/tests.rs b/src/tui/src/worker/stream/tests.rs new file mode 100644 index 00000000..1d776181 --- /dev/null +++ b/src/tui/src/worker/stream/tests.rs @@ -0,0 +1,426 @@ +//! Unit tests for the worker's half: the emulator-to-wire conversion and the +//! sampler's frame decision. +//! +//! Both are pure, so none of this needs a pty, a runtime or a network. The +//! end-to-end property — a viewer folding what a sampler emits ends up holding +//! the emulator's screen — is asserted here too, since it is the only check that +//! covers the conversion and the diff *together*. + +use medulla::tinyplace::{ + apply_frame, ApplyOutcome, Color, ScreenRun, ScreenView, ATTR_BOLD, ATTR_INVERSE, + ATTR_UNDERLINE, +}; + +use super::super::pty::{ScreenCell, ScreenSnapshot}; +use super::*; + +/// A cell with text and no styling. +fn cell(text: &str) -> ScreenCell { + ScreenCell { + text: text.to_string(), + ..ScreenCell::default() + } +} + +/// A snapshot from rows of text, one cell per character. +fn snapshot(rows: &[&str]) -> ScreenSnapshot { + ScreenSnapshot { + cells: rows + .iter() + .map(|row| row.chars().map(|c| cell(&c.to_string())).collect()) + .collect(), + cursor: (0, 0), + hide_cursor: false, + } +} + +// --- conversion ------------------------------------------------------------ + +#[test] +fn colours_map_across_without_being_resolved() { + // Default must stay Default: the viewer inherits its own palette for + // unstyled text, exactly as the local renderer does. + assert_eq!(wire_color(vt100::Color::Default), Color::Default); + assert_eq!(wire_color(vt100::Color::Idx(4)), Color::Idx(4)); + assert_eq!(wire_color(vt100::Color::Rgb(1, 2, 3)), Color::Rgb(1, 2, 3)); +} + +#[test] +fn attributes_become_flags() { + let styled = ScreenCell { + text: "x".into(), + bold: true, + underline: true, + ..ScreenCell::default() + }; + let style = wire_style(&styled); + assert!(style.has(ATTR_BOLD)); + assert!(style.has(ATTR_UNDERLINE)); + assert!(!style.has(ATTR_INVERSE)); +} + +#[test] +fn inverse_is_carried_as_a_flag_not_baked_into_the_colours() { + // The local renderer swaps fg/bg at paint time because terminals disagree + // about REVERSED. Baking that into the wire would leave the viewer unable to + // tell an inverted cell from a deliberately colour-swapped one. + let inverted = ScreenCell { + text: "x".into(), + fg: vt100::Color::Idx(1), + bg: vt100::Color::Idx(7), + inverse: true, + ..ScreenCell::default() + }; + let style = wire_style(&inverted); + assert_eq!(style.fg, Color::Idx(1), "colours must not be pre-swapped"); + assert_eq!(style.bg, Color::Idx(7)); + assert!(style.has(ATTR_INVERSE)); +} + +#[test] +fn a_grid_takes_its_size_from_the_snapshot() { + let grid = wire_grid(&snapshot(&["abcd", "efgh", "ijkl"])); + assert_eq!(grid.rows, 3); + assert_eq!(grid.cols, 4); + assert_eq!(grid.lines.len(), 3); + assert_eq!(grid.lines[0], vec![ScreenRun::plain("abcd")]); +} + +#[test] +fn rows_are_coalesced_and_trailing_blanks_dropped() { + // A 120-column screen is mostly blank; carrying it cell by cell would + // dominate every frame. + let grid = wire_grid(&snapshot(&["hi "])); + assert_eq!(grid.lines[0], vec![ScreenRun::plain("hi")]); + assert_eq!(grid.cols, 10, "the row is still ten cells wide"); +} + +#[test] +fn an_empty_snapshot_converts_without_panicking() { + let grid = wire_grid(&ScreenSnapshot { + cells: Vec::new(), + cursor: (0, 0), + hide_cursor: false, + }); + assert_eq!(grid.rows, 0); + assert_eq!(grid.cols, 0); +} + +// --- the sampler ----------------------------------------------------------- + +#[test] +fn the_first_tick_is_a_full_frame() { + let mut stream = SessionStream::new("w_1"); + let frame = stream.tick(&snapshot(&["a", "b"])).expect("a first frame"); + assert!(frame.full); + assert_eq!(frame.seq, 1); + assert_eq!(stream.seq(), 1); +} + +#[test] +fn an_unchanged_screen_produces_nothing_and_does_not_advance_the_seq() { + // The gap-free chain matters: a skipped frame that still burned a sequence + // number would make the viewer's next base_seq check fail for no reason. + let mut stream = SessionStream::new("w_1"); + let screen = snapshot(&["steady"]); + assert!(stream.tick(&screen).is_some()); + assert_eq!(stream.seq(), 1); + assert!(stream.tick(&screen).is_none()); + assert!(stream.tick(&screen).is_none()); + assert_eq!(stream.seq(), 1, "skipped frames must not advance the chain"); +} + +#[test] +fn a_change_after_a_quiet_stretch_still_chains_from_the_last_sent_frame() { + let mut stream = SessionStream::new("w_1"); + stream.tick(&snapshot(&["one", "two"])); + stream.tick(&snapshot(&["one", "two"])); + let frame = stream + .tick(&snapshot(&["one", "CHANGED"])) + .expect("a change is a frame"); + assert!(!frame.full); + assert_eq!(frame.seq, 2); + assert_eq!( + frame.base_seq, 1, + "chains from the last frame actually sent" + ); + assert_eq!(frame.rows_changed.len(), 1); + assert_eq!(frame.rows_changed[0].y, 1); +} + +#[test] +fn a_resync_request_forces_the_next_frame_full() { + let mut stream = SessionStream::new("w_1"); + stream.tick(&snapshot(&["a", "b"])); + let delta = stream.tick(&snapshot(&["a", "c"])).expect("a delta"); + assert!(!delta.full); + + stream.request_resync(); + let full = stream + .tick(&snapshot(&["a", "c"])) + .expect("a resync sends even though nothing changed"); + assert!(full.full, "a resync must produce a full frame"); + assert_eq!(full.rows_changed.len(), 2, "the whole screen, not a delta"); +} + +#[test] +fn a_resize_produces_a_full_frame_by_itself() { + let mut stream = SessionStream::new("w_1"); + stream.tick(&snapshot(&["abc"])); + let frame = stream + .tick(&snapshot(&["abcdef"])) + .expect("a resize is a change"); + assert!(frame.full, "row indices do not survive a resize"); + assert_eq!(frame.cols, 6); +} + +#[test] +fn the_sample_interval_is_clamped_to_a_sane_range() { + use std::time::Duration; + assert_eq!(sample_interval(1), Duration::from_millis(1000)); + assert_eq!(sample_interval(4), Duration::from_millis(250)); + assert_eq!(sample_interval(10), Duration::from_millis(100)); + // A zero would divide by zero; an absurd rate would flood the transport. + assert_eq!(sample_interval(0), Duration::from_millis(1000)); + assert_eq!(sample_interval(255), Duration::from_millis(100)); +} + +// --- sampler and viewer together ------------------------------------------- + +#[test] +fn a_viewer_folding_the_samplers_frames_ends_up_holding_the_emulators_screen() { + // The only check that covers the conversion and the diff together — either + // one being subtly wrong shows up here and nowhere else. + let screens = [ + snapshot(&["one ", "two ", "three "]), + snapshot(&["one ", "TWO! ", "three "]), + snapshot(&["one ", "TWO! ", "three "]), // idle: nothing sent + snapshot(&["wider now", "and ", "taller "]), // resize + ]; + + let mut stream = SessionStream::new("w_1"); + let mut view: Option = None; + + for screen in &screens { + if let Some(frame) = stream.tick(screen) { + assert_eq!( + apply_frame(&mut view, &frame), + ApplyOutcome::Applied, + "every frame the sampler emits must apply in order" + ); + } + assert_eq!( + &view.as_ref().expect("a view by now").grid, + &wire_grid(screen), + "viewer diverged from the emulator" + ); + } +} + +#[test] +fn a_viewer_that_missed_a_frame_recovers_through_a_resync() { + let mut stream = SessionStream::new("w_1"); + let mut view: Option = None; + + let first = stream.tick(&snapshot(&["a", "b"])).expect("first"); + apply_frame(&mut view, &first); + + // The frame carrying this change never reaches the viewer. + let _lost = stream.tick(&snapshot(&["a", "LOST"])).expect("second"); + + // So the next delta is unusable: it chains from a state the viewer lacks. + let orphan = stream.tick(&snapshot(&["a", "AGAIN"])).expect("third"); + assert_eq!(apply_frame(&mut view, &orphan), ApplyOutcome::NeedsResync); + + // The viewer asks to resync; the next frame is full and closes the gap + // without anything being retransmitted. + stream.request_resync(); + let recovered = stream.tick(&snapshot(&["a", "AGAIN"])).expect("resync"); + assert_eq!(apply_frame(&mut view, &recovered), ApplyOutcome::Applied); + assert_eq!( + view.expect("recovered").grid, + wire_grid(&snapshot(&["a", "AGAIN"])) + ); +} + +#[test] +fn styling_survives_the_whole_round_trip() { + let mut screen = snapshot(&["ab"]); + screen.cells[0][0] = ScreenCell { + text: "a".into(), + fg: vt100::Color::Idx(4), + bold: true, + ..ScreenCell::default() + }; + screen.cells[0][1] = ScreenCell { + text: "b".into(), + bg: vt100::Color::Rgb(9, 9, 9), + inverse: true, + ..ScreenCell::default() + }; + + let mut stream = SessionStream::new("w_1"); + let frame = stream.tick(&screen).expect("a frame"); + let mut view = None; + apply_frame(&mut view, &frame); + + let grid = view.expect("applied").grid; + assert_eq!( + grid.lines[0].len(), + 2, + "different styles stay separate runs" + ); + assert_eq!(grid.lines[0][0].style.fg, Color::Idx(4)); + assert!(grid.lines[0][0].style.has(ATTR_BOLD)); + assert_eq!(grid.lines[0][1].style.bg, Color::Rgb(9, 9, 9)); + assert!(grid.lines[0][1].style.has(ATTR_INVERSE)); +} + +// --- the spawned task ------------------------------------------------------ + +#[tokio::test] +async fn a_subscription_ends_when_its_session_does_not_exist() { + // A stream must not outlive the thing it is watching, and must not sit + // spinning against a session id that was never there. + use std::sync::{Arc, Mutex}; + use std::time::Duration; + + let sent: Arc>> = Arc::new(Mutex::new(Vec::new())); + let recorder = sent.clone(); + let send = send_fn(move |to, body| { + let recorder = recorder.clone(); + async move { + recorder.lock().unwrap().push((to, body)); + } + }); + + let handle = spawn_session_stream( + super::super::pty::PtyManager::new(), + "t1".to_string(), + "w_missing".to_string(), + "peer".to_string(), + 10, + send, + ); + + tokio::time::timeout(Duration::from_secs(5), handle) + .await + .expect("the task must end rather than spin") + .expect("and end cleanly"); + assert!( + sent.lock().unwrap().is_empty(), + "nothing should be sent for a session that does not exist" + ); +} + +#[tokio::test] +async fn unsubscribing_stops_the_stream() { + let sessions = super::super::pty::PtyManager::new(); + let send = send_fn(|_, _| async {}); + let mut registry = StreamRegistry::new(); + + assert!(registry.is_empty()); + registry.subscribe(&sessions, "t1", "w_1", "peer", 1, send.clone()); + assert_eq!(registry.len(), 1); + + // A second subscribe replaces rather than fans out: two watchers on one + // session would double its transport cost for no new information. + registry.subscribe(&sessions, "t1", "w_1", "peer", 1, send); + assert_eq!(registry.len(), 1); + + registry.unsubscribe("t1"); + assert!(registry.is_empty()); +} + +// --- the router ------------------------------------------------------------ + +/// A router over an empty worker: no sessions, no running tasks. +fn empty_router() -> ScreenRouter { + let runtime = medulla::daemon::DaemonRuntime::new( + medulla::daemon::DaemonConfig { + providers: vec![medulla::tinyplace::HarnessProvider::Claude], + default_provider: medulla::tinyplace::HarnessProvider::Claude, + workspace: "/tmp".into(), + env: std::collections::HashMap::new(), + task_timeout_ms: 1_000, + capability_timeout_ms: None, + concurrency: 1, + status_throttle_ms: 1_000, + max_pending: 1, + model: None, + agent: None, + extra_args: Vec::new(), + skip_permissions: false, + accessible_dirs: Vec::new(), + router: None, + budget: None, + }, + std::sync::Arc::new(|_| Box::pin(async { Err("unused".to_string()) })), + std::sync::Arc::new(|_, _| Box::pin(async {})), + ); + ScreenRouter::new( + super::super::pty::PtyManager::new(), + runtime, + send_fn(|_, _| async {}), + ) +} + +#[tokio::test] +async fn a_subscribe_for_a_task_this_sender_never_dispatched_is_refused() { + // Authorization is structural: the running-task record is keyed by + // (authenticated sender, task id), so a peer cannot name another's task — + // the key it would need includes an identity it does not have. + let mut router = empty_router(); + router.handle( + "peerA", + medulla::tinyplace::ScreenMessage::Subscribe { + task_id: "t1".into(), + max_fps: 1, + resync: true, + }, + ); + assert_eq!(router.active(), 0, "nothing may be streamed"); +} + +#[tokio::test] +async fn an_unsubscribe_for_an_unresolvable_task_does_nothing() { + // Same rule in the other direction: without it, any peer could cancel + // another's stream by naming its task id. + let mut router = empty_router(); + router.handle( + "peerA", + medulla::tinyplace::ScreenMessage::Unsubscribe { + task_id: "t1".into(), + }, + ); + assert_eq!(router.active(), 0); +} + +#[tokio::test] +async fn the_router_ignores_messages_it_is_not_the_receiver_for() { + // An ack is accepted and does nothing; a frame arriving at the sender is a + // peer with the protocol backwards. Neither may panic or start a stream. + let mut router = empty_router(); + router.handle( + "peerA", + medulla::tinyplace::ScreenMessage::Ack { + task_id: "t1".into(), + seq: 4, + }, + ); + router.handle( + "peerA", + medulla::tinyplace::ScreenMessage::Frame(medulla::tinyplace::ScreenFrame { + task_id: "t1".into(), + seq: 1, + base_seq: 0, + full: true, + cols: 1, + rows: 1, + cursor: (0, 0), + hide_cursor: false, + rows_changed: Vec::new(), + }), + ); + assert_eq!(router.active(), 0); +} diff --git a/src/tui/src/worker_loop/commands.rs b/src/tui/src/worker_loop/commands.rs index 5f096eb3..ce7f3592 100644 --- a/src/tui/src/worker_loop/commands.rs +++ b/src/tui/src/worker_loop/commands.rs @@ -90,7 +90,24 @@ fn start_worker( ) }); let daemon = worker_runtime(start, mode, provider, &transport); - *inbox = Some(spawn_inbox_drain(transport, daemon.clone())); + // Screens are only worth streaming when there are screens: the headless + // executor runs harnesses without a pty, so a subscriber finds nothing. + let screens = + medulla_tui::worker::stream::ScreenRouter::new(start.sessions.clone(), daemon.clone(), { + let sender = transport.clone(); + medulla_tui::worker::stream::send_fn(move |to: String, body: String| { + let sender = sender.clone(); + async move { + let _ = sender.send(&to, &body).await; + } + }) + }) + .with_log(start.logs.sink()); + // Best-effort push channel. Nothing below depends on it: it only shortens + // the wait between a peer sending and this loop looking. + let push = medulla::daemon::ws_inbox_enabled(&start.env) + .then(|| transport.spawn_inbox_listener(Some(start.logs.sink()))); + *inbox = Some(spawn_inbox_drain(transport, daemon.clone(), screens, push)); *runtime = Some(daemon); app.set_status(format!( "Serving peers · {} on {}", diff --git a/src/tui/src/worker_loop/mod.rs b/src/tui/src/worker_loop/mod.rs index 8e74de2e..4c60c3a1 100644 --- a/src/tui/src/worker_loop/mod.rs +++ b/src/tui/src/worker_loop/mod.rs @@ -231,17 +231,36 @@ pub(super) fn worker_runtime( } /// Drain the encrypted inbox into the runtime until aborted. +/// +/// Screen messages are claimed **before** the runtime sees the body, and that +/// ordering is load-bearing rather than tidy: `DaemonRuntime::handle_message` +/// routes anything that is not a task frame to the plain-text path, which types +/// it into a harness as a prompt. An unclaimed screen message would not be +/// ignored — it would be executed. pub(super) fn spawn_inbox_drain( transport: SignalTransport, runtime: DaemonRuntime, + mut screens: medulla_tui::worker::stream::ScreenRouter, + push: Option, ) -> tokio::task::JoinHandle<()> { tokio::spawn(async move { + // Held here so the push channel dies with the loop it feeds. A detached + // listener would keep a socket open for a drain that no longer exists — + // and a `JoinHandle` dropped on its own detaches rather than aborting. + let _push = push; loop { for message in transport.drain_inbox(50).await { + if let Some(screen) = medulla::tinyplace::parse_screen_message(&message.text) { + screens.handle(&message.from, screen); + continue; + } let frame = decode_task_frame(&message.text); runtime.handle_message(message.from, message.text, frame); } - tokio::time::sleep(INBOX_POLL).await; + // Returns early when the relay's push channel delivers, so a + // subscribe is acted on at about a round trip rather than up to a + // poll interval later. The interval stays the correctness floor. + transport.wait_for_inbox(INBOX_POLL).await; } }) } diff --git a/src/tui/tests/e2e_screen_stream.rs b/src/tui/tests/e2e_screen_stream.rs new file mode 100644 index 00000000..c8f13939 --- /dev/null +++ b/src/tui/tests/e2e_screen_stream.rs @@ -0,0 +1,305 @@ +//! End-to-end for `medulla.screen.v1`: a real harness on a real pty, sampled, +//! diffed, encoded, and folded into the hub's store. +//! +//! Every other test of this feature exercises one side against literal grids. +//! This is the only one that runs the whole chain — a child process writing to a +//! pseudo-terminal, a `vt100` emulator parsing it, the conversion into the wire +//! model, the diff, the JSON envelope, the hub's fold, and back out as the text +//! the child actually printed. +//! +//! Everything except the network: the transport is a channel rather than the +//! Signal relay, which is deliberate. What the relay does with an encrypted body +//! is proven separately and live in `medulla`'s `live_inbox_push`; what is +//! unproven without this is whether the two halves of the screen protocol agree +//! about a screen that came from a real terminal rather than one written by +//! hand. +//! +//! Unix-only: it runs `/bin/sh` on a pty, which Windows has no equivalent of. + +#![cfg(unix)] + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use medulla::daemon::{DaemonConfig, DaemonRuntime}; +use medulla::hub::ScreenStore; +use medulla::tinyplace::{ + parse_screen_message, ApplyOutcome, HarnessProvider, ScreenMessage, TaskFrameKind, +}; +use medulla_tui::worker::pty::{LaunchSpec, PtyManager}; +use medulla_tui::worker::stream::{send_fn, ScreenRouter}; + +/// How long to allow for a child to paint and a frame to be sampled. +/// +/// Generous on purpose: real children on real ptys are at the mercy of machine +/// load, and a tight deadline turns "the box was busy" into a red test. +const PATIENCE: Duration = Duration::from_secs(20); + +/// A spec that runs `sh -c