Skip to main content

newt_core/
session.rs

1use crate::caveats::Caveats;
2use serde::{Deserialize, Serialize};
3use std::collections::{BTreeMap, VecDeque};
4use std::fmt;
5use std::str::FromStr;
6use uuid::Uuid;
7
8/// Opaque session identifier — wraps a UUID v4.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
10#[serde(transparent)]
11pub struct SessionId(Uuid);
12
13impl SessionId {
14    pub fn new() -> Self {
15        Self(Uuid::new_v4())
16    }
17
18    pub fn from_uuid(uuid: Uuid) -> Self {
19        Self(uuid)
20    }
21
22    pub fn as_uuid(&self) -> &Uuid {
23        &self.0
24    }
25}
26
27impl Default for SessionId {
28    fn default() -> Self {
29        Self::new()
30    }
31}
32
33impl fmt::Display for SessionId {
34    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
35        self.0.fmt(f)
36    }
37}
38
39impl FromStr for SessionId {
40    type Err = uuid::Error;
41    fn from_str(s: &str) -> Result<Self, Self::Err> {
42        Uuid::from_str(s).map(Self)
43    }
44}
45
46// ===========================================================================
47// Multi-attach session model — Phase 1 of the mesh-remote control plane
48// (docs/design/mesh-remote-control-mobile-app.md §7.1).
49//
50// The agent is NOT a single-stdin/stdout process. A *session* is a unit of work;
51// a *terminal attach* — the local console OR a remote mesh peer — is just an
52// `Attachment` to it. The same `OutputChunk` stream fans out to every attachment,
53// so a human at the keyboard and a phone observing/co-driving see the same turn.
54//
55// This module is the in-process architecture and authority/arbitration logic; it
56// has NO transport dependency. The mesh wire protocol (`newt/session/v1`,
57// Phase 1b) and the live-console rewiring (local console becomes "attachment #0")
58// sit on top of these types. Two invariants it enforces structurally:
59//   * only a `Driver` attachment can submit input (an `Observer` never mutates);
60//   * turns are serialized — a turn in flight must complete or cancel before the
61//     next input is accepted, so two attachments can't interleave half-turns.
62// Authority for a turn is the *active driver attachment's* caveats (each a
63// worker-delegated down-set, so already ⊑ the worker — §5.1).
64// ===========================================================================
65
66/// Which logical stream an [`OutputChunk`] belongs to (a terminal styles each
67/// distinctly — stdout vs. the agent's thoughts vs. a tool call vs. a diff).
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
69pub enum OutputStream {
70    Stdout,
71    Stderr,
72    AgentThought,
73    ToolCall,
74    Diff,
75}
76
77/// One streamed unit of session output, fanned out to every attachment. This is
78/// the in-process Phase-1 shape; the mesh wire `OutputChunk` (§6.1) maps onto it
79/// 1:1 (it adds only `session_id`, carried by the topic in Phase 1b).
80#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
81pub struct OutputChunk {
82    /// Which turn produced this chunk (monotonic within a session).
83    pub turn: u64,
84    pub stream: OutputStream,
85    /// Ordering within the turn; an attachment dedupes/reorders by this and asks
86    /// for a `replay_from(seq)` after a reconnect (§6.3).
87    pub seq: u64,
88    pub data: String,
89    /// Final chunk of this turn's stream. Does NOT itself end the turn — the
90    /// driver calls [`SessionState::complete_turn`] (a turn may stream several
91    /// `last`-tagged sub-streams).
92    pub last: bool,
93}
94
95/// How an attachment relates to its session.
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97pub enum AttachRole {
98    /// May submit input (drive turns). Its caveats govern the turns it drives.
99    Driver,
100    /// Read-only: receives the output stream, can never submit input. Observing
101    /// can never mutate — enforced here, not by the observer's caveats.
102    Observer,
103}
104
105/// Identifies one attachment within a session (assigned at attach time).
106#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd)]
107pub struct AttachId(u64);
108
109impl fmt::Display for AttachId {
110    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
111        write!(f, "attach#{}", self.0)
112    }
113}
114
115/// The sink an attachment receives [`OutputChunk`]s on. A local console, a mesh
116/// peer, or a test collector all implement this, so the session fans out to
117/// `dyn OutputSink` and never knows the transport.
118pub trait OutputSink: Send {
119    fn deliver(&mut self, chunk: &OutputChunk);
120}
121
122/// Why [`SessionState::submit_input`] refused an input.
123#[derive(Debug, Clone, Copy, PartialEq, Eq)]
124pub enum InputRefused {
125    /// The attachment is an `Observer` — only a `Driver` may submit.
126    NotADriver,
127    /// A turn is already in flight; it must complete or cancel first.
128    TurnInFlight,
129    /// No such attachment in this session.
130    NoSuchAttachment,
131}
132
133impl fmt::Display for InputRefused {
134    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
135        match self {
136            Self::NotADriver => write!(
137                f,
138                "attachment is an observer; only a driver may submit input"
139            ),
140            Self::TurnInFlight => write!(
141                f,
142                "a turn is already in flight; complete or cancel it first"
143            ),
144            Self::NoSuchAttachment => write!(f, "no such attachment in this session"),
145        }
146    }
147}
148
149impl std::error::Error for InputRefused {}
150
151struct Attachment {
152    role: AttachRole,
153    caveats: Caveats,
154    sink: Box<dyn OutputSink>,
155}
156
157/// A turn accepted for execution: the harness runs it under `caveats` (the active
158/// driver's authority) and streams output back via [`SessionState::emit`].
159#[derive(Debug, Clone)]
160pub struct AcceptedTurn {
161    pub turn: u64,
162    pub driver: AttachId,
163    pub text: String,
164    pub caveats: Caveats,
165}
166
167struct InFlight {
168    turn: u64,
169    driver: AttachId,
170    next_seq: u64,
171}
172
173/// One session: a set of attachments fanned the same output, with serialized
174/// input and per-driver authority.
175pub struct SessionState {
176    id: SessionId,
177    attachments: BTreeMap<AttachId, Attachment>,
178    next_attach: u64,
179    turn: u64,
180    in_flight: Option<InFlight>,
181    ring: VecDeque<OutputChunk>,
182    ring_cap: usize,
183}
184
185impl SessionState {
186    /// `ring_cap` bounds the per-session resume buffer (recent chunks replayed to
187    /// an attachment that reconnects — §6.3). Clamped to at least 1.
188    pub fn new(id: SessionId, ring_cap: usize) -> Self {
189        Self {
190            id,
191            attachments: BTreeMap::new(),
192            next_attach: 0,
193            turn: 0,
194            in_flight: None,
195            ring: VecDeque::new(),
196            ring_cap: ring_cap.max(1),
197        }
198    }
199
200    pub fn id(&self) -> SessionId {
201        self.id
202    }
203
204    /// Attach a console/peer/observer; returns its handle. The caller supplies the
205    /// attachment's caveats — for a `Driver`, its worker-delegated down-set; for
206    /// an `Observer`, an attenuated read-only set (though the role, not the
207    /// caveats, is what structurally prevents an observer from mutating).
208    pub fn attach(
209        &mut self,
210        role: AttachRole,
211        caveats: Caveats,
212        sink: Box<dyn OutputSink>,
213    ) -> AttachId {
214        let id = AttachId(self.next_attach);
215        self.next_attach += 1;
216        self.attachments.insert(
217            id,
218            Attachment {
219                role,
220                caveats,
221                sink,
222            },
223        );
224        id
225    }
226
227    /// Detach an attachment; future output no longer fans out to it. Returns
228    /// whether it was present.
229    pub fn detach(&mut self, id: AttachId) -> bool {
230        self.attachments.remove(&id).is_some()
231    }
232
233    pub fn attachment_count(&self) -> usize {
234        self.attachments.len()
235    }
236
237    pub fn driver_count(&self) -> usize {
238        self.attachments
239            .values()
240            .filter(|a| a.role == AttachRole::Driver)
241            .count()
242    }
243
244    pub fn turn_in_flight(&self) -> bool {
245        self.in_flight.is_some()
246    }
247
248    /// The caveats governing the in-flight turn (the active driver's), or `None`
249    /// when idle. A turn never runs under more authority than the driver that
250    /// submitted it.
251    pub fn effective_caveats(&self) -> Option<&Caveats> {
252        let f = self.in_flight.as_ref()?;
253        self.attachments.get(&f.driver).map(|a| &a.caveats)
254    }
255
256    /// Submit a human/peer input. Enforces the two structural invariants:
257    /// driver-only, and one turn at a time. On accept, opens a new turn and
258    /// returns it (with the active driver's caveats) for the harness to run.
259    pub fn submit_input(
260        &mut self,
261        from: AttachId,
262        text: impl Into<String>,
263    ) -> Result<AcceptedTurn, InputRefused> {
264        let att = self
265            .attachments
266            .get(&from)
267            .ok_or(InputRefused::NoSuchAttachment)?;
268        if att.role != AttachRole::Driver {
269            return Err(InputRefused::NotADriver);
270        }
271        if self.in_flight.is_some() {
272            return Err(InputRefused::TurnInFlight);
273        }
274        self.turn += 1;
275        let caveats = att.caveats.clone();
276        self.in_flight = Some(InFlight {
277            turn: self.turn,
278            driver: from,
279            next_seq: 0,
280        });
281        Ok(AcceptedTurn {
282            turn: self.turn,
283            driver: from,
284            text: text.into(),
285            caveats,
286        })
287    }
288
289    /// Emit one output chunk for the in-flight turn: buffer it (bounded by
290    /// `ring_cap`) and fan it out to every attachment. No-op if idle.
291    pub fn emit(&mut self, stream: OutputStream, data: impl Into<String>, last: bool) {
292        let Some(f) = self.in_flight.as_mut() else {
293            return;
294        };
295        let chunk = OutputChunk {
296            turn: f.turn,
297            stream,
298            seq: f.next_seq,
299            data: data.into(),
300            last,
301        };
302        f.next_seq += 1;
303        if self.ring.len() == self.ring_cap {
304            self.ring.pop_front();
305        }
306        self.ring.push_back(chunk.clone());
307        for att in self.attachments.values_mut() {
308            att.sink.deliver(&chunk);
309        }
310    }
311
312    /// End the in-flight turn (success). Returns whether one was in flight.
313    pub fn complete_turn(&mut self) -> bool {
314        self.in_flight.take().is_some()
315    }
316
317    /// Cancel the in-flight turn (a `ControlC` from any attachment). Returns
318    /// whether one was in flight.
319    pub fn cancel_turn(&mut self) -> bool {
320        self.in_flight.take().is_some()
321    }
322
323    /// Replay buffered chunks with `seq >= from_seq` to a reconnecting attachment
324    /// (§6.3 resume). Bounded by `ring_cap`, so a long-gone attachment may miss
325    /// the head — it gets the retained tail.
326    pub fn replay_from(&self, from_seq: u64) -> Vec<OutputChunk> {
327        self.ring
328            .iter()
329            .filter(|c| c.seq >= from_seq)
330            .cloned()
331            .collect()
332    }
333}
334
335/// The agent's session registry: `SessionId -> SessionState`. The core owns one;
336/// every attach (local console or mesh peer) registers a session against it.
337pub struct SessionRegistry {
338    sessions: BTreeMap<SessionId, SessionState>,
339    default_ring_cap: usize,
340}
341
342impl Default for SessionRegistry {
343    fn default() -> Self {
344        Self::new()
345    }
346}
347
348impl SessionRegistry {
349    pub fn new() -> Self {
350        Self {
351            sessions: BTreeMap::new(),
352            default_ring_cap: 256,
353        }
354    }
355
356    /// Registry whose sessions use `cap` as their resume-buffer size.
357    pub fn with_ring_cap(cap: usize) -> Self {
358        Self {
359            sessions: BTreeMap::new(),
360            default_ring_cap: cap.max(1),
361        }
362    }
363
364    /// Open a fresh session; returns its id.
365    pub fn open(&mut self) -> SessionId {
366        let id = SessionId::new();
367        self.sessions
368            .insert(id, SessionState::new(id, self.default_ring_cap));
369        id
370    }
371
372    /// Close a session, dropping all its attachments. Returns whether it existed.
373    pub fn close(&mut self, id: SessionId) -> bool {
374        self.sessions.remove(&id).is_some()
375    }
376
377    pub fn get(&self, id: SessionId) -> Option<&SessionState> {
378        self.sessions.get(&id)
379    }
380
381    pub fn get_mut(&mut self, id: SessionId) -> Option<&mut SessionState> {
382        self.sessions.get_mut(&id)
383    }
384
385    pub fn len(&self) -> usize {
386        self.sessions.len()
387    }
388
389    pub fn is_empty(&self) -> bool {
390        self.sessions.is_empty()
391    }
392
393    pub fn ids(&self) -> Vec<SessionId> {
394        self.sessions.keys().copied().collect()
395    }
396}
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401
402    #[test]
403    fn generate_unique() {
404        let a = SessionId::new();
405        let b = SessionId::new();
406        assert_ne!(a, b);
407    }
408
409    #[test]
410    fn parse_roundtrip() {
411        let id = SessionId::new();
412        let s = id.to_string();
413        let parsed: SessionId = s.parse().unwrap();
414        assert_eq!(id, parsed);
415    }
416
417    #[test]
418    fn display_is_uuid_format() {
419        let id = SessionId::new();
420        let s = id.to_string();
421        assert_eq!(s.len(), 36);
422        assert_eq!(s.as_bytes()[8], b'-');
423        assert_eq!(s.as_bytes()[13], b'-');
424        assert_eq!(s.as_bytes()[18], b'-');
425        assert_eq!(s.as_bytes()[23], b'-');
426    }
427
428    #[test]
429    fn invalid_string_rejected() {
430        assert!("not-a-uuid".parse::<SessionId>().is_err());
431    }
432
433    #[test]
434    fn serde_roundtrip() {
435        let id = SessionId::new();
436        let json = serde_json::to_string(&id).unwrap();
437        let back: SessionId = serde_json::from_str(&json).unwrap();
438        assert_eq!(id, back);
439    }
440}
441
442#[cfg(test)]
443mod attach_tests {
444    use super::*;
445    use crate::caveats::{CaveatsExt, Scope};
446    use std::sync::{Arc, Mutex};
447
448    /// An in-memory attachment sink that records everything fanned to it.
449    #[derive(Clone, Default)]
450    struct Collector(Arc<Mutex<Vec<OutputChunk>>>);
451
452    impl OutputSink for Collector {
453        fn deliver(&mut self, chunk: &OutputChunk) {
454            self.0.lock().unwrap().push(chunk.clone());
455        }
456    }
457
458    impl Collector {
459        fn count(&self) -> usize {
460            self.0.lock().unwrap().len()
461        }
462    }
463
464    /// A read-only observer authority: no exec (the role is the real gate, but
465    /// this models the attenuated caveats §7.1 says an observer carries).
466    fn observer_caveats() -> Caveats {
467        Caveats {
468            exec: Scope::none(),
469            ..Caveats::top()
470        }
471    }
472
473    #[test]
474    fn registry_open_close_and_ids() {
475        let mut reg = SessionRegistry::new();
476        assert!(reg.is_empty());
477        let a = reg.open();
478        let b = reg.open();
479        assert_eq!(reg.len(), 2);
480        assert_ne!(a, b);
481        assert!(reg.get(a).is_some());
482        assert!(reg.ids().contains(&a) && reg.ids().contains(&b));
483        assert!(reg.close(a));
484        assert!(!reg.close(a)); // already gone
485        assert_eq!(reg.len(), 1);
486    }
487
488    #[test]
489    fn separate_sessions_are_isolated() {
490        // The default attach mode: two drivers each on their OWN session never
491        // see each other's output (§7.1 separate-sessions).
492        let mut reg = SessionRegistry::new();
493        let s1 = reg.open();
494        let s2 = reg.open();
495        let c1 = Collector::default();
496        let c2 = Collector::default();
497        let d1 = reg.get_mut(s1).unwrap().attach(
498            AttachRole::Driver,
499            Caveats::top(),
500            Box::new(c1.clone()),
501        );
502        let _d2 = reg.get_mut(s2).unwrap().attach(
503            AttachRole::Driver,
504            Caveats::top(),
505            Box::new(c2.clone()),
506        );
507
508        let sess1 = reg.get_mut(s1).unwrap();
509        sess1.submit_input(d1, "go").unwrap();
510        sess1.emit(OutputStream::Stdout, "only s1", true);
511
512        assert_eq!(c1.count(), 1);
513        assert_eq!(c2.count(), 0, "s2 must not see s1's output");
514    }
515
516    #[test]
517    fn observer_receives_output_but_cannot_drive() {
518        let mut reg = SessionRegistry::new();
519        let s = reg.open();
520        let cd = Collector::default();
521        let co = Collector::default();
522        let sess = reg.get_mut(s).unwrap();
523        let driver = sess.attach(AttachRole::Driver, Caveats::top(), Box::new(cd.clone()));
524        let observer = sess.attach(
525            AttachRole::Observer,
526            observer_caveats(),
527            Box::new(co.clone()),
528        );
529
530        // The observer structurally cannot submit input.
531        assert!(matches!(
532            sess.submit_input(observer, "mutate"),
533            Err(InputRefused::NotADriver)
534        ));
535        assert!(!sess.turn_in_flight());
536
537        // The driver drives; output fans to BOTH the driver and the observer.
538        sess.submit_input(driver, "go").unwrap();
539        sess.emit(OutputStream::AgentThought, "thinking", false);
540        sess.emit(OutputStream::Stdout, "done", true);
541
542        assert_eq!(cd.count(), 2);
543        assert_eq!(co.count(), 2, "observer watches the same stream");
544    }
545
546    #[test]
547    fn turns_are_serialized_until_complete_or_cancel() {
548        let mut reg = SessionRegistry::new();
549        let s = reg.open();
550        let sess = reg.get_mut(s).unwrap();
551        let d = sess.attach(
552            AttachRole::Driver,
553            Caveats::top(),
554            Box::new(Collector::default()),
555        );
556
557        let t1 = sess.submit_input(d, "a").unwrap();
558        assert_eq!(t1.turn, 1);
559        assert!(sess.turn_in_flight());
560
561        // No second turn may start while one is in flight.
562        assert!(matches!(
563            sess.submit_input(d, "b"),
564            Err(InputRefused::TurnInFlight)
565        ));
566
567        assert!(sess.complete_turn());
568        let t2 = sess.submit_input(d, "b").unwrap();
569        assert_eq!(t2.turn, 2, "turns increment monotonically");
570
571        assert!(sess.cancel_turn());
572        assert!(!sess.turn_in_flight());
573        assert!(!sess.complete_turn(), "nothing in flight to complete");
574    }
575
576    #[test]
577    fn co_drivers_share_output_and_serialize() {
578        // Shared/observed session with two DRIVERS (e.g. laptop + phone co-drive):
579        // output fans to both, and inputs still serialize across attachments.
580        let mut reg = SessionRegistry::new();
581        let s = reg.open();
582        let ca = Collector::default();
583        let cb = Collector::default();
584        let sess = reg.get_mut(s).unwrap();
585        let a = sess.attach(AttachRole::Driver, Caveats::top(), Box::new(ca.clone()));
586        let b = sess.attach(AttachRole::Driver, Caveats::top(), Box::new(cb.clone()));
587        assert_eq!(sess.driver_count(), 2);
588
589        // A drives; B's input is refused mid-turn (no interleaving half-turns).
590        sess.submit_input(a, "a-input").unwrap();
591        assert!(matches!(
592            sess.submit_input(b, "b-input"),
593            Err(InputRefused::TurnInFlight)
594        ));
595        sess.emit(OutputStream::Stdout, "from a's turn", true);
596        sess.complete_turn();
597
598        // Now B can drive; both still see the output.
599        sess.submit_input(b, "b-input").unwrap();
600        sess.emit(OutputStream::Stdout, "from b's turn", true);
601        sess.complete_turn();
602
603        assert_eq!(ca.count(), 2, "A sees both turns' output");
604        assert_eq!(cb.count(), 2, "B sees both turns' output");
605    }
606
607    #[test]
608    fn effective_caveats_tracks_the_active_driver() {
609        let mut reg = SessionRegistry::new();
610        let s = reg.open();
611        let sess = reg.get_mut(s).unwrap();
612        // A: full authority. B: no exec.
613        let a = sess.attach(
614            AttachRole::Driver,
615            Caveats::top(),
616            Box::new(Collector::default()),
617        );
618        let b = sess.attach(
619            AttachRole::Driver,
620            observer_caveats(),
621            Box::new(Collector::default()),
622        );
623
624        assert!(sess.effective_caveats().is_none(), "idle → no authority");
625
626        sess.submit_input(a, "x").unwrap();
627        assert!(
628            sess.effective_caveats().unwrap().permits_exec("git"),
629            "A's turn runs under A's (full) authority"
630        );
631        sess.complete_turn();
632
633        sess.submit_input(b, "y").unwrap();
634        assert!(
635            !sess.effective_caveats().unwrap().permits_exec("git"),
636            "B's turn runs under B's (no-exec) authority — the active driver governs"
637        );
638        sess.complete_turn();
639        assert!(sess.effective_caveats().is_none());
640    }
641
642    #[test]
643    fn replay_returns_the_buffered_tail() {
644        let mut reg = SessionRegistry::with_ring_cap(3);
645        let s = reg.open();
646        let sess = reg.get_mut(s).unwrap();
647        let d = sess.attach(
648            AttachRole::Driver,
649            Caveats::top(),
650            Box::new(Collector::default()),
651        );
652        sess.submit_input(d, "go").unwrap();
653        for i in 0..5 {
654            sess.emit(OutputStream::Stdout, format!("chunk{i}"), false);
655        }
656        // ring_cap=3 keeps seq 2,3,4 (head evicted).
657        let from0 = sess.replay_from(0);
658        assert_eq!(
659            from0.iter().map(|c| c.seq).collect::<Vec<_>>(),
660            vec![2, 3, 4]
661        );
662        let from3 = sess.replay_from(3);
663        assert_eq!(from3.iter().map(|c| c.seq).collect::<Vec<_>>(), vec![3, 4]);
664    }
665
666    #[test]
667    fn detach_stops_fanout() {
668        let mut reg = SessionRegistry::new();
669        let s = reg.open();
670        let cd = Collector::default();
671        let co = Collector::default();
672        let sess = reg.get_mut(s).unwrap();
673        let d = sess.attach(AttachRole::Driver, Caveats::top(), Box::new(cd.clone()));
674        let obs = sess.attach(
675            AttachRole::Observer,
676            observer_caveats(),
677            Box::new(co.clone()),
678        );
679
680        assert_eq!(sess.attachment_count(), 2);
681        assert!(sess.detach(obs));
682        assert_eq!(sess.attachment_count(), 1);
683
684        sess.submit_input(d, "go").unwrap();
685        sess.emit(OutputStream::Stdout, "after detach", true);
686        assert_eq!(cd.count(), 1);
687        assert_eq!(co.count(), 0, "detached attachment receives nothing");
688    }
689}