1use uuid::Uuid;
12
13use crate::SessionSlot;
14use crate::protocol::{PendingAsk, SessionUpdate};
15use crate::workspace::Workspace;
16use forge_primitives::settings::{SettingsWriteAsk, SettingsWriteEnding};
17
18pub(crate) type ParkedSettingsWrite =
24 (SessionSlot, SettingsWriteAsk, Option<tokio::sync::oneshot::Sender<bool>>);
25
26impl Workspace {
27 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 self.settings_writes.lock().remove(&id);
48 let (_ignored_sender, dead_receiver) = tokio::sync::oneshot::channel();
49 return (id, dead_receiver, false);
50 }
51 self.note_ask(caller);
54 (id, receiver, true)
55 }
56
57 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 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 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 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 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 fn stub() -> (Arc<Workspace>, tokio::sync::mpsc::UnboundedReceiver<crate::SeatUpdate>) {
174 Workspace::testing_stub()
175 }
176
177 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}