1use forge_primitives::document::{DocumentHandOffAsk, DocumentHandOffEnding};
12use tokio::sync::oneshot;
13
14use crate::SessionSlot;
15use crate::protocol::SessionUpdate;
16use crate::workspace::Workspace;
17
18pub const NO_VIEWER_CLIENT: &str =
20 "no client is connected, so the document cannot open - no view is attached to draw it";
21
22pub(crate) struct ParkedDocument {
25 pub(crate) seat: SessionSlot,
26 pub(crate) ask: DocumentHandOffAsk,
27 sender: oneshot::Sender<DocumentHandOffEnding>,
28}
29
30impl Workspace {
31 pub(crate) fn register_document_hand_off(
40 &self,
41 seat: &SessionSlot,
42 path: &str,
43 reviewable: bool,
44 ) -> Option<(uuid::Uuid, oneshot::Receiver<DocumentHandOffEnding>)> {
45 let id = uuid::Uuid::new_v4();
46 let (sender, receiver) = oneshot::channel();
47 if !self.client_attached() {
48 return None;
49 }
50 let ask = DocumentHandOffAsk { id, path: path.to_owned(), reviewable };
51 self.document_handoffs
52 .lock()
53 .insert(id, ParkedDocument { seat: seat.clone(), ask, sender });
54 let answerable =
55 self.update_sender().send_answering(SessionUpdate::DocumentHandOffPending {
56 key: seat.clone(),
57 id,
58 path: path.to_owned(),
59 reviewable,
60 });
61 if !answerable {
62 self.document_handoffs.lock().remove(&id);
63 return None;
64 }
65 self.note_ask(seat);
66 Some((id, receiver))
67 }
68
69 pub(crate) fn document_handoff_waiting(&self, id: uuid::Uuid, seat: &SessionSlot) -> bool {
73 self.document_handoffs.lock().get(&id).is_some_and(|held| &held.seat == seat)
74 }
75
76 pub(crate) fn resolve_document_hand_off(
79 &self,
80 id: uuid::Uuid,
81 seat: &SessionSlot,
82 ending: DocumentHandOffEnding,
83 ) -> bool {
84 let mut parked = self.document_handoffs.lock();
85 if !parked.get(&id).is_some_and(|held| &held.seat == seat) {
86 return false;
87 }
88 let Some(held) = parked.remove(&id) else { return false };
89 drop(parked);
90 let _ = held.sender.send(ending.clone());
91 let _ = self.update_sender().send(SessionUpdate::DocumentHandOffResolved {
92 key: held.seat,
93 id,
94 ending,
95 });
96 true
97 }
98
99 pub async fn hand_off_document(
110 &self,
111 seat: &SessionSlot,
112 path: &str,
113 reviewable: bool,
114 ) -> Result<(String, DocumentHandOffEnding), String> {
115 let Some(cwd) = self.cwd_for_session(seat) else {
116 return Err(format!("forge holds no session for {}", seat.display()));
117 };
118 let read = crate::env::documents::read(
119 std::path::Path::new(&cwd),
120 &self.document_roots_for_session(seat),
121 path,
122 reviewable,
123 self.document_max_bytes(),
124 )
125 .map_err(|refusal| refusal.to_string())?;
126 let resolved = read.path.to_string_lossy().into_owned();
127 let Some((id, ending)) = self.register_document_hand_off(seat, &resolved, reviewable)
132 else {
133 return Err(NO_VIEWER_CLIENT.to_owned());
134 };
135 let _guard = AbandonOnDrop { workspace: self, id, seat };
139 Ok((resolved, ending.await.unwrap_or(DocumentHandOffEnding::Abandoned)))
140 }
141}
142
143struct AbandonOnDrop<'a> {
147 workspace: &'a Workspace,
148 id: uuid::Uuid,
149 seat: &'a SessionSlot,
150}
151
152impl Drop for AbandonOnDrop<'_> {
153 fn drop(&mut self) {
154 self.workspace.resolve_document_hand_off(
155 self.id,
156 self.seat,
157 DocumentHandOffEnding::Abandoned,
158 );
159 }
160}
161
162#[cfg(test)]
163mod tests {
164 use std::path::PathBuf;
165 use std::sync::Arc;
166
167 use forge_primitives::document::DocumentHandOffEnding;
168
169 use crate::SessionSlot;
170 use crate::workspace::Workspace;
171
172 fn ws_with_project(cwd: &str, roots: Vec<PathBuf>) -> (Arc<Workspace>, SessionSlot) {
178 let mut config = crate::config::LoadedConfig::empty_for_test();
179 config.projects.push(crate::config::LoadedProject {
180 name: "myproj".to_owned(),
181 path: PathBuf::from(cwd),
182 display_path: cwd.to_owned(),
183 org: "TestOrg".to_owned(),
184 accounts: Vec::new(),
185 fallback_accounts: Vec::new(),
186 model: None,
187 env: std::collections::HashMap::new(),
188 env_file: None,
189 max_workers: None,
190 permission_mode: forge_primitives::permission::PermissionMode::Auto,
191 document_roots: roots,
192 handoff: None,
193 browser_profile: forge_primitives::browser::ProfileKey::of("TestOrg", "default")
194 .expect("a profile key"),
195 slack_workspace: None,
196 });
197 let (ws, _rx) = Workspace::testing_stub_with_config(
198 PathBuf::from("/tmp/forge-document-roots-test"),
199 config,
200 )
201 .expect("the stub builds");
202 (ws, SessionSlot::lead("TestOrg", "myproj"))
203 }
204
205 #[tokio::test]
209 async fn a_hand_off_waiter_that_dies_is_reported_as_abandoned() {
210 let dir = tempfile::tempdir().expect("tempdir");
211 let base = dir.path().canonicalize().expect("canonical");
212 std::fs::create_dir_all(base.join("tree/docs")).expect("the tree");
213 std::fs::write(base.join("tree/docs/readme.md"), b"# hi\n").expect("the document");
214 let (ws, seat) = ws_with_project(&base.join("tree").to_string_lossy(), Vec::new());
215 ws.note_client_attached();
216 let mut updates = ws.subscribe_answerer();
217
218 let task = tokio::spawn({
219 let ws = Arc::clone(&ws);
220 let seat = seat.clone();
221 async move { ws.hand_off_document(&seat, "docs/readme.md", false).await }
222 });
223 let mut pending = false;
226 for _ in 0..400 {
227 while let Ok(frame) = updates.try_recv() {
228 if matches!(
229 &*frame.update,
230 crate::protocol::SessionUpdate::DocumentHandOffPending { .. }
231 ) {
232 pending = true;
233 }
234 }
235 if pending {
236 break;
237 }
238 tokio::task::yield_now().await;
239 }
240 assert!(pending, "the blocked call parked the ask");
241
242 task.abort();
243 let _ = task.await;
244
245 let mut resolved = Vec::new();
246 while let Ok(frame) = updates.try_recv() {
247 if let crate::protocol::SessionUpdate::DocumentHandOffResolved { ending, .. } =
248 &*frame.update
249 {
250 resolved.push(ending.clone());
251 }
252 }
253 assert_eq!(
254 resolved,
255 vec![DocumentHandOffEnding::Abandoned],
256 "a dropped waiter says which ending took the ask",
257 );
258 assert!(ws.document_handoffs.lock().is_empty(), "and leaves nothing registered");
259 }
260
261 #[tokio::test]
267 async fn the_hand_off_reads_through_the_seats_projects_document_roots() {
268 let dir = tempfile::tempdir().expect("tempdir");
269 let base = dir.path().canonicalize().expect("canonical");
270 let tree = base.join("tree");
271 std::fs::create_dir_all(&tree).expect("the session tree");
272 std::fs::create_dir_all(base.join("vault")).expect("the vault");
273 std::fs::write(base.join("vault/plan.md"), b"# plan\n").expect("the vault document");
274 let plan = base.join("vault/plan.md").to_string_lossy().into_owned();
275
276 let (ws, seat) = ws_with_project(&tree.to_string_lossy(), vec![base.join("vault")]);
277 let refusal = ws
278 .hand_off_document(&seat, &plan, false)
279 .await
280 .expect_err("no client is attached, so the park is what refuses");
281 assert_eq!(refusal, super::NO_VIEWER_CLIENT, "the read got past containment");
282
283 let (no_roots, no_roots_seat) = ws_with_project(&tree.to_string_lossy(), Vec::new());
284 let refusal = no_roots
285 .hand_off_document(&no_roots_seat, &plan, false)
286 .await
287 .expect_err("outside the tree and every root");
288 assert!(
289 refusal.contains("resolves outside the session's tree"),
290 "the control is the refusal sentence: {refusal}"
291 );
292 }
293}