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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
69pub enum OutputStream {
70 Stdout,
71 Stderr,
72 AgentThought,
73 ToolCall,
74 Diff,
75}
76
77#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
81pub struct OutputChunk {
82 pub turn: u64,
84 pub stream: OutputStream,
85 pub seq: u64,
88 pub data: String,
89 pub last: bool,
93}
94
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97pub enum AttachRole {
98 Driver,
100 Observer,
103}
104
105#[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
115pub trait OutputSink: Send {
119 fn deliver(&mut self, chunk: &OutputChunk);
120}
121
122#[derive(Debug, Clone, Copy, PartialEq, Eq)]
124pub enum InputRefused {
125 NotADriver,
127 TurnInFlight,
129 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#[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
173pub 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 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 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 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 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 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 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 pub fn complete_turn(&mut self) -> bool {
314 self.in_flight.take().is_some()
315 }
316
317 pub fn cancel_turn(&mut self) -> bool {
320 self.in_flight.take().is_some()
321 }
322
323 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
335pub 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 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 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 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 #[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 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)); assert_eq!(reg.len(), 1);
486 }
487
488 #[test]
489 fn separate_sessions_are_isolated() {
490 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 assert!(matches!(
532 sess.submit_input(observer, "mutate"),
533 Err(InputRefused::NotADriver)
534 ));
535 assert!(!sess.turn_in_flight());
536
537 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 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 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 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 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 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 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}