Skip to main content

forge_workspace/
settings_ask.rs

1//! The held configuration write.
2//!
3//! A settings edit an agent asks for parks here, in the workspace's own
4//! registry, until the person at a client answers it: the blocked tool call
5//! awaits a oneshot this module hands back, and nothing reaches
6//! `forge.toml` unless that answer approved it. It sits beside the Slack
7//! draft and the hand-offs for the same reason they do - the parking is
8//! workspace state, not a session's pending set, so a seat with no domain
9//! can still hold one.
10
11use uuid::Uuid;
12
13use crate::SessionSlot;
14use crate::protocol::{PendingAsk, SessionUpdate};
15use crate::workspace::Workspace;
16use forge_primitives::settings::{SettingsWriteAsk, SettingsWriteEnding};
17
18/// One composed write held for a decision: the session that asked, the ask
19/// itself, and the sender the blocked tool handler awaits. The sender is
20/// taken at the answer: **the entry stays until the write is settled**, so a
21/// second view's answer is refused while the first one applies, and the
22/// resolved update carries the write's own outcome rather than the click.
23pub(crate) type ParkedSettingsWrite =
24    (SessionSlot, SettingsWriteAsk, Option<tokio::sync::oneshot::Sender<bool>>);
25
26impl Workspace {
27    /// Hold a composed write and hand back its id plus the receiver the
28    /// caller awaits. Addressed to `caller`, so the prompt surfaces in the
29    /// session that will read the reply rather than in whichever session
30    /// happens to be focused.
31    pub(crate) fn register_settings_write(
32        &self,
33        caller: &SessionSlot,
34        ask: SettingsWriteAsk,
35    ) -> (Uuid, tokio::sync::oneshot::Receiver<bool>, bool) {
36        let (sender, receiver) = tokio::sync::oneshot::channel();
37        let id = ask.id;
38        self.settings_writes.lock().insert(id, (caller.clone(), ask.clone(), Some(sender)));
39        let answerable = self
40            .update_sender()
41            .send_answering(SessionUpdate::SettingsWritePending { key: caller.clone(), ask });
42        if !answerable {
43            // No subscriber can answer this - either none is attached or none
44            // renders a dock - so holding the caller would park it for ever.
45            // Drop the write; the awaiting caller reads the flag and fails
46            // closed.
47            self.settings_writes.lock().remove(&id);
48            let (_ignored_sender, dead_receiver) = tokio::sync::oneshot::channel();
49            return (id, dead_receiver, false);
50        }
51        // **A second write is a second ask.** The number is the ask's identity
52        // on the wire, so a write that never moves it is never announced.
53        self.note_ask(caller);
54        (id, receiver, true)
55    }
56
57    /// Whether a write with this id is still registered to `caller`, without
58    /// removing it - the read the dispatch guard makes before an answer.
59    pub(crate) fn settings_write_waiting(&self, id: Uuid, caller: &SessionSlot) -> bool {
60        self.settings_writes.lock().get(&id).is_some_and(|(owner, _, _)| owner == caller)
61    }
62
63    /// Answer a held write with the reader's decision, returning whether one
64    /// was waiting for THIS caller. True exactly once, for the entry that was
65    /// there; a write held for another session, or one already answered and
66    /// still applying, is refused rather than answered.
67    ///
68    /// **The entry stays until the write is settled** ([`Self::settle_settings_write`]):
69    /// the answer unblocks the caller, which applies the edit and then
70    /// reports the outcome, so the resolved update says what happened to the
71    /// write rather than merely that a button was pressed.
72    pub(crate) fn resolve_settings_write(
73        &self,
74        id: Uuid,
75        caller: &SessionSlot,
76        approved: bool,
77    ) -> bool {
78        let mut writes = self.settings_writes.lock();
79        let Some((owner, _, sender)) = writes.get_mut(&id) else { return false };
80        if owner != caller {
81            return false;
82        }
83        let Some(sender) = sender.take() else { return false };
84        drop(writes);
85        let _ = sender.send(approved);
86        true
87    }
88
89    /// Settle an answered or abandoned write: remove the entry and emit the
90    /// ending every view reads. Idempotent, because the caller's drop guard
91    /// and the answer path both reach it and only the first has an entry.
92    pub(crate) fn settle_settings_write(
93        &self,
94        id: Uuid,
95        caller: &SessionSlot,
96        ending: SettingsWriteEnding,
97    ) -> bool {
98        let mut writes = self.settings_writes.lock();
99        if !writes.get(&id).is_some_and(|(owner, _, _)| owner == caller) {
100            return false;
101        }
102        let Some((owner, _, _)) = writes.remove(&id) else { return false };
103        drop(writes);
104        let _ = self.update_sender().send(SessionUpdate::SettingsWriteResolved {
105            key: owner,
106            id,
107            ending,
108        });
109        true
110    }
111
112    /// End every write `slot` still holds as abandoned.
113    ///
114    /// **A parked write outlives its own future.** The blocked MCP handler's
115    /// future is dropped when the session's task goes, but a detached control
116    /// task is not aborted by the client's disconnect, so the registry sweep
117    /// is what tells every other view the dock is over. Worker labels are
118    /// reused, and an entry left behind would draw the previous occupant's
119    /// write on the next session's dock.
120    pub(crate) fn abandon_settings_writes(&self, slot: &SessionSlot) {
121        let ids: Vec<Uuid> = self
122            .settings_writes
123            .lock()
124            .iter()
125            .filter(|(_, (owner, _, _))| owner == slot)
126            .map(|(id, _)| *id)
127            .collect();
128        for id in ids {
129            self.settle_settings_write(id, slot, SettingsWriteEnding::Abandoned);
130        }
131    }
132
133    /// Every held write this seat owns, **in the registry's own order** - a
134    /// `HashMap` keeps no arrival order to offer, the same exception two
135    /// Slack drafts already have. A seat with one write sees the same frame
136    /// and read; only two at once can differ between them.
137    pub(crate) fn settings_write_asks(&self, slot: &SessionSlot) -> Vec<PendingAsk> {
138        self.settings_writes
139            .lock()
140            .values()
141            .filter(|(owner, _, _)| owner == slot)
142            .map(|(_, ask, _)| PendingAsk::SettingsWrite(Box::new(ask.clone())))
143            .collect()
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use std::sync::Arc;
150
151    use super::*;
152    use forge_primitives::settings::SettingsWriteChange;
153    use serde_json::json;
154
155    fn caller() -> SessionSlot {
156        SessionSlot::lead("Default", "forge")
157    }
158
159    fn an_ask(key: &str) -> SettingsWriteAsk {
160        SettingsWriteAsk {
161            id: Uuid::nil(),
162            edits: vec![SettingsWriteChange {
163                key: key.to_owned(),
164                current: json!(2),
165                proposed: json!(4),
166                unset: false,
167            }],
168        }
169    }
170
171    /// A stub workspace whose answering subscriber stays alive, which is what
172    /// makes a registration answerable at all.
173    fn stub() -> (Arc<Workspace>, tokio::sync::mpsc::UnboundedReceiver<crate::SeatUpdate>) {
174        Workspace::testing_stub()
175    }
176
177    /// The prompt is addressed to the session that asked, never the lead:
178    /// the agent blocked on the oneshot is the one the answer belongs to.
179    #[test]
180    fn a_write_is_addressed_to_the_session_that_asked() {
181        let (ws, mut rx) = Workspace::testing_stub();
182        let asked = SessionSlot::worker("Default", "forge", "settings-worker");
183        let mut ask = an_ask("orgs.Default.projects.forge.max_workers");
184        ask.id = Uuid::new_v4();
185        let _held = ws.register_settings_write(&asked, ask);
186
187        let mut addressed = Vec::new();
188        while let Ok(frame) = rx.try_recv() {
189            if let SessionUpdate::SettingsWritePending { key, .. } =
190                std::sync::Arc::unwrap_or_clone(frame.update)
191            {
192                addressed.push(key);
193            }
194        }
195        assert_eq!(addressed, vec![asked], "the write is addressed to the worker that asked");
196    }
197
198    /// The record read a client takes on reconnect carries the held write,
199    /// not only the live frame a client that was there happened to see.
200    #[test]
201    fn the_record_read_reports_the_held_write() {
202        let (ws, _rx) = stub();
203        let mut ask = an_ask("orgs.Default.projects.forge.max_workers");
204        ask.id = Uuid::new_v4();
205        let (id, _held, _) = ws.register_settings_write(&caller(), ask);
206
207        let asks = ws.pending_asks(&caller());
208        assert!(
209            asks.iter().any(|waiting| matches!(
210                waiting,
211                PendingAsk::SettingsWrite(held) if held.id == id
212            )),
213            "a client that attached after the ask has no other way to read it",
214        );
215        assert!(ws.pending_asks(&SessionSlot::lead("Other", "project")).is_empty());
216    }
217
218    /// An approval resolves the waiter true and a decline false; a second
219    /// answer for the same id is refused rather than silently taken.
220    #[test]
221    fn an_answer_resolves_the_waiter_exactly_once() {
222        let (ws, _rx) = stub();
223        let mut ask = an_ask("server.port");
224        ask.id = Uuid::new_v4();
225        let (id, mut held, _) = ws.register_settings_write(&caller(), ask);
226
227        assert!(ws.settings_write_waiting(id, &caller()));
228        assert!(ws.resolve_settings_write(id, &caller(), true));
229        assert_eq!(held.try_recv(), Ok(true), "the approval reaches the blocked caller");
230        assert!(!ws.resolve_settings_write(id, &caller(), false), "one answer only");
231        assert!(
232            ws.settings_write_waiting(id, &caller()),
233            "the entry stays until the write is settled",
234        );
235        assert!(ws.settle_settings_write(id, &caller(), SettingsWriteEnding::Applied));
236        assert!(!ws.settings_write_waiting(id, &caller()));
237    }
238
239    /// A write held for this seat is not another seat's to answer.
240    #[test]
241    fn another_seat_cannot_answer_this_write() {
242        let (ws, _rx) = stub();
243        let mut ask = an_ask("server.port");
244        ask.id = Uuid::new_v4();
245        let (id, _held, _) = ws.register_settings_write(&caller(), ask);
246
247        let elsewhere = SessionSlot::worker("Default", "forge", "someone-else");
248        assert!(!ws.resolve_settings_write(id, &elsewhere, true));
249        assert!(ws.settings_write_waiting(id, &caller()), "the write is still the asker's");
250    }
251
252    /// No client to answer: the registry must not hold the write, and the
253    /// awaiting caller sees a dead receiver - the fail-closed read.
254    #[tokio::test]
255    async fn a_write_with_no_ui_to_answer_it_fails_closed() {
256        let (ws, rx) = Workspace::testing_stub();
257        drop(rx);
258        let mut ask = an_ask("server.port");
259        ask.id = Uuid::new_v4();
260
261        let (_id, held, _) = ws.register_settings_write(&caller(), ask);
262
263        assert!(ws.settings_writes.lock().is_empty(), "an unanswerable write is dropped");
264        assert!(held.await.is_err(), "a dead receiver reads as not-approved");
265    }
266
267    /// Through `Workspace::dispatch`, the route a view's dock answer takes -
268    /// the direct `resolve_settings_write` tests above never exercise the
269    /// guard or the arm, so deleting either would leave an approved write
270    /// from the dock hanging with every test still green.
271    #[tokio::test]
272    async fn the_dock_answer_reaches_the_registry_through_dispatch() {
273        let (ws, _rx) = stub();
274        let mut ask = an_ask("server.port");
275        ask.id = Uuid::new_v4();
276        let (id, decision, _) = ws.register_settings_write(&caller(), ask);
277
278        ws.dispatch(crate::protocol::Command::RespondSettingsWrite {
279            key: caller(),
280            id,
281            approved: true,
282        })
283        .expect("the dock answer routes to the workspace");
284
285        assert!(
286            decision.await.expect("the held write answers"),
287            "the answer the dock gave reaches the awaiting caller",
288        );
289    }
290
291    /// A click on a dock the core has already resolved is refused with the
292    /// reason rather than dropped.
293    #[tokio::test]
294    async fn a_stale_dock_answer_is_refused_with_the_reason() {
295        let (ws, _rx) = stub();
296        let mut ask = an_ask("server.port");
297        ask.id = Uuid::new_v4();
298        let (id, _decision, _) = ws.register_settings_write(&caller(), ask);
299        assert!(ws.resolve_settings_write(id, &caller(), true));
300
301        let refused = ws.dispatch(crate::protocol::Command::RespondSettingsWrite {
302            key: caller(),
303            id,
304            approved: true,
305        });
306
307        assert!(
308            matches!(refused, Err(crate::protocol::DispatchError::NoSettingsWriteWaiting { .. })),
309            "a stale dock must report why the answer did not land: {refused:?}",
310        );
311    }
312
313    /// The guard's owner half: another session's dispatch is refused, and
314    /// the write stays waiting for the session that asked.
315    #[tokio::test]
316    async fn another_session_answering_through_dispatch_is_refused() {
317        let (ws, _rx) = stub();
318        let asker = SessionSlot::worker("Default", "forge", "settings-worker");
319        let other = SessionSlot::worker("Default", "forge", "another-worker");
320        let mut ask = an_ask("server.port");
321        ask.id = Uuid::new_v4();
322        let (id, _decision, _) = ws.register_settings_write(&asker, ask);
323
324        let refused = ws.dispatch(crate::protocol::Command::RespondSettingsWrite {
325            key: other,
326            id,
327            approved: true,
328        });
329
330        assert!(
331            matches!(refused, Err(crate::protocol::DispatchError::NoSettingsWriteWaiting { .. })),
332            "{refused:?}",
333        );
334        assert!(ws.settings_write_waiting(id, &asker), "the write is still the asker's");
335    }
336
337    /// **A session going away ends every write it holds.** A detached MCP
338    /// control task is not aborted by the client's disconnect, so without
339    /// this sweep the entry would outlive the session that could answer it -
340    /// and a reused worker label would draw the previous occupant's write on
341    /// the next session's dock.
342    #[test]
343    fn a_session_teardown_abandons_the_writes_it_holds() {
344        let (ws, mut rx) = Workspace::testing_stub();
345        let mut ask = an_ask("server.port");
346        ask.id = Uuid::new_v4();
347        let (_id, mut held, _) = ws.register_settings_write(&caller(), ask);
348
349        ws.abandon_settings_writes(&caller());
350
351        assert!(ws.settings_writes.lock().is_empty(), "the teardown leaves nothing parked");
352        assert!(held.try_recv().is_err(), "the blocked caller is released as abandoned");
353        let mut endings = Vec::new();
354        while let Ok(frame) = rx.try_recv() {
355            if let SessionUpdate::SettingsWriteResolved { ending, .. } =
356                std::sync::Arc::unwrap_or_clone(frame.update)
357            {
358                endings.push(ending);
359            }
360        }
361        assert_eq!(endings, vec![SettingsWriteEnding::Abandoned]);
362    }
363
364    /// A waiter whose session went away is abandoned rather than approved:
365    /// the registry clears it, the blocked caller reads false, and the
366    /// resolved update says which ending took it.
367    #[test]
368    fn an_abandoned_write_is_not_an_approval() {
369        let (ws, mut rx) = Workspace::testing_stub();
370        let mut ask = an_ask("server.port");
371        ask.id = Uuid::new_v4();
372        let (id, mut held, _) = ws.register_settings_write(&caller(), ask);
373
374        assert!(ws.settle_settings_write(id, &caller(), SettingsWriteEnding::Abandoned));
375        assert!(held.try_recv().is_err(), "abandoned is not an approval");
376        assert!(ws.settings_writes.lock().is_empty());
377
378        let mut endings = Vec::new();
379        while let Ok(frame) = rx.try_recv() {
380            if let SessionUpdate::SettingsWriteResolved { ending, .. } =
381                std::sync::Arc::unwrap_or_clone(frame.update)
382            {
383                endings.push(ending);
384            }
385        }
386        assert_eq!(endings, vec![SettingsWriteEnding::Abandoned]);
387    }
388}