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/tests.rs b/src/tui/src/ui/app/tests.rs index f69263a6..9d51cf38 100644 --- a/src/tui/src/ui/app/tests.rs +++ b/src/tui/src/ui/app/tests.rs @@ -330,3 +330,83 @@ fn subscription_strategy_navigation_persists_and_emits_its_own_operation() { "{saved}" ); } + +// --- screen subscriptions follow the selection ----------------------------- + +/// Put the cursor on the first task sublane in the rail, returning the command +/// that produced. +fn select_first_task(app: &mut App) -> Option { + app.tab_index = tab("Agents"); + let rows = app.rail_rows(); + let idx = rows.iter().position(|r| { + matches!( + r, + super::rail::RailRow::Agent(crate::ui::agents::AgentRow::Sub { .. }) + ) + })?; + app.agent_index = idx; + app.retarget_watch() +} + +#[test] +fn selecting_a_task_asks_to_watch_it() { + let mut app = app(); + let cmd = select_first_task(&mut app).expect("the fixture has a selectable task"); + let Cmd::WatchTask { stop, start } = cmd else { + panic!("selecting a task should retarget the watch"); + }; + assert!(stop.is_none(), "nothing was being watched before"); + let (_, task_id) = start.expect("a task to start watching"); + assert!(!task_id.is_empty()); + assert!(app.watching.is_some(), "the target is remembered"); +} + +#[test] +fn reselecting_the_same_task_does_not_resubscribe() { + // Every subscribe carries `resync: true`, so re-issuing one on each + // keystroke would make the worker resend a full frame each time — the + // expensive thing this protocol exists to avoid. + let mut app = app(); + select_first_task(&mut app).expect("a task to select"); + assert!( + app.retarget_watch().is_none(), + "an unchanged selection must not retarget" + ); +} + +#[test] +fn leaving_the_agents_tab_releases_the_subscription() { + // A stream nobody is looking at still costs the worker a sample, a ratchet + // advance and a send every tick. + let mut app = app(); + select_first_task(&mut app).expect("a task to select"); + app.tab_index = tab("Overview"); + + let Some(Cmd::WatchTask { stop, start }) = app.retarget_watch() else { + panic!("leaving the tab should release the watch"); + }; + assert!(stop.is_some(), "the previous stream is stopped"); + assert!(start.is_none(), "and nothing replaces it"); + assert!(app.watching.is_none()); +} + +#[test] +fn selecting_a_lane_rather_than_a_task_watches_nothing() { + // Streams are addressed by task. A lane names none, and guessing one of its + // tasks would watch work the operator did not point at. + let mut app = app(); + app.tab_index = tab("Agents"); + let rows = app.rail_rows(); + let idx = rows + .iter() + .position(|r| { + matches!( + r, + super::rail::RailRow::Agent(crate::ui::agents::AgentRow::Lane { .. }) + ) + }) + .expect("the fixture has a lane row"); + app.agent_index = idx; + assert!(app.retarget_watch().is_none()); + assert!(app.watching.is_none()); +} 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