Skip to main content

forge_workspace/
documents.rs

1//! The document hand-off's parked ask: one document handed to the reader,
2//! held until their answer.
3//!
4//! Mirrors [`crate::browser`]'s relay shape rather than inventing a second
5//! parked one: [`Workspace::hand_off_document`] registers the call and
6//! awaits its ending; a client answers **not now** to decline, and the
7//! viewer's **approve** or **feedback** - after the reader's comments were
8//! filed - releases it with the decision and the note the reader gave.
9//! Nothing parks without a client attached to draw the ask.
10
11use forge_primitives::document::{DocumentHandOffAsk, DocumentHandOffEnding};
12use tokio::sync::oneshot;
13
14use crate::SessionSlot;
15use crate::protocol::SessionUpdate;
16use crate::workspace::Workspace;
17
18/// What a hand-off answers when no client is attached to draw its ask.
19pub const NO_VIEWER_CLIENT: &str =
20    "no client is connected, so the document cannot open - no view is attached to draw it";
21
22/// One parked call, under its id: who handed it, the ask as the read
23/// surface carries it, and the sender the awaiting tool holds.
24pub(crate) struct ParkedDocument {
25    pub(crate) seat: SessionSlot,
26    pub(crate) ask: DocumentHandOffAsk,
27    sender: oneshot::Sender<DocumentHandOffEnding>,
28}
29
30impl Workspace {
31    /// Hold a document hand-off for the reader, and hand back its id plus
32    /// the receiver the caller awaits.
33    ///
34    /// **No client, no park.** A forge with no attached client returns
35    /// `None` rather than a receiver: holding a session on an ask no view
36    /// can draw would park it for ever, which is the failure this refuses.
37    /// The raise goes out as an ANSWERING prompt, the same way a browser
38    /// hand-off's does - the ask needs a client that can answer it.
39    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    /// Whether a hand-off with this id is still registered to `seat`,
70    /// without removing it - the read the dispatch guard makes before an
71    /// answer.
72    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    /// Remove a parked hand-off, answer its waiter, and tell every view it
77    /// is gone. `false` when the id is not this seat's to resolve.
78    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    /// Hand one document to the reader: read it out of `seat`'s own tree,
100    /// ask the clients drawing that seat to open it, and park until the
101    /// reader answers.
102    ///
103    /// **The answer is a decision, not the open.** The reader accepts,
104    /// reads and comments; the viewer's approve or feedback releases the
105    /// call with the decision, note and filed review, `NotNow` releases it
106    /// with their decline, and the waiter's own drop with
107    /// [`DocumentHandOffEnding::Abandoned`]. A refused read raises nothing
108    /// and parks on nothing.
109    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        // The ask carries what the AGENT asked for, not the document's own
128        // reviewability: a reader can comment on any document a client can
129        // read, so the flag is the hand-off's own fact and nothing gates on
130        // the read's answer.
131        let Some((id, ending)) = self.register_document_hand_off(seat, &resolved, reviewable)
132        else {
133            return Err(NO_VIEWER_CLIENT.to_owned());
134        };
135        // A waiter that goes away releases the park. Resolving an id the
136        // normal path already removed is a no-op, so the guard needs no
137        // arming flag: it only ever fires on the drop.
138        let _guard = AbandonOnDrop { workspace: self, id, seat };
139        Ok((resolved, ending.await.unwrap_or(DocumentHandOffEnding::Abandoned)))
140    }
141}
142
143/// Releases a parked hand-off as [`DocumentHandOffEnding::Abandoned`] when
144/// the awaiting call goes away mid-ask - a cancelled turn must not leave the
145/// registry holding a sender nothing awaits, or an ask no view can close.
146struct 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    /// A workspace holding one project rooted at `cwd` with `roots` as
173    /// its document roots, and the project's lead slot. The project is
174    /// declared in the config rather than seeded into the test overlay:
175    /// a lead's cwd comes from `project_for_slot`, which reads
176    /// `config.projects`.
177    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    /// **A waiter that goes away releases its park.** The document guard
206    /// carries the same rule the browser and Slack ones do: a cancelled
207    /// turn must not leave an ask no view can close.
208    #[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        // The ask lands in the registry, which is what the blocked call just
224        // did.
225        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    /// The hand-off consults the seat's PROJECT's document_roots: a file
262    /// under one gets past the containment check - with no client
263    /// attached the park is what refuses it - while the same call on a
264    /// project declaring none is the outside-the-tree sentence, the
265    /// control that the roots are what carried it.
266    #[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}