1use std::path::PathBuf;
21use std::sync::Arc;
22use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
23use std::time::{Duration, Instant};
24
25use forge_primitives::SessionSlot;
26use forge_primitives::browser::HandOffEnding;
27use forge_primitives::terminal::{
28 TerminalExit, TerminalHandOff, TerminalHandOffAnswer, TerminalInputExpected, TerminalInputKind,
29 TerminalSource, TerminalState, TerminalSummary,
30};
31use parking_lot::Mutex;
32use tokio::sync::oneshot;
33
34use crate::protocol::SessionUpdate;
35use crate::workspace::Workspace;
36
37pub use forge_terminal::OutputChunk;
40
41pub(crate) const DEFAULT_COLS: u16 = 120;
43pub(crate) const DEFAULT_ROWS: u16 = 30;
44
45const CLOCK_TICK: Duration = Duration::from_secs(30);
47
48pub struct TerminalEntry {
50 pub id: u16,
52 pub terminal: Arc<forge_terminal::Terminal>,
53 pub last_used: Mutex<Instant>,
55 pub attached: AtomicUsize,
57 controller: AtomicU64,
61 control: tokio::sync::watch::Sender<Option<u64>>,
63}
64
65impl TerminalEntry {
66 fn touch(&self) {
67 *self.last_used.lock() = Instant::now();
68 }
69
70 pub fn controller(&self) -> Option<u64> {
72 match self.controller.load(Ordering::Relaxed) {
73 0 => None,
74 id => Some(id),
75 }
76 }
77
78 pub fn take_control(&self, viewer: u64) {
82 if viewer == 0 {
83 return;
84 }
85 self.controller.store(viewer, Ordering::Relaxed);
86 self.touch();
87 let _ = self.control.send(Some(viewer));
88 }
89
90 pub fn clear_control(&self) {
94 if self.controller.swap(0, Ordering::Relaxed) != 0 {
95 self.touch();
96 let _ = self.control.send(None);
97 }
98 }
99
100 pub fn release_control(&self, viewer: u64) -> bool {
103 if self.controller() != Some(viewer) {
104 return false;
105 }
106 self.controller.store(0, Ordering::Relaxed);
107 self.touch();
108 let _ = self.control.send(None);
109 true
110 }
111
112 pub fn control_stream(&self) -> tokio::sync::watch::Receiver<Option<u64>> {
114 self.control.subscribe()
115 }
116
117 fn idle_for(&self) -> Duration {
118 self.last_used.lock().elapsed()
119 }
120
121 pub fn summary(&self, slot: &SessionSlot, held: bool) -> TerminalSummary {
123 TerminalSummary {
124 id: self.id,
125 seat: slot.clone(),
126 shell: self.terminal.shell().to_owned(),
127 cwd: self.terminal.cwd().display().to_string(),
128 cols: self.terminal.cols(),
129 rows: self.terminal.rows(),
130 alive: self.terminal.is_alive(),
131 state: wire_state(self.terminal.state()),
132 title: self.terminal.title(),
133 held,
134 }
135 }
136}
137
138pub(crate) fn wire_state(state: forge_terminal::State) -> TerminalState {
140 TerminalState {
141 running: state.running,
142 input_expected: state.input_expected.map(|expected| TerminalInputExpected {
143 kind: match expected.kind {
144 forge_terminal::InputKind::Password => TerminalInputKind::Password,
145 forge_terminal::InputKind::Confirm => TerminalInputKind::Confirm,
146 forge_terminal::InputKind::Prompt => TerminalInputKind::Prompt,
147 },
148 evidence: expected.evidence,
149 }),
150 exit: state.exit.map(|exit| TerminalExit { code: exit.code, signal: exit.signal }),
151 source: match state.source {
152 forge_terminal::Source::Exact => TerminalSource::Exact,
153 forge_terminal::Source::Heuristic => TerminalSource::Heuristic,
154 },
155 }
156}
157
158impl Workspace {
159 pub(crate) fn terminal_for(
165 self: &Arc<Self>,
166 slot: &SessionSlot,
167 ) -> Result<Arc<TerminalEntry>, String> {
168 self.spawn_terminal_at(slot, None)
169 }
170
171 pub(crate) fn spawn_terminal_at(
177 self: &Arc<Self>,
178 slot: &SessionSlot,
179 cwd: Option<PathBuf>,
180 ) -> Result<Arc<TerminalEntry>, String> {
181 let mut terminals = self.terminals.lock();
182 if let Some(entry) = terminals.get(slot).cloned() {
183 if entry.terminal.is_alive() {
184 entry.touch();
185 return Ok(entry);
186 }
187 terminals.remove(slot);
188 }
189 let entry = self.spawn_terminal(slot, cwd)?;
190 terminals.insert(slot.clone(), Arc::clone(&entry));
191 drop(terminals);
192 self.ensure_terminal_clock();
193 self.watch_terminal_exit(slot, &entry);
194 self.watch_terminal_state(slot, &entry);
195 self.announce_terminal(slot, Some(entry.summary(slot, false)));
196 Ok(entry)
197 }
198
199 pub fn open_terminal(
209 self: &Arc<Self>,
210 slot: &SessionSlot,
211 ) -> Result<Arc<TerminalEntry>, String> {
212 if self.cwd_for_session(slot).is_none() {
213 return Err(format!("forge holds no session for {}", slot.display()));
216 }
217 self.terminal_for(slot)
218 }
219
220 pub(crate) fn spawn_terminal_for_run(
223 &self,
224 slot: &SessionSlot,
225 cwd: Option<PathBuf>,
226 ) -> Result<Arc<TerminalEntry>, String> {
227 self.spawn_terminal(slot, cwd)
228 }
229
230 pub fn terminal_if_any(&self, slot: &SessionSlot) -> Option<Arc<TerminalEntry>> {
232 self.terminals.lock().get(slot).cloned()
233 }
234
235 pub fn terminal_by_id(
237 &self,
238 slot: &SessionSlot,
239 id: u16,
240 ) -> Result<Arc<TerminalEntry>, String> {
241 let Some(entry) = self.terminal_if_any(slot) else {
242 return Err(
243 "this seat has no terminal open; run terminal__open first (the id it returns is \
244 what this call takes)"
245 .to_owned(),
246 );
247 };
248 if entry.id != id {
249 return Err(format!("terminal {id} is not this seat's; its terminal is {}", entry.id));
250 }
251 entry.touch();
252 Ok(entry)
253 }
254
255 pub fn terminal_held(&self, slot: &SessionSlot) -> bool {
257 self.terminal_handoffs.lock().values().any(|(owner, _, _)| owner == slot)
258 }
259
260 pub fn terminal_controlled(&self, slot: &SessionSlot) -> bool {
263 self.terminal_if_any(slot).is_some_and(|entry| entry.controller().is_some())
264 }
265
266 pub fn take_terminal_control(
274 &self,
275 slot: &SessionSlot,
276 id: u16,
277 viewer: u64,
278 ) -> Result<(), String> {
279 let entry = self.terminal_by_id(slot, id)?;
280 entry.take_control(viewer);
281 Ok(())
282 }
283
284 pub fn close_terminal(&self, slot: &SessionSlot) {
286 let removed = self.terminals.lock().remove(slot);
287 if let Some(entry) = removed {
288 entry.terminal.close();
289 self.announce_terminal(slot, None);
290 }
291 self.abandon_terminal_hand_offs(slot);
292 }
293
294 pub(crate) fn sweep_idle_terminals(&self) {
297 let idle_minutes = self.config_arc().terminal.idle_minutes;
298 if idle_minutes == 0 {
299 return;
300 }
301 let idle = Duration::from_secs(idle_minutes.saturating_mul(60));
302 let idle_slots: Vec<SessionSlot> = self
308 .terminals
309 .lock()
310 .iter()
311 .filter(|(_, entry)| {
312 entry.attached.load(Ordering::Relaxed) == 0 && entry.idle_for() >= idle
313 })
314 .map(|(slot, _)| slot.clone())
315 .collect();
316 let victims: Vec<SessionSlot> = idle_slots
320 .into_iter()
321 .filter(|slot| !self.terminal_held(slot) && !self.terminal_controlled(slot))
322 .collect();
323 for slot in victims {
324 tracing::debug!(
325 event_name = "terminal_idle_closed",
326 slot = %slot.display(),
327 idle_minutes,
328 "an idle terminal was closed"
329 );
330 self.close_terminal(&slot);
331 }
332 }
333
334 pub(crate) fn announce_terminal(&self, slot: &SessionSlot, terminal: Option<TerminalSummary>) {
336 let _ = self
337 .update_sender()
338 .send(SessionUpdate::TerminalChanged { key: slot.clone(), terminal });
339 }
340
341 pub(crate) fn register_terminal_hand_off(
349 &self,
350 caller: &SessionSlot,
351 terminal: u16,
352 reason: &str,
353 ) -> Result<(uuid::Uuid, oneshot::Receiver<HandOffEnding>), String> {
354 let entry = self.terminal_by_id(caller, terminal)?;
355 let state = wire_state(entry.terminal.state());
356 let secret = state
359 .input_expected
360 .as_ref()
361 .is_some_and(|expected| expected.kind == TerminalInputKind::Password);
362 let (sender, receiver) = oneshot::channel();
363 let id = uuid::Uuid::new_v4();
364 let handoff = TerminalHandOff { id, reason: reason.to_owned(), terminal, secret };
365 {
366 let mut parked = self.terminal_handoffs.lock();
371 entry.clear_control();
372 parked.insert(id, (caller.clone(), handoff.clone(), sender));
373 }
374 self.announce_terminal(caller, Some(entry.summary(caller, true)));
377 let answerable = self
378 .update_sender()
379 .send_answering(SessionUpdate::TerminalHandOffPending { key: caller.clone(), handoff });
380 if !answerable {
381 self.terminal_handoffs.lock().remove(&id);
382 self.announce_terminal(caller, Some(entry.summary(caller, false)));
385 let (_ignored_sender, dead_receiver) = oneshot::channel();
386 return Ok((id, dead_receiver));
387 }
388 self.note_ask(caller);
389 Ok((id, receiver))
390 }
391
392 pub(crate) fn terminal_handoff_waiting(&self, id: uuid::Uuid, caller: &SessionSlot) -> bool {
395 self.terminal_handoffs.lock().get(&id).is_some_and(|(owner, _, _)| owner == caller)
396 }
397
398 pub(crate) fn resolve_terminal_hand_off(
401 &self,
402 id: uuid::Uuid,
403 caller: &SessionSlot,
404 answer: TerminalHandOffAnswer,
405 ) -> bool {
406 let mut parked = self.terminal_handoffs.lock();
407 if !parked.get(&id).is_some_and(|(owner, _, _)| owner == caller) {
408 return false;
409 }
410 let Some((owner, handoff, sender)) = parked.remove(&id) else { return false };
411 if let Some(entry) = self.terminal_if_any(&owner) {
415 entry.clear_control();
416 }
417 drop(parked);
418 tracing::debug!(
419 event_name = "terminal_hand_off_resolved",
420 id = %id,
421 ending = ?answer.ending,
422 source = ?answer.source,
423 client = answer.client,
424 seat = %owner.display(),
425 terminal = handoff.terminal,
426 "a parked terminal hand-off left the registry"
427 );
428 let _ = sender.send(answer.ending);
429 let _ = self.update_sender().send(SessionUpdate::TerminalHandOffResolved {
430 key: owner.clone(),
431 id,
432 answer,
433 });
434 if let Some(entry) = self.terminal_if_any(&owner) {
436 self.announce_terminal(&owner, Some(entry.summary(&owner, false)));
437 }
438 true
439 }
440
441 pub(crate) fn abandon_terminal_hand_offs(&self, slot: &SessionSlot) {
445 let ids: Vec<uuid::Uuid> = self
446 .terminal_handoffs
447 .lock()
448 .iter()
449 .filter(|(_, (owner, _, _))| owner == slot)
450 .map(|(id, _)| *id)
451 .collect();
452 for id in ids {
453 let _ = self.resolve_terminal_hand_off(
454 id,
455 slot,
456 TerminalHandOffAnswer {
457 ending: HandOffEnding::Abandoned,
458 source: forge_primitives::browser::HandOffSource::Abandoned,
459 client: None,
460 },
461 );
462 }
463 }
464
465 fn spawn_terminal(
468 &self,
469 slot: &SessionSlot,
470 cwd: Option<PathBuf>,
471 ) -> Result<Arc<TerminalEntry>, String> {
472 let Some(project) = self.project_for_slot(slot) else {
473 return Err(format!(
474 "{slot} names no configured project, so it has no cwd or environment to give a \
475 terminal",
476 slot = slot.display()
477 ));
478 };
479 let account_env =
480 self.pool
481 .lock()
482 .get(slot)
483 .and_then(|pooled| self.accounts.env(&pooled.account))
484 .or_else(|| {
485 project.accounts.first().and_then(|name| {
486 self.accounts.env(&forge_gateway::AccountKey(name.clone()))
487 })
488 })
489 .unwrap_or_default();
490 let mut env = crate::config::seat_env(&project, &account_env);
491 let cwd = cwd
492 .or_else(|| self.cwd_for_session(slot).map(PathBuf::from))
493 .unwrap_or_else(|| project.path.clone());
494 env.insert("PWD".to_owned(), cwd.display().to_string());
495 let config = self.config_arc();
496 let terminal = forge_terminal::Terminal::spawn(&forge_terminal::TerminalConfig {
497 shell: config.terminal.shell.clone(),
498 cwd,
499 env,
500 cols: DEFAULT_COLS,
501 rows: DEFAULT_ROWS,
502 scrollback_lines: config.terminal.scrollback_lines,
503 })
504 .map_err(|error| format!("the terminal could not start: {error}"))?;
505 let id = self.next_terminal_id.fetch_add(1, Ordering::Relaxed);
506 let (control, _) = tokio::sync::watch::channel(None);
507 Ok(Arc::new(TerminalEntry {
508 id,
509 terminal: Arc::new(terminal),
510 last_used: Mutex::new(Instant::now()),
511 attached: AtomicUsize::new(0),
512 controller: AtomicU64::new(0),
513 control,
514 }))
515 }
516
517 fn watch_terminal_exit(self: &Arc<Self>, slot: &SessionSlot, entry: &Arc<TerminalEntry>) {
520 let Ok(runtime) = tokio::runtime::Handle::try_current() else { return };
521 let weak = Arc::downgrade(self);
522 let slot = slot.clone();
523 let entry = Arc::clone(entry);
524 let mut exit = entry.terminal.exit_stream();
525 runtime.spawn(async move {
526 if exit.changed().await.is_err() {
527 return;
528 }
529 let Some(workspace) = weak.upgrade() else { return };
530 let held = workspace.terminal_held(&slot);
531 workspace.announce_terminal(&slot, Some(entry.summary(&slot, held)));
532 });
533 }
534
535 fn watch_terminal_state(self: &Arc<Self>, slot: &SessionSlot, entry: &Arc<TerminalEntry>) {
540 let Ok(runtime) = tokio::runtime::Handle::try_current() else { return };
541 let weak = Arc::downgrade(self);
542 let slot = slot.clone();
543 let entry = Arc::clone(entry);
544 let mut states = entry.terminal.state_stream();
545 runtime.spawn(async move {
546 while states.changed().await.is_ok() {
547 let Some(workspace) = weak.upgrade() else { return };
548 if workspace.terminal_if_any(&slot).map(|held| held.id) != Some(entry.id) {
551 return;
552 }
553 let held = workspace.terminal_held(&slot);
554 workspace.announce_terminal(&slot, Some(entry.summary(&slot, held)));
555 }
556 });
557 }
558
559 fn ensure_terminal_clock(self: &Arc<Self>) {
561 if tokio::runtime::Handle::try_current().is_err() {
565 return;
566 }
567 let weak = Arc::downgrade(self);
568 self.supervisor.spawn(
569 "terminal_idle_clock",
570 forge_supervise::Policy::backoff(),
571 move || {
572 let weak = weak.clone();
573 async move {
574 loop {
575 tokio::time::sleep(CLOCK_TICK).await;
576 let Some(workspace) = weak.upgrade() else { return };
577 workspace.sweep_idle_terminals();
578 }
579 }
580 },
581 );
582 }
583}
584
585#[cfg(test)]
586mod tests {
587 use super::*;
588
589 #[test]
592 fn a_password_terminal_hand_off_is_secret() {
593 let state = TerminalState {
594 running: false,
595 input_expected: Some(TerminalInputExpected {
596 kind: TerminalInputKind::Password,
597 evidence: "ECHO off".to_owned(),
598 }),
599 exit: None,
600 source: TerminalSource::Exact,
601 };
602 let secret = state
603 .input_expected
604 .as_ref()
605 .is_some_and(|expected| expected.kind == TerminalInputKind::Password);
606 assert!(secret);
607 }
608
609 #[tokio::test]
614 async fn the_terminal_belongs_to_the_slot_across_occupants() {
615 let (workspace, _rx) = Workspace::testing_stub();
616 workspace.seed_test_project("proj", "/tmp");
617 let slot = SessionSlot::lead("TestOrg", "proj");
618 let first = workspace.terminal_for(&slot).expect("a terminal");
619 let again = workspace.terminal_for(&slot).expect("the same terminal");
622 assert_eq!(again.id, first.id, "the terminal id is the slot's terminal");
623 assert!(
624 Arc::ptr_eq(&first.terminal, &again.terminal),
625 "the same PTY, shell and scrollback are what a new occupant finds",
626 );
627 workspace.close_terminal(&slot);
628 }
629
630 #[tokio::test]
633 async fn a_parked_hand_off_blinds_the_agent() {
634 let (workspace, _rx) = Workspace::testing_stub();
635 workspace.seed_test_project("proj", "/tmp");
636 let slot = SessionSlot::lead("TestOrg", "proj");
637 let entry = workspace.terminal_for(&slot).expect("a terminal");
638 let (_id, _answer) = workspace
639 .register_terminal_hand_off(&slot, entry.id, "the sudo password")
640 .expect("a hand-off parks");
641 assert!(workspace.terminal_held(&slot), "the person owns the input");
642 let refused = crate::mcp::terminal::facade::ProdTerminalFacade::from_workspace(&workspace);
643 assert!(
644 refused.read(&slot, entry.id, None, 200, 32 * 1024).await.is_err(),
645 "a read while parked is refused",
646 );
647 assert!(
648 refused.write(&slot, entry.id, Some("x".to_owned()), Vec::new()).await.is_err(),
649 "a write while parked is refused",
650 );
651 workspace.close_terminal(&slot);
652 }
653
654 #[tokio::test]
658 async fn an_agents_write_takes_the_keyboard_from_the_person() {
659 let (workspace, _rx) = Workspace::testing_stub();
660 workspace.seed_test_project("proj", "/tmp");
661 let slot = SessionSlot::lead("TestOrg", "proj");
662 let entry = workspace.terminal_for(&slot).expect("a terminal");
663 entry.take_control(7);
664 assert!(workspace.terminal_controlled(&slot), "the person holds the terminal");
665 assert!(!workspace.terminal_held(&slot), "which is not a hand-off");
666 let facade = crate::mcp::terminal::facade::ProdTerminalFacade::from_workspace(&workspace);
667 assert!(
668 facade.read(&slot, entry.id, None, 10, 1024).await.is_ok(),
669 "a read while the person controls is allowed",
670 );
671 assert!(
672 facade.write(&slot, entry.id, Some("x".to_owned()), Vec::new()).await.is_ok(),
673 "a write lands and takes the keyboard from the person",
674 );
675 assert!(!workspace.terminal_controlled(&slot), "and the person no longer holds it");
676 entry.take_control(8);
677 assert_eq!(entry.controller(), Some(8), "another client's take overrides the holder");
678 assert!(!entry.release_control(7), "the old holder's release does not land");
679 assert!(entry.release_control(8), "the holder releases it");
680 assert!(!workspace.terminal_controlled(&slot), "the agent owns the input again");
681 workspace.close_terminal(&slot);
682 }
683
684 #[tokio::test]
689 async fn a_wait_in_flight_stops_when_a_hand_off_parks() {
690 let (workspace, _rx) = Workspace::testing_stub();
691 workspace.seed_test_project("proj", "/tmp");
692 let slot = SessionSlot::lead("TestOrg", "proj");
693 let entry = workspace.terminal_for(&slot).expect("a terminal");
694 let facade = crate::mcp::terminal::facade::ProdTerminalFacade::from_workspace(&workspace);
695 let waiter = {
696 let facade = Arc::clone(&facade);
697 let slot = slot.clone();
698 let id = entry.id;
699 tokio::spawn(async move {
700 facade
701 .wait(
702 &slot,
703 id,
704 crate::mcp::terminal::facade::WaitUntil::Pattern("NEVER_SHOWN".to_owned()),
705 5_000,
706 )
707 .await
708 })
709 };
710 tokio::time::sleep(Duration::from_millis(150)).await;
712 let (_id, _answer) = workspace
713 .register_terminal_hand_off(&slot, entry.id, "the sudo password")
714 .expect("a hand-off parks");
715 let joined = tokio::time::timeout(Duration::from_secs(2), waiter)
716 .await
717 .expect("the wait returns once the person holds the screen")
718 .expect("the wait task joins");
719 assert!(joined.is_err(), "a wait in flight must refuse once a hand-off parks: {joined:?}");
720 workspace.close_terminal(&slot);
721 }
722
723 #[tokio::test]
727 async fn run_is_refused_while_a_hand_off_is_parked() {
728 let (workspace, _rx) = Workspace::testing_stub();
729 workspace.seed_test_project("proj", "/tmp");
730 let slot = SessionSlot::lead("TestOrg", "proj");
731 let entry = workspace.terminal_for(&slot).expect("a terminal");
732 let (_id, _answer) = workspace
733 .register_terminal_hand_off(&slot, entry.id, "the sudo password")
734 .expect("a hand-off parks");
735 let facade = crate::mcp::terminal::facade::ProdTerminalFacade::from_workspace(&workspace);
736 let result = facade.run(&slot, "echo x", None, 1_000).await;
737 assert!(result.is_err(), "run is refused while the person owns the screen");
738 workspace.close_terminal(&slot);
739 }
740
741 #[tokio::test]
744 async fn a_hand_off_answer_settles_the_park() {
745 let (workspace, mut rx) = Workspace::testing_stub();
746 workspace.seed_test_project("proj", "/tmp");
747 let slot = SessionSlot::lead("TestOrg", "proj");
748 let entry = workspace.terminal_for(&slot).expect("a terminal");
749 let (id, answer) = workspace
750 .register_terminal_hand_off(&slot, entry.id, "the sudo password")
751 .expect("a hand-off parks");
752 assert!(workspace.terminal_handoff_waiting(id, &slot));
753 let resolved = workspace.resolve_terminal_hand_off(
754 id,
755 &slot,
756 TerminalHandOffAnswer {
757 ending: HandOffEnding::Done,
758 source: forge_primitives::browser::HandOffSource::Unstated,
759 client: None,
760 },
761 );
762 assert!(resolved, "the registered hand-off resolves");
763 assert_eq!(answer.await.expect("an ending"), HandOffEnding::Done);
764 assert!(!workspace.terminal_held(&slot), "the agent owns the input again");
765 assert!(!workspace.terminal_handoff_waiting(id, &slot));
766 let mut told = false;
767 while let Ok(frame) = rx.try_recv() {
768 if matches!(
769 &*frame.update,
770 SessionUpdate::TerminalHandOffResolved { id: resolved, .. } if *resolved == id
771 ) {
772 told = true;
773 }
774 }
775 assert!(told, "the views were told the ask resolved");
776 workspace.close_terminal(&slot);
777 }
778
779 #[tokio::test]
782 async fn ending_the_seat_abandons_a_parked_hand_off() {
783 let (workspace, _rx) = Workspace::testing_stub();
784 workspace.seed_test_project("proj", "/tmp");
785 let slot = SessionSlot::lead("TestOrg", "proj");
786 let entry = workspace.terminal_for(&slot).expect("a terminal");
787 let (id, answer) = workspace
788 .register_terminal_hand_off(&slot, entry.id, "the sudo password")
789 .expect("a hand-off parks");
790 workspace.release_session(&slot);
791 assert_eq!(
792 answer.await.expect("an ending"),
793 HandOffEnding::Abandoned,
794 "the seat ended, so the ask cannot be answered"
795 );
796 assert!(!workspace.terminal_handoff_waiting(id, &slot));
797 }
798
799 #[tokio::test]
802 async fn another_seat_cannot_resolve_a_hand_off() {
803 let (workspace, _rx) = Workspace::testing_stub();
804 workspace.seed_test_project("proj", "/tmp");
805 let slot = SessionSlot::lead("TestOrg", "proj");
806 let other = SessionSlot::lead("OtherOrg", "proj");
807 let entry = workspace.terminal_for(&slot).expect("a terminal");
808 let (id, _answer) = workspace
809 .register_terminal_hand_off(&slot, entry.id, "the sudo password")
810 .expect("a hand-off parks");
811 let resolved = workspace.resolve_terminal_hand_off(
812 id,
813 &other,
814 TerminalHandOffAnswer {
815 ending: HandOffEnding::Done,
816 source: forge_primitives::browser::HandOffSource::Unstated,
817 client: None,
818 },
819 );
820 assert!(!resolved, "another seat's answer is refused");
821 assert!(workspace.terminal_held(&slot), "the ask still parks on its owner");
822 workspace.close_terminal(&slot);
823 }
824
825 #[tokio::test]
828 async fn a_command_start_is_pushed_to_the_views() {
829 let (workspace, mut rx) = Workspace::testing_stub();
830 workspace.seed_test_project("proj", "/tmp");
831 let slot = SessionSlot::lead("TestOrg", "proj");
832 let entry = workspace.terminal_for(&slot).expect("a terminal");
833 let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
834 while tokio::time::Instant::now() < deadline
835 && entry.terminal.screen().text.trim().is_empty()
836 {
837 tokio::time::sleep(Duration::from_millis(20)).await;
838 }
839 while rx.try_recv().is_ok() {}
841 entry.terminal.write_text("sleep 1\r").expect("a write");
842 let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
843 let mut running = false;
844 while tokio::time::Instant::now() < deadline && !running {
845 match rx.try_recv() {
846 Ok(frame) => {
847 if let SessionUpdate::TerminalChanged { terminal: Some(summary), .. } =
848 &*frame.update
849 {
850 running |= summary.state.running;
851 }
852 }
853 Err(_) => tokio::time::sleep(Duration::from_millis(10)).await,
854 }
855 }
856 assert!(running, "a TerminalChanged said the command is running");
857 workspace.close_terminal(&slot);
858 }
859
860 #[tokio::test]
863 async fn ending_the_seat_closes_its_terminal() {
864 let (workspace, _rx) = Workspace::testing_stub();
865 workspace.seed_test_project("proj", "/tmp");
866 let slot = SessionSlot::lead("TestOrg", "proj");
867 let entry = workspace.terminal_for(&slot).expect("a terminal");
868 workspace.release_session(&slot);
869 assert!(
870 workspace.terminal_if_any(&slot).is_none(),
871 "the seat ended, so its terminal left the registry with it",
872 );
873 entry.terminal.close();
874 }
875
876 #[tokio::test]
881 async fn opening_a_terminal_follows_the_seat_read() {
882 let (workspace, _rx) = Workspace::testing_stub();
883 workspace.seed_test_project("proj", "/tmp");
884
885 let declared = SessionSlot::lead("TestOrg", "proj");
886 let entry = workspace.open_terminal(&declared).expect("a declared seat answers its read");
887 drop(entry);
888 workspace.close_terminal(&declared);
889
890 let unstarted = SessionSlot::worker("TestOrg", "proj", "nobody");
891 assert!(
892 workspace.open_terminal(&unstarted).is_err(),
893 "a seat with no working tree opened a terminal",
894 );
895 assert!(workspace.terminal_if_any(&unstarted).is_none(), "and none was registered");
896 }
897
898 #[tokio::test]
902 async fn a_take_lands_during_a_park_and_the_ending_clears_it() {
903 let (workspace, _rx) = Workspace::testing_stub();
904 workspace.seed_test_project("proj", "/tmp");
905 let slot = SessionSlot::lead("TestOrg", "proj");
906 let entry = workspace.terminal_for(&slot).expect("a terminal");
907 let (parked, _rx) = workspace
908 .register_terminal_hand_off(&slot, entry.id, "the sudo password")
909 .expect("a hand-off parks");
910
911 assert!(
912 workspace.take_terminal_control(&slot, entry.id, 7).is_ok(),
913 "the person's take lands while the ask waits",
914 );
915 assert!(workspace.terminal_controlled(&slot), "and they hold the keyboard");
916
917 assert!(workspace.resolve_terminal_hand_off(
918 parked,
919 &slot,
920 TerminalHandOffAnswer {
921 ending: HandOffEnding::Done,
922 source: forge_primitives::browser::HandOffSource::Takeover,
923 client: Some(7),
924 },
925 ));
926 assert!(!workspace.terminal_controlled(&slot), "the ending gave the keyboard back");
927 entry.terminal.close();
928 }
929
930 #[tokio::test]
935 async fn a_take_on_a_replaced_or_missing_terminal_is_refused() {
936 let (workspace, _rx) = Workspace::testing_stub();
937 workspace.seed_test_project("proj", "/tmp");
938 let slot = SessionSlot::lead("TestOrg", "proj");
939 let entry = workspace.terminal_for(&slot).expect("a terminal");
940 let stale = entry.id;
941 workspace.close_terminal(&slot);
942 let fresh = workspace.terminal_for(&slot).expect("a fresh terminal");
943 assert_ne!(fresh.id, stale, "the shell was replaced with a new id");
944 assert!(
945 workspace.take_terminal_control(&slot, stale, 7).is_err(),
946 "a take on a replaced terminal landed",
947 );
948 assert!(!workspace.terminal_controlled(&slot), "and no role was handed out");
949 assert!(
950 workspace.take_terminal_control(&slot, fresh.id, 7).is_ok(),
951 "the terminal this client is viewing takes the role",
952 );
953
954 let empty = SessionSlot::worker("TestOrg", "proj", "nobody");
955 assert!(
956 workspace.take_terminal_control(&empty, 1, 8).is_err(),
957 "a take with no terminal was answered as though it landed",
958 );
959 workspace.close_terminal(&slot);
960 }
961
962 #[tokio::test]
966 async fn a_park_takes_the_keyboard_from_the_person() {
967 let (workspace, _rx) = Workspace::testing_stub();
968 workspace.seed_test_project("proj", "/tmp");
969 let slot = SessionSlot::lead("TestOrg", "proj");
970 let entry = workspace.terminal_for(&slot).expect("a terminal");
971 entry.take_control(7);
972 assert!(
973 workspace.register_terminal_hand_off(&slot, entry.id, "the sudo password").is_ok(),
974 "a hand-off parks over a person's take",
975 );
976 assert!(workspace.terminal_held(&slot), "and the ask is registered");
977 assert!(!workspace.terminal_controlled(&slot), "while the park owns the keyboard");
978 entry.terminal.close();
979 }
980
981 #[test]
984 fn the_crates_state_crosses_verbatim() {
985 let state = forge_terminal::State {
986 running: true,
987 input_expected: Some(forge_terminal::InputExpected {
988 kind: forge_terminal::InputKind::Prompt,
989 evidence: "quiet at a prompt".to_owned(),
990 }),
991 exit: Some(forge_terminal::Exit { code: Some(3), signal: None }),
992 source: forge_terminal::Source::Heuristic,
993 };
994 let wire = wire_state(state);
995 assert!(wire.running);
996 assert_eq!(wire.exit, Some(TerminalExit { code: Some(3), signal: None }));
997 assert_eq!(wire.source, TerminalSource::Heuristic);
998 assert_eq!(wire.input_expected.expect("an input").kind, TerminalInputKind::Prompt);
999 }
1000}