1use std::sync::{Arc, Weak};
26
27use crate::mcp::peers::types::{PeerLiveness, PeerStatus, WrappedPrompt};
28
29use crate::SessionSlot;
30use crate::protocol::Command;
31use crate::workspace::Workspace;
32
33#[derive(Clone, Debug, PartialEq, Eq)]
39pub enum DeliverError {
40 UnknownTarget { name: String },
42 DispatchFailed { message: String },
47}
48
49pub trait WorkspaceFacade: Send + Sync {
52 fn list_peers(&self) -> Vec<PeerStatus>;
55
56 fn whoami(&self, caller: &SessionSlot) -> Option<PeerStatus>;
61
62 fn deliver_peer_prompt(
70 &self,
71 caller: &SessionSlot,
72 target_project: &str,
73 wrapped: WrappedPrompt,
74 ) -> Result<(), DeliverError>;
75
76 fn handoff(&self, caller: &SessionSlot, note: String) -> Result<(), HandoffError>;
81}
82
83#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
85pub enum HandoffError {
86 #[error("the seat {slot} is not running; a handoff needs a live session to hand over")]
87 SessionGone { slot: String },
88 #[error(
89 "no handoff is running on {slot}; a handoff starts at /handoff or at the automatic trigger \
90 (fill or compaction)"
91 )]
92 NotInAHandoff { slot: String },
93}
94
95pub struct ProdWorkspaceFacade(pub Weak<Workspace>);
108
109impl ProdWorkspaceFacade {
110 pub fn from_arc(workspace: &Arc<Workspace>) -> Arc<dyn WorkspaceFacade> {
113 Arc::new(Self(Arc::downgrade(workspace)))
114 }
115}
116
117fn lead_for(
123 ws: &crate::workspace::Workspace,
124 view: &crate::views::ProjectView,
125) -> (SessionSlot, bool) {
126 let slot = SessionSlot::lead(&view.org, &view.name);
127 let running = ws.session_is_pooled(&slot);
128 (slot, running)
129}
130
131impl WorkspaceFacade for ProdWorkspaceFacade {
132 fn list_peers(&self) -> Vec<PeerStatus> {
133 let Some(ws) = self.0.upgrade() else { return Vec::new() };
134 ws.list_projects()
135 .into_iter()
136 .map(|view| {
137 let (lead, running) = lead_for(&ws, &view);
138 let liveness = if running { PeerLiveness::Running } else { PeerLiveness::Sleeping };
139 PeerStatus {
140 name: view.name,
141 org: view.org,
142 path: view.path,
143 status: liveness,
144 spawned_at: ws.session_last_activity(&lead),
145 }
146 })
147 .collect()
148 }
149
150 fn whoami(&self, caller: &SessionSlot) -> Option<PeerStatus> {
151 let ws = self.0.upgrade()?;
152 let cx = crate::mcp::caller_context::caller_context(&ws, caller)?;
153 let status = if cx.lead_running { PeerLiveness::Running } else { PeerLiveness::Sleeping };
161 let spawned_at = if cx.is_lead {
165 ws.session_last_activity(&cx.lead)
166 } else {
167 ws.list_live_workers(&cx.project_key)
168 .iter()
169 .find(|w| w.slot == *caller)
170 .map(|w| w.spawned_at)
171 };
172 Some(PeerStatus {
173 name: cx.project_name,
174 org: cx.project_org,
175 path: cx.project_path,
176 status,
177 spawned_at,
178 })
179 }
180
181 fn deliver_peer_prompt(
182 &self,
183 caller: &SessionSlot,
184 target_project: &str,
185 wrapped: WrappedPrompt,
186 ) -> Result<(), DeliverError> {
187 let Some(ws) = self.0.upgrade() else {
188 return Err(DeliverError::UnknownTarget { name: target_project.to_owned() });
189 };
190 if !ws.list_projects().iter().any(|v| v.name == target_project) {
191 return Err(DeliverError::UnknownTarget { name: target_project.to_owned() });
192 }
193 let (respond, mut reply) = tokio::sync::oneshot::channel();
194 ws.dispatch(Command::DeliverPeerPrompt {
195 caller: caller.clone(),
196 target_project: target_project.to_owned(),
197 wrapped,
198 respond: Some(respond),
199 })
200 .map_err(|err| DeliverError::DispatchFailed {
201 message: format!("the workspace did not take the command ({err:?})"),
202 })?;
203 delivery_answer(reply.try_recv())
207 }
208
209 fn handoff(&self, caller: &SessionSlot, note: String) -> Result<(), HandoffError> {
210 let Some(ws) = self.0.upgrade() else {
211 return Err(HandoffError::SessionGone { slot: caller.display() });
212 };
213 if !ws.swap_in_progress(caller) {
217 return Err(HandoffError::NotInAHandoff { slot: caller.display() });
218 }
219 ws.dispatch(Command::Handoff { key: caller.clone(), brief: Some(note) })
220 .map_err(|_| HandoffError::SessionGone { slot: caller.display() })
221 }
222}
223
224fn delivery_answer(
227 reply: Result<Result<(), String>, tokio::sync::oneshot::error::TryRecvError>,
228) -> Result<(), DeliverError> {
229 match reply {
230 Ok(Ok(())) => Ok(()),
231 Ok(Err(message)) => Err(DeliverError::DispatchFailed { message }),
232 Err(_) => Err(DeliverError::DispatchFailed {
233 message: "the delivery handler took the command and answered nothing".to_owned(),
234 }),
235 }
236}
237
238#[cfg(any(test, feature = "testing"))]
242#[derive(Default)]
243pub struct MockWorkspaceFacade {
244 pub peers: parking_lot::Mutex<Vec<PeerStatus>>,
246 pub deliver_calls: parking_lot::Mutex<Vec<(SessionSlot, String, WrappedPrompt)>>,
248 pub force_deliver_error: parking_lot::Mutex<Option<DeliverError>>,
252 pub handoff_calls: parking_lot::Mutex<Vec<(SessionSlot, String)>>,
254}
255
256#[cfg(any(test, feature = "testing"))]
257impl MockWorkspaceFacade {
258 pub fn new() -> Self {
260 Self::default()
261 }
262
263 pub fn into_arc(self) -> Arc<dyn WorkspaceFacade> {
265 Arc::new(self)
266 }
267}
268
269#[cfg(any(test, feature = "testing"))]
270impl WorkspaceFacade for MockWorkspaceFacade {
271 fn list_peers(&self) -> Vec<PeerStatus> {
272 self.peers.lock().clone()
273 }
274
275 fn whoami(&self, caller: &SessionSlot) -> Option<PeerStatus> {
276 self.peers.lock().iter().find(|p| p.name == caller.label()).cloned()
283 }
284
285 fn deliver_peer_prompt(
286 &self,
287 caller: &SessionSlot,
288 target_project: &str,
289 wrapped: WrappedPrompt,
290 ) -> Result<(), DeliverError> {
291 if let Some(err) = self.force_deliver_error.lock().clone() {
292 return Err(err);
293 }
294 let known = self.peers.lock().iter().any(|p| p.name == target_project);
295 if !known {
296 return Err(DeliverError::UnknownTarget { name: target_project.to_owned() });
297 }
298 self.deliver_calls.lock().push((caller.clone(), target_project.to_owned(), wrapped));
299 Ok(())
300 }
301
302 fn handoff(&self, caller: &SessionSlot, note: String) -> Result<(), HandoffError> {
303 self.handoff_calls.lock().push((caller.clone(), note));
304 Ok(())
305 }
306}
307
308#[cfg(test)]
309mod tests {
310 use super::*;
311 use crate::mcp::peers::types::{MessageId, WrappedKind};
312 use std::path::PathBuf;
313
314 fn fake_key(s: &str) -> SessionSlot {
315 SessionSlot::from_str_for_test(s)
319 }
320
321 fn fake_peer(name: &str, liveness: PeerLiveness) -> PeerStatus {
322 PeerStatus {
323 name: name.to_owned(),
324 org: "TestOrg".to_owned(),
325 path: PathBuf::from(format!("/tmp/{name}")),
326 status: liveness,
327 spawned_at: None,
328 }
329 }
330
331 fn fake_wrapped() -> WrappedPrompt {
332 WrappedPrompt {
333 id: MessageId::mint(),
334 kind: WrappedKind::Message,
335 sender_name: "forge".to_owned(),
336 sender_org: "Personal".to_owned(),
337 body: "hi".to_owned(),
338 }
339 }
340
341 #[test]
342 fn mock_list_peers_returns_preloaded() {
343 let mock = MockWorkspaceFacade::new();
344 mock.peers.lock().push(fake_peer("alpha", PeerLiveness::Running));
345 mock.peers.lock().push(fake_peer("beta", PeerLiveness::Sleeping));
346 let peers = mock.list_peers();
347 assert_eq!(peers.len(), 2);
348 assert_eq!(peers[0].name, "alpha");
349 assert_eq!(peers[1].status, PeerLiveness::Sleeping);
350 }
351
352 #[test]
353 fn mock_deliver_unknown_target_errors() {
354 let mock = MockWorkspaceFacade::new();
355 let caller = fake_key("alpha");
356 let result = mock.deliver_peer_prompt(&caller, "missing", fake_wrapped());
357 assert!(
358 matches!(result, Err(DeliverError::UnknownTarget { ref name }) if name == "missing")
359 );
360 }
361
362 #[test]
363 fn mock_deliver_known_target_records_the_call() {
364 let mock = MockWorkspaceFacade::new();
365 mock.peers.lock().push(fake_peer("beta", PeerLiveness::Running));
366 let caller = fake_key("alpha");
367 assert_eq!(mock.deliver_peer_prompt(&caller, "beta", fake_wrapped()), Ok(()));
368 assert_eq!(mock.deliver_calls.lock().len(), 1);
369 }
370
371 #[test]
372 fn mock_whoami_matches_by_name() {
373 let mock = MockWorkspaceFacade::new();
374 mock.peers.lock().push(fake_peer("alpha", PeerLiveness::Running));
375 mock.peers.lock().push(fake_peer("beta", PeerLiveness::Sleeping));
376 let identity = mock.whoami(&fake_key("alpha"));
379 assert!(identity.is_some());
380 assert_eq!(identity.unwrap().name, "alpha");
381 }
382
383 #[test]
384 fn force_deliver_error_overrides_normal_path() {
385 let mock = MockWorkspaceFacade::new();
386 mock.peers.lock().push(fake_peer("beta", PeerLiveness::Running));
387 *mock.force_deliver_error.lock() =
388 Some(DeliverError::UnknownTarget { name: "forced".to_owned() });
389 let caller = fake_key("alpha");
390 let result = mock.deliver_peer_prompt(&caller, "beta", fake_wrapped());
391 assert!(matches!(
392 result,
393 Err(DeliverError::UnknownTarget { ref name }) if name == "forced"
394 ));
395 }
396
397 #[test]
402 fn a_handler_that_answers_nothing_is_not_an_ack() {
403 let (sender, mut reply) = tokio::sync::oneshot::channel::<Result<(), String>>();
404 drop(sender);
405 match delivery_answer(reply.try_recv()) {
406 Err(DeliverError::DispatchFailed { message }) => assert!(
407 message.contains("answered nothing"),
408 "the refusal says the command was taken but unanswered: {message}",
409 ),
410 other => panic!("an empty answer must not read as delivered; got {other:?}"),
411 }
412 }
413}
414
415#[cfg(all(test, feature = "test-helpers"))]
420mod lead_resolution_tests {
421 use super::{DeliverError, HandoffError, ProdWorkspaceFacade, lead_for};
422 use crate::mcp::peers::types::WrappedKind;
423 use crate::target::ProjectKey;
424 use crate::views::{ProjectView, SessionView};
425 use crate::workspace::Workspace;
426 use crate::{MessageId, SessionSlot, WorkerEntry, WrappedPrompt};
427 use forge_primitives::WorkerLiveness;
428 use std::time::{Duration, SystemTime};
429
430 fn session(id: &str) -> SessionView {
431 SessionView::new_for_test(forge_primitives::SessionId::new(id), id, true, None)
432 }
433
434 fn worker_entry(slot: SessionSlot) -> WorkerEntry {
435 WorkerEntry {
436 label: "reviewer".into(),
437 charter: "review the diff".into(),
438 slot,
439 session_id: None,
440 status: WorkerLiveness::Running,
441 spawned_at: SystemTime::UNIX_EPOCH,
442 spawned_by: SessionSlot::lead("Test", "forge"),
443 needs_tag: false,
444 is_git_repo_at_spawn: false,
445 cwd: None,
446 diagnostic: None,
447 kick: None,
448 }
449 }
450
451 #[test]
452 fn names_the_lead_slot_when_no_lead_row_is_catalogued() {
453 let (ws, _rx) = Workspace::testing_stub();
456 let key = ProjectKey::new("p".to_owned());
457 let lead = session("lead-uuid");
458 let view = ProjectView::new_for_test(key, "forge", "/tmp/forge", vec![lead.clone()]);
459 let (resolved, running) = lead_for(&ws, &view);
460 assert_eq!(resolved, SessionSlot::lead("Test", "forge"));
461 assert!(!running, "nothing is pooled for it here");
462 }
463
464 #[test]
465 fn names_the_lead_slot_even_when_every_session_is_a_live_worker() {
466 let (ws, _rx) = Workspace::testing_stub();
470 let key = ProjectKey::new("p".to_owned());
471 let worker = session("worker-uuid");
472 let view =
473 ProjectView::new_for_test(key.clone(), "forge", "/tmp/forge", vec![worker.clone()]);
474 ws.insert_live_worker(
475 &key,
476 worker_entry(SessionSlot::from_str_for_test(worker.session.as_str())),
477 );
478 let (resolved, running) = lead_for(&ws, &view);
479 assert_eq!(
480 resolved,
481 SessionSlot::lead("Test", "forge"),
482 "an all-worker project still names its lead"
483 );
484 assert!(!running, "and the lead is not running");
485 }
486
487 fn wrapped() -> WrappedPrompt {
488 WrappedPrompt {
489 id: MessageId::mint(),
490 kind: WrappedKind::Message,
491 sender_name: "forge".to_owned(),
492 sender_org: "Personal".to_owned(),
493 body: "hi".to_owned(),
494 }
495 }
496
497 #[test]
502 fn a_delivery_no_session_can_take_is_not_acked() {
503 let (ws, _rx) = Workspace::testing_stub();
504 ws.seed_test_project("beta", "/tmp/beta");
505 let slot = SessionSlot::lead("TestOrg", "beta");
506 let (handle, _commands) = Workspace::testing_stub_handle();
507 ws.pool.lock().insert(
508 slot,
509 crate::workspace::PooledAgent {
510 handle: std::sync::Arc::new(handle),
511 account: forge_gateway::AccountKey("acct-a".to_owned()),
512 permission_mode: None,
513 registration: None,
514 session_id: "pooled-no-task".to_owned(),
515 },
516 );
517 let facade = ProdWorkspaceFacade::from_arc(&ws);
518
519 let result = facade.deliver_peer_prompt(
520 &SessionSlot::from_str_for_test("caller"),
521 "beta",
522 WrappedPrompt {
523 id: MessageId::mint(),
524 kind: WrappedKind::Message,
525 sender_name: "forge".to_owned(),
526 sender_org: "Personal".to_owned(),
527 body: "hi".to_owned(),
528 },
529 );
530
531 match result {
532 Err(DeliverError::DispatchFailed { message }) => assert!(
533 message.contains("the target's running session did not take the message"),
534 "the refusal is the running branch's own answer, not an unknown target: {message}",
535 ),
536 other => panic!(
537 "a prompt the pooled lead's missing task cannot take is a refusal; got {other:?}"
538 ),
539 }
540 }
541
542 #[test]
543 fn deliver_to_unknown_project_errors() {
544 let (ws, _rx) = Workspace::testing_stub();
545 let facade = ProdWorkspaceFacade::from_arc(&ws);
546 let result = facade.deliver_peer_prompt(
547 &SessionSlot::from_str_for_test("caller"),
548 "no-such-project",
549 wrapped(),
550 );
551 assert!(
552 matches!(result, Err(DeliverError::UnknownTarget { ref name }) if name == "no-such-project")
553 );
554 }
555
556 #[test]
557 fn whoami_none_when_caller_leads_no_project() {
558 let (ws, _rx) = Workspace::testing_stub();
559 let facade = ProdWorkspaceFacade::from_arc(&ws);
560 assert!(facade.whoami(&SessionSlot::from_str_for_test("nobody")).is_none());
561 }
562
563 #[test]
567 fn whoami_resolves_worker_caller_to_project_peer_identity() {
568 let (ws, _rx) = Workspace::testing_stub();
569 ws.seed_test_project("myproj", "/tmp/myproj");
570 ws.record_connected_session("/tmp/myproj", "lead-uuid", None);
571 let pk = crate::ProjectKey::new(
572 forge_agent::userdata::catalog::scan::project_key_for_directory(Some("/tmp/myproj")),
573 );
574 let worker = SessionSlot::worker("TestOrg", "myproj", "worker-uuid");
575 ws.insert_live_worker(&pk, worker_entry(worker.clone()));
576
577 let facade = ProdWorkspaceFacade::from_arc(&ws);
578 let status =
579 facade.whoami(&worker).expect("worker caller resolves to its project's peer identity");
580 assert_eq!(status.name, "myproj");
581 assert_eq!(status.org, "TestOrg");
582
583 let lead_status = facade
586 .whoami(&SessionSlot::lead("TestOrg", "myproj"))
587 .expect("lead caller still resolves");
588 assert_eq!(lead_status.name, "myproj");
589 }
590
591 fn entry_stamped(label: &str, spawned_at: SystemTime) -> WorkerEntry {
594 let slot = SessionSlot::worker("TestOrg", "myproj", label);
595 WorkerEntry { label: label.to_owned(), spawned_at, ..worker_entry(slot) }
596 }
597
598 #[test]
603 fn whoami_stamps_the_callers_own_seat() {
604 let (ws, _rx) = Workspace::testing_stub();
605 ws.seed_test_project("myproj", "/tmp/myproj");
606 let pk = crate::ProjectKey::new(
607 forge_agent::userdata::catalog::scan::project_key_for_directory(Some("/tmp/myproj")),
608 );
609 let lead = SessionSlot::lead("TestOrg", "myproj");
612 ws.seed_test_running_session_id(&lead, "lead-session");
613 ws.record_connected_session("/tmp/myproj", "lead-session", None);
614 let lead_stamp = ws.session_last_activity(&lead).expect("the lead's row is seeded");
615
616 let caller = SessionSlot::worker("TestOrg", "myproj", "caller");
619 let caller_stamp = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000);
620 ws.insert_live_worker(
621 &pk,
622 entry_stamped("other", SystemTime::UNIX_EPOCH + Duration::from_secs(2_000)),
623 );
624 ws.insert_live_worker(&pk, entry_stamped("caller", caller_stamp));
625 ws.seed_test_running_session_id(&caller, "caller-session");
628 ws.record_connected_session("/tmp/myproj", "caller-session", None);
629
630 let facade = ProdWorkspaceFacade::from_arc(&ws);
631 let worker_status = facade.whoami(&caller).expect("a worker caller resolves");
632 assert_eq!(
633 worker_status.spawned_at,
634 Some(caller_stamp),
635 "a worker caller's stamp is its own seat's, the row agents__list shows for it \
636 (the lead's is {lead_stamp:?})",
637 );
638
639 let lead_status = facade.whoami(&lead).expect("a lead caller resolves");
640 assert_eq!(
641 lead_status.spawned_at,
642 Some(lead_stamp),
643 "a lead caller's stamp is its project's own row, unchanged",
644 );
645 }
646
647 #[test]
652 fn whoami_answers_a_caller_with_no_row_with_no_stamp() {
653 let (ws, _rx) = Workspace::testing_stub();
654 ws.seed_test_project("myproj", "/tmp/myproj");
655 let lead = SessionSlot::lead("TestOrg", "myproj");
658 ws.seed_test_running_session_id(&lead, "lead-session");
659 ws.record_connected_session("/tmp/myproj", "lead-session", None);
660
661 let gone = SessionSlot::worker("TestOrg", "myproj", "gone");
662 let facade = ProdWorkspaceFacade::from_arc(&ws);
663 let status = facade.whoami(&gone).expect("a worker caller still resolves to its project");
664 assert_eq!(
665 status.spawned_at, None,
666 "a caller with no live-worker row is answered with no stamp, not the lead's",
667 );
668 }
669
670 #[test]
674 fn a_running_handoffs_exit_is_dispatched() {
675 let (ws, _rx) = Workspace::testing_stub();
676 ws.enable_test_dispatch_intercept();
677 let facade = ProdWorkspaceFacade::from_arc(&ws);
678 let slot = SessionSlot::worker("Busytools", "forge", "handoff-cut");
679 ws.begin_swap(&slot);
680
681 facade.handoff(&slot, "Now: the cut is in.".to_owned()).expect("accepted");
682
683 let routed = ws.drain_test_dispatch_buffer();
684 assert!(
685 routed.iter().any(|command| matches!(
686 command,
687 crate::protocol::Command::Handoff { brief: Some(note), .. }
688 if note == "Now: the cut is in."
689 )),
690 "the note rides the command: {routed:?}",
691 );
692 }
693
694 #[test]
699 fn a_cold_handoff_exit_is_refused() {
700 let (ws, _rx) = Workspace::testing_stub();
701 let facade = ProdWorkspaceFacade::from_arc(&ws);
702 let slot = SessionSlot::worker("Busytools", "forge", "handoff-cut");
703
704 let error = facade
705 .handoff(&slot, "Now: the cut is in.".to_owned())
706 .expect_err("no handoff is running on this seat");
707 assert_eq!(error, HandoffError::NotInAHandoff { slot: slot.display() });
708 assert!(
709 error.to_string().contains("/handoff")
710 && error.to_string().contains("automatic trigger"),
711 "the refusal names both doors: {error}",
712 );
713 }
714}