1use std::path::PathBuf;
9use std::sync::Arc;
10use std::time::Duration;
11
12use async_trait::async_trait;
13use forge_primitives::SessionSlot;
14use forge_primitives::browser::{HandOffEnding, HandOffSource};
15use forge_primitives::terminal::{TerminalExit, TerminalState};
16use forge_terminal::Key;
17use tokio::time::{Instant, timeout};
18
19use crate::terminal::{TerminalEntry, wire_state};
20use crate::workspace::Workspace;
21
22#[derive(Clone, Debug, Default)]
24pub struct OpenRequest {
25 pub command: Option<String>,
27 pub cwd: Option<String>,
29 pub cols: Option<u16>,
30 pub rows: Option<u16>,
31}
32
33#[derive(Clone, Debug, PartialEq, Eq)]
35pub struct OpenAnswer {
36 pub id: u16,
37 pub screen: String,
38 pub cols: u16,
39 pub rows: u16,
40 pub shell: String,
41 pub cwd: String,
42}
43
44#[derive(Clone, Debug, PartialEq, Eq)]
46pub struct ReadAnswer {
47 pub text: String,
48 pub cursor: u64,
49 pub cols: u16,
50 pub rows: u16,
51 pub state: TerminalState,
52 pub truncated: bool,
53}
54
55#[derive(Clone, Debug, PartialEq, Eq)]
57pub enum WaitUntil {
58 Exit,
61 Quiet,
63 Pattern(String),
65}
66
67#[derive(Clone, Debug, PartialEq, Eq)]
69pub struct WaitAnswer {
70 pub matched: bool,
71 pub text: String,
72 pub cursor: u64,
73 pub state: TerminalState,
74}
75
76#[derive(Clone, Debug, PartialEq, Eq)]
78pub struct RunAnswer {
79 pub exit: Option<TerminalExit>,
80 pub text: String,
81 pub truncated: bool,
82}
83
84#[async_trait]
86pub trait TerminalFacade: Send + Sync {
87 async fn open(&self, seat: &SessionSlot, request: OpenRequest) -> Result<OpenAnswer, String>;
88
89 async fn write(
90 &self,
91 seat: &SessionSlot,
92 id: u16,
93 text: Option<String>,
94 keys: Vec<Key>,
95 ) -> Result<TerminalState, String>;
96
97 async fn read(
98 &self,
99 seat: &SessionSlot,
100 id: u16,
101 since: Option<u64>,
102 max_lines: usize,
103 max_bytes: usize,
104 ) -> Result<ReadAnswer, String>;
105
106 async fn wait(
107 &self,
108 seat: &SessionSlot,
109 id: u16,
110 until: WaitUntil,
111 timeout_ms: u64,
112 ) -> Result<WaitAnswer, String>;
113
114 async fn resize(&self, seat: &SessionSlot, id: u16, cols: u16, rows: u16)
115 -> Result<(), String>;
116
117 async fn close(&self, seat: &SessionSlot, id: u16) -> Result<Option<TerminalExit>, String>;
118
119 async fn hand_off(
120 &self,
121 seat: &SessionSlot,
122 id: u16,
123 reason: &str,
124 ) -> Result<HandOffEnding, String>;
125
126 async fn run(
127 &self,
128 seat: &SessionSlot,
129 command: &str,
130 cwd: Option<String>,
131 timeout_ms: u64,
132 ) -> Result<RunAnswer, String>;
133}
134
135pub struct ProdTerminalFacade {
137 workspace: std::sync::Weak<Workspace>,
138}
139
140impl ProdTerminalFacade {
141 pub fn from_workspace(workspace: &Arc<Workspace>) -> Arc<dyn TerminalFacade> {
142 Arc::new(Self { workspace: Arc::downgrade(workspace) })
143 }
144
145 fn workspace(&self) -> Result<Arc<Workspace>, String> {
146 self.workspace
147 .upgrade()
148 .ok_or_else(|| "the workspace went away before the call was staged".to_owned())
149 }
150}
151
152#[async_trait]
153impl TerminalFacade for ProdTerminalFacade {
154 async fn open(&self, seat: &SessionSlot, request: OpenRequest) -> Result<OpenAnswer, String> {
155 let workspace = self.workspace()?;
156 refuse_when_held(&workspace, seat)?;
160 let entry = match request.cwd.as_deref() {
161 Some(cwd) if !cwd.trim().is_empty() => {
162 if workspace.terminal_if_any(seat).is_some() {
166 workspace.terminal_for(seat)?
167 } else {
168 workspace.spawn_terminal_at(seat, Some(PathBuf::from(cwd)))?
169 }
170 }
171 _ => workspace.terminal_for(seat)?,
172 };
173 if let (Some(cols), Some(rows)) = (request.cols, request.rows) {
174 entry.terminal.resize(cols, rows).map_err(|error| error.to_string())?;
175 }
176 if let Some(command) = request.command.filter(|command| !command.trim().is_empty()) {
177 wait_ready(&entry).await;
178 entry.terminal.write_text(&command).map_err(|error| error.to_string())?;
179 entry.terminal.write_keys(&[Key::Enter]).map_err(|error| error.to_string())?;
180 }
181 let screen = entry.terminal.screen();
182 Ok(OpenAnswer {
183 id: entry.id,
184 screen: screen.text,
185 cols: screen.cols,
186 rows: screen.rows,
187 shell: entry.terminal.shell().to_owned(),
188 cwd: entry.terminal.cwd().display().to_string(),
189 })
190 }
191
192 async fn write(
193 &self,
194 seat: &SessionSlot,
195 id: u16,
196 text: Option<String>,
197 keys: Vec<Key>,
198 ) -> Result<TerminalState, String> {
199 let workspace = self.workspace()?;
200 refuse_when_held(&workspace, seat)?;
201 let entry = workspace.terminal_by_id(seat, id)?;
202 entry.clear_control();
207 if let Some(text) = text.filter(|text| !text.is_empty()) {
208 entry.terminal.write_text(&text).map_err(|error| error.to_string())?;
209 }
210 if !keys.is_empty() {
211 entry.terminal.write_keys(&keys).map_err(|error| error.to_string())?;
212 }
213 Ok(wire_state(entry.terminal.state()))
214 }
215
216 async fn read(
217 &self,
218 seat: &SessionSlot,
219 id: u16,
220 since: Option<u64>,
221 max_lines: usize,
222 max_bytes: usize,
223 ) -> Result<ReadAnswer, String> {
224 let workspace = self.workspace()?;
225 refuse_when_held(&workspace, seat)?;
226 let entry = workspace.terminal_by_id(seat, id)?;
227 let read = entry.terminal.read(since, max_lines, max_bytes);
228 Ok(ReadAnswer {
229 text: read.text,
230 cursor: read.cursor,
231 cols: read.cols,
232 rows: read.rows,
233 state: wire_state(entry.terminal.state()),
234 truncated: read.truncated,
235 })
236 }
237
238 async fn wait(
239 &self,
240 seat: &SessionSlot,
241 id: u16,
242 until: WaitUntil,
243 timeout_ms: u64,
244 ) -> Result<WaitAnswer, String> {
245 let workspace = self.workspace()?;
246 refuse_when_held(&workspace, seat)?;
247 let entry = workspace.terminal_by_id(seat, id)?;
248 let deadline = Instant::now() + Duration::from_millis(timeout_ms);
249 let blind = Some((workspace.as_ref(), seat));
253 let matched = match until {
254 WaitUntil::Exit => wait_exit(&entry, deadline, blind).await?,
255 WaitUntil::Quiet => wait_quiet(&entry, deadline, blind).await?,
256 WaitUntil::Pattern(pattern) => wait_pattern(&entry, &pattern, deadline, blind).await?,
257 };
258 refuse_when_held(&workspace, seat)?;
261 let read = entry.terminal.read(None, 200, 32 * 1024);
262 Ok(WaitAnswer {
263 matched,
264 text: read.text,
265 cursor: read.cursor,
266 state: wire_state(entry.terminal.state()),
267 })
268 }
269
270 async fn resize(
271 &self,
272 seat: &SessionSlot,
273 id: u16,
274 cols: u16,
275 rows: u16,
276 ) -> Result<(), String> {
277 let workspace = self.workspace()?;
278 refuse_when_held(&workspace, seat)?;
279 let entry = workspace.terminal_by_id(seat, id)?;
280 entry.terminal.resize(cols, rows).map_err(|error| error.to_string())
281 }
282
283 async fn close(&self, seat: &SessionSlot, id: u16) -> Result<Option<TerminalExit>, String> {
284 let workspace = self.workspace()?;
285 refuse_when_held(&workspace, seat)?;
286 let entry = workspace.terminal_by_id(seat, id)?;
287 entry.terminal.close();
288 let deadline = Instant::now() + Duration::from_secs(2);
289 while Instant::now() < deadline && entry.terminal.is_alive() {
290 tokio::time::sleep(Duration::from_millis(10)).await;
291 }
292 if entry.terminal.is_alive() {
293 return Err("the terminal's shell did not exit within 2 s; it is still open".to_owned());
296 }
297 let exit = entry
298 .terminal
299 .shell_exit()
300 .map(|exit| TerminalExit { code: exit.code, signal: exit.signal });
301 workspace.close_terminal(seat);
302 Ok(exit)
303 }
304
305 async fn hand_off(
306 &self,
307 seat: &SessionSlot,
308 id: u16,
309 reason: &str,
310 ) -> Result<HandOffEnding, String> {
311 let workspace = self.workspace()?;
312 if workspace.terminal_held(seat) {
313 return Err("a hand-off is already parked on this terminal".to_owned());
314 }
315 let (parked, answer) = workspace.register_terminal_hand_off(seat, id, reason)?;
318 let guard =
319 ResolveOnDrop { workspace: Arc::clone(&workspace), id: parked, caller: seat.clone() };
320 let ending = answer
321 .await
322 .map_err(|_| "no attached client can show the terminal hand-off".to_owned())?;
323 drop(guard);
324 Ok(ending)
325 }
326
327 async fn run(
328 &self,
329 seat: &SessionSlot,
330 command: &str,
331 cwd: Option<String>,
332 timeout_ms: u64,
333 ) -> Result<RunAnswer, String> {
334 let workspace = self.workspace()?;
335 refuse_when_held(&workspace, seat)?;
336 let entry = workspace.spawn_terminal_for_run(
337 seat,
338 cwd.filter(|cwd| !cwd.trim().is_empty()).map(PathBuf::from),
339 )?;
340 let guard = CloseOnDrop { terminal: Arc::clone(&entry.terminal) };
343 wait_ready(&entry).await;
344 let deadline = Instant::now() + Duration::from_millis(timeout_ms);
345 entry.terminal.write_text(command).map_err(|error| error.to_string())?;
346 entry.terminal.write_keys(&[Key::Enter]).map_err(|error| error.to_string())?;
347 let matched = wait_exit(&entry, deadline, None).await?;
348 let read = entry.terminal.read(None, 500, 64 * 1024);
349 let exit = if matched {
350 entry
351 .terminal
352 .state()
353 .exit
354 .map(|exit| TerminalExit { code: exit.code, signal: exit.signal })
355 } else {
356 entry.terminal.close();
358 Some(TerminalExit { code: None, signal: Some(9) })
359 };
360 drop(guard);
361 Ok(RunAnswer { exit, text: read.text, truncated: read.truncated })
362 }
363}
364
365fn refuse_when_held(workspace: &Workspace, seat: &SessionSlot) -> Result<(), String> {
372 if workspace.terminal_held(seat) {
373 return Err(held_error());
374 }
375 Ok(())
376}
377
378fn held_error() -> String {
379 "a hand-off is parked on this terminal: the person owns its screen and input until they answer"
380 .to_owned()
381}
382
383type Blind<'a> = Option<(&'a Workspace, &'a SessionSlot)>;
387
388fn refusal_now(blind: Blind<'_>) -> Option<String> {
389 let (workspace, seat) = blind?;
390 if workspace.terminal_held(seat) {
391 return Some(held_error());
392 }
393 None
394}
395
396struct ResolveOnDrop {
399 workspace: Arc<Workspace>,
400 id: uuid::Uuid,
401 caller: SessionSlot,
402}
403
404impl Drop for ResolveOnDrop {
405 fn drop(&mut self) {
406 self.workspace.resolve_terminal_hand_off(
407 self.id,
408 &self.caller,
409 forge_primitives::terminal::TerminalHandOffAnswer {
410 ending: HandOffEnding::Abandoned,
411 source: HandOffSource::Abandoned,
412 client: None,
413 },
414 );
415 }
416}
417
418struct CloseOnDrop {
420 terminal: Arc<forge_terminal::Terminal>,
421}
422
423impl Drop for CloseOnDrop {
424 fn drop(&mut self) {
425 self.terminal.close();
426 }
427}
428
429async fn wait_ready(entry: &TerminalEntry) {
432 let mut output = entry.terminal.subscribe();
433 let deadline = Instant::now() + Duration::from_millis(1200);
434 loop {
435 if !entry.terminal.screen().text.trim().is_empty() {
436 return;
437 }
438 let left = deadline.saturating_duration_since(Instant::now());
439 if left.is_zero() {
440 return;
441 }
442 let _ = timeout(left.min(Duration::from_millis(50)), output.recv()).await;
443 }
444}
445
446async fn wait_exit(
449 entry: &TerminalEntry,
450 deadline: Instant,
451 blind: Blind<'_>,
452) -> Result<bool, String> {
453 let mut states = entry.terminal.state_stream();
454 loop {
455 if let Some(why) = refusal_now(blind) {
456 return Err(why);
457 }
458 if !entry.terminal.is_alive() {
459 return Ok(true);
460 }
461 let state = entry.terminal.state();
462 let finished = match state.source {
463 forge_terminal::Source::Exact => !state.running && state.exit.is_some(),
464 forge_terminal::Source::Heuristic => {
465 !state.running
466 && state
467 .input_expected
468 .as_ref()
469 .is_some_and(|expected| expected.kind == forge_terminal::InputKind::Prompt)
470 }
471 };
472 if finished {
473 return Ok(true);
474 }
475 let left = deadline.saturating_duration_since(Instant::now());
476 if left.is_zero() {
477 return Ok(false);
478 }
479 let _ = timeout(left.min(Duration::from_millis(50)), states.changed()).await;
480 }
481}
482
483async fn wait_quiet(
485 entry: &TerminalEntry,
486 deadline: Instant,
487 blind: Blind<'_>,
488) -> Result<bool, String> {
489 let mut output = entry.terminal.subscribe();
490 loop {
491 if let Some(why) = refusal_now(blind) {
492 return Err(why);
493 }
494 let left = deadline.saturating_duration_since(Instant::now());
495 if left.is_zero() {
496 return Ok(false);
497 }
498 let window = Duration::from_millis(300);
499 match timeout(window.min(left), output.recv()).await {
500 Ok(Ok(_)) => {}
501 Ok(Err(_)) => return Ok(true),
502 Err(_) if left >= window => return Ok(true),
503 Err(_) => return Ok(false),
504 }
505 }
506}
507
508async fn wait_pattern(
510 entry: &TerminalEntry,
511 pattern: &str,
512 deadline: Instant,
513 blind: Blind<'_>,
514) -> Result<bool, String> {
515 let mut output = entry.terminal.subscribe();
516 loop {
517 if let Some(why) = refusal_now(blind) {
518 return Err(why);
519 }
520 if entry.terminal.screen().text.contains(pattern) {
521 return Ok(true);
522 }
523 let left = deadline.saturating_duration_since(Instant::now());
524 if left.is_zero() {
525 return Ok(false);
526 }
527 let _ = timeout(left.min(Duration::from_millis(100)), output.recv()).await;
528 }
529}
530
531#[cfg(test)]
533type WriteCall = (u16, Option<String>, Vec<Key>);
534
535#[cfg(test)]
537type ReadCall = (u16, Option<u64>, usize, usize);
538
539#[cfg(test)]
542pub struct MockTerminalFacade {
543 pub opens: parking_lot::Mutex<Vec<OpenRequest>>,
544 pub writes: parking_lot::Mutex<Vec<WriteCall>>,
545 pub reads: parking_lot::Mutex<Vec<ReadCall>>,
546 pub waits: parking_lot::Mutex<Vec<(u16, WaitUntil, u64)>>,
547 pub resizes: parking_lot::Mutex<Vec<(u16, u16, u16)>>,
548 pub closes: parking_lot::Mutex<Vec<u16>>,
549 pub hand_offs: parking_lot::Mutex<Vec<(u16, String)>>,
550 pub runs: parking_lot::Mutex<Vec<(String, Option<String>, u64)>>,
551 pub seats: parking_lot::Mutex<Vec<SessionSlot>>,
552 pub answer: parking_lot::Mutex<Result<OpenAnswer, String>>,
553 pub state: parking_lot::Mutex<Result<TerminalState, String>>,
554 pub read_answer: parking_lot::Mutex<Result<ReadAnswer, String>>,
555 pub wait_answer: parking_lot::Mutex<Result<WaitAnswer, String>>,
556 pub close_answer: parking_lot::Mutex<Result<Option<TerminalExit>, String>>,
557 pub hand_off_answer: parking_lot::Mutex<Result<HandOffEnding, String>>,
558 pub run_answer: parking_lot::Mutex<Result<RunAnswer, String>>,
559}
560
561#[cfg(test)]
562impl MockTerminalFacade {
563 pub fn new() -> Self {
564 Self {
565 opens: parking_lot::Mutex::new(Vec::new()),
566 writes: parking_lot::Mutex::new(Vec::new()),
567 reads: parking_lot::Mutex::new(Vec::new()),
568 waits: parking_lot::Mutex::new(Vec::new()),
569 resizes: parking_lot::Mutex::new(Vec::new()),
570 closes: parking_lot::Mutex::new(Vec::new()),
571 hand_offs: parking_lot::Mutex::new(Vec::new()),
572 runs: parking_lot::Mutex::new(Vec::new()),
573 seats: parking_lot::Mutex::new(Vec::new()),
574 answer: parking_lot::Mutex::new(Ok(OpenAnswer {
575 id: 1,
576 screen: "$ ".to_owned(),
577 cols: 120,
578 rows: 30,
579 shell: "/bin/zsh".to_owned(),
580 cwd: "/tmp".to_owned(),
581 })),
582 state: parking_lot::Mutex::new(Ok(empty_state())),
583 read_answer: parking_lot::Mutex::new(Ok(ReadAnswer {
584 text: "hello".to_owned(),
585 cursor: 1,
586 cols: 120,
587 rows: 30,
588 state: empty_state(),
589 truncated: false,
590 })),
591 wait_answer: parking_lot::Mutex::new(Ok(WaitAnswer {
592 matched: true,
593 text: "done".to_owned(),
594 cursor: 2,
595 state: empty_state(),
596 })),
597 close_answer: parking_lot::Mutex::new(Ok(Some(TerminalExit {
598 code: Some(0),
599 signal: None,
600 }))),
601 hand_off_answer: parking_lot::Mutex::new(Ok(HandOffEnding::Done)),
602 run_answer: parking_lot::Mutex::new(Ok(RunAnswer {
603 exit: Some(TerminalExit { code: Some(0), signal: None }),
604 text: "ran".to_owned(),
605 truncated: false,
606 })),
607 }
608 }
609
610 pub fn into_arc(self) -> Arc<dyn TerminalFacade> {
611 Arc::new(self)
612 }
613}
614
615#[cfg(test)]
616pub(crate) fn empty_state() -> TerminalState {
617 TerminalState {
618 running: false,
619 input_expected: None,
620 exit: None,
621 source: forge_primitives::terminal::TerminalSource::Heuristic,
622 }
623}
624
625#[cfg(test)]
626#[async_trait]
627impl TerminalFacade for MockTerminalFacade {
628 async fn open(&self, seat: &SessionSlot, request: OpenRequest) -> Result<OpenAnswer, String> {
629 self.seats.lock().push(seat.clone());
630 self.opens.lock().push(request);
631 self.answer.lock().clone()
632 }
633
634 async fn write(
635 &self,
636 seat: &SessionSlot,
637 id: u16,
638 text: Option<String>,
639 keys: Vec<Key>,
640 ) -> Result<TerminalState, String> {
641 self.seats.lock().push(seat.clone());
642 self.writes.lock().push((id, text, keys));
643 self.state.lock().clone()
644 }
645
646 async fn read(
647 &self,
648 seat: &SessionSlot,
649 id: u16,
650 since: Option<u64>,
651 max_lines: usize,
652 max_bytes: usize,
653 ) -> Result<ReadAnswer, String> {
654 self.seats.lock().push(seat.clone());
655 self.reads.lock().push((id, since, max_lines, max_bytes));
656 self.read_answer.lock().clone()
657 }
658
659 async fn wait(
660 &self,
661 seat: &SessionSlot,
662 id: u16,
663 until: WaitUntil,
664 timeout_ms: u64,
665 ) -> Result<WaitAnswer, String> {
666 self.seats.lock().push(seat.clone());
667 self.waits.lock().push((id, until, timeout_ms));
668 self.wait_answer.lock().clone()
669 }
670
671 async fn resize(
672 &self,
673 seat: &SessionSlot,
674 id: u16,
675 cols: u16,
676 rows: u16,
677 ) -> Result<(), String> {
678 self.seats.lock().push(seat.clone());
679 self.resizes.lock().push((id, cols, rows));
680 Ok(())
681 }
682
683 async fn close(&self, seat: &SessionSlot, id: u16) -> Result<Option<TerminalExit>, String> {
684 self.seats.lock().push(seat.clone());
685 self.closes.lock().push(id);
686 self.close_answer.lock().clone()
687 }
688
689 async fn hand_off(
690 &self,
691 seat: &SessionSlot,
692 id: u16,
693 reason: &str,
694 ) -> Result<HandOffEnding, String> {
695 self.seats.lock().push(seat.clone());
696 self.hand_offs.lock().push((id, reason.to_owned()));
697 self.hand_off_answer.lock().clone()
698 }
699
700 async fn run(
701 &self,
702 seat: &SessionSlot,
703 command: &str,
704 cwd: Option<String>,
705 timeout_ms: u64,
706 ) -> Result<RunAnswer, String> {
707 self.seats.lock().push(seat.clone());
708 self.runs.lock().push((command.to_owned(), cwd, timeout_ms));
709 self.run_answer.lock().clone()
710 }
711}