max / goingson
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
9 files changed,
+1040 insertions,
-32 deletions
| @@ -8493,6 +8493,10 @@ | |||
| 8493 | 8493 | "winnow 1.0.4", | |
| 8494 | 8494 | ] | |
| 8495 | 8495 | ||
| 8496 | + | [[patch.unused]] | |
| 8497 | + | name = "ops-status" | |
| 8498 | + | version = "0.1.0" | |
| 8499 | + | ||
| 8496 | 8500 | [[patch.unused]] | |
| 8497 | 8501 | name = "quasi-axum" | |
| 8498 | 8502 | version = "0.56.0" | |
| @@ -8508,7 +8512,3 @@ | |||
| 8508 | 8512 | [[patch.unused]] | |
| 8509 | 8513 | name = "quasi-store" | |
| 8510 | 8514 | version = "0.1.0" | |
| 8511 | - | ||
| 8512 | - | [[patch.unused]] | |
| 8513 | - | name = "ops-status" | |
| 8514 | - | version = "0.1.0" |
| @@ -70,3 +70,4 @@ | |||
| 70 | 70 | 067 6ac04d5280e01472180139ba6a5ae83be242dbcae8ca5b1adf90c15ad01507f734047262fd987bc8eb50c238f96621d8 | |
| 71 | 71 | 068 ccdddaa4176f36169bb90b1eec191ae776a87ee1cd56ba2bd789f905fb212bcd2b04b884e820498fab8b6d03fb7e5c6d | |
| 72 | 72 | 069 afc5492dfbda7dde625f1ca00e06e8ae0c26ad10e3a26399aec7bf0fee66d4beaabb0e4b00ee8fab1ddb695790194c46 | |
| 73 | + | 070 b95a1c7f6c7c3103de2d952f9571b8d284b5fbc1679c70757e1a322023f2a4d6f0c213dd0e8b95136483d931b290e896 |
| @@ -12,6 +12,9 @@ | |||
| 12 | 12 | pub mod email_sync_scheduler; | |
| 13 | 13 | pub mod export; | |
| 14 | 14 | pub mod external_sync; | |
| 15 | + | /// The group-admin queue drainer. See the module header, and `outbox` for the | |
| 16 | + | /// pattern it is the second of. | |
| 17 | + | pub mod group_queue; | |
| 15 | 18 | pub mod jmap; | |
| 16 | 19 | pub mod notifs; | |
| 17 | 20 | pub mod oauth; | |
| @@ -466,6 +469,19 @@ | |||
| 466 | 469 | crate::outbox::start_outbox_drainer(outbox_handle, outbox_cancel).await; | |
| 467 | 470 | }); | |
| 468 | 471 | ||
| 472 | + | // The group-admin queue drainer: what actually creates a group or | |
| 473 | + | // admits a member. Same shape and same reason as the outbox above, | |
| 474 | + | // one layer along. See `group_queue`. | |
| 475 | + | let group_queue_handle = app.handle().clone(); | |
| 476 | + | let group_queue_cancel = cancel_token.clone(); | |
| 477 | + | tauri::async_runtime::spawn(async move { | |
| 478 | + | crate::group_queue::start_group_queue_drainer( | |
| 479 | + | group_queue_handle, | |
| 480 | + | group_queue_cancel, | |
| 481 | + | ) | |
| 482 | + | .await; | |
| 483 | + | }); | |
| 484 | + | ||
| 469 | 485 | // Cloud sync runs through the SyncStore engine's own scheduler now | |
| 470 | 486 | // (its own timer + SSE race + gate chain), reporting via GoSyncObserver. | |
| 471 | 487 | let cloud_sync_handle = app.handle().clone(); |
| @@ -617,6 +617,36 @@ | |||
| 617 | 617 | .toast(quasi_router::layout::Tone::Success, said)) | |
| 618 | 618 | } | |
| 619 | 619 | ||
| 620 | + | /// The Sharing pane, re-read, with a word about what just happened. | |
| 621 | + | /// | |
| 622 | + | /// Whole-pane rather than a narrower region, because every one of these writes | |
| 623 | + | /// lands in the queue list and one of them lands in the group list too. | |
| 624 | + | fn sharing_pane(state: &AppState, said: &str) -> Result<Response, RouteError> { | |
| 625 | + | Ok(Response::fragment( | |
| 626 | + | SECTION_REGION, | |
| 627 | + | Node::Region(Slot::new(SECTION_REGION, RegionKind::Pane).extend(sharing::pane(state)?)), | |
| 628 | + | ) | |
| 629 | + | .toast(quasi_router::layout::Tone::Success, said)) | |
| 630 | + | } | |
| 631 | + | ||
| 632 | + | /// Queue a new group. | |
| 633 | + | fn queue_group(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> { | |
| 634 | + | let said = sharing::create_group(state, &request)?; | |
| 635 | + | sharing_pane(state, said) | |
| 636 | + | } | |
| 637 | + | ||
| 638 | + | /// Queue an add-member. | |
| 639 | + | fn queue_member(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> { | |
| 640 | + | let said = sharing::add_member(state, &request)?; | |
| 641 | + | sharing_pane(state, said) | |
| 642 | + | } | |
| 643 | + | ||
| 644 | + | /// Take a queued admin write back out. | |
| 645 | + | fn cancel_queued(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> { | |
| 646 | + | let said = sharing::cancel(state, &request)?; | |
| 647 | + | sharing_pane(state, said) | |
| 648 | + | } | |
| 649 | + | ||
| 620 | 650 | /// Turn automatic syncing on or off. | |
| 621 | 651 | fn set_sync_auto(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> { | |
| 622 | 652 | let on = request.payload.get(sync::AUTO_SYNC).unwrap_or_default() != "disabled"; | |
| @@ -747,6 +777,11 @@ | |||
| 747 | 777 | .post("/settings/about/update-check", set_update_check) | |
| 748 | 778 | .post("/settings/sync/auto", set_sync_auto) | |
| 749 | 779 | .post("/settings/sync/interval", set_sync_interval) | |
| 750 | - | .post("/settings/sync/disconnect", disconnect_sync); | |
| 780 | + | .post("/settings/sync/disconnect", disconnect_sync) | |
| 781 | + | // Every one of these is a local insert. What talks to the server is | |
| 782 | + | // `group_queue`'s drainer; see `sharing`. | |
| 783 | + | .post("/settings/sharing/groups", queue_group) | |
| 784 | + | .post("/settings/sharing/members", queue_member) | |
| 785 | + | .post("/settings/sharing/queue/{id}/cancel", cancel_queued); | |
| 751 | 786 | router.get("/settings/{section}", section) | |
| 752 | 787 | } |
| @@ -67,6 +67,14 @@ | |||
| 67 | 67 | "hlc_state", // hybrid logical clock, device-local sync internal | |
| 68 | 68 | "sync_committed_hlc", // per-row committed HLC clock store, device-local sync internal | |
| 69 | 69 | "user_config", // app settings; synced keys recover via cloud sync, device-local keys re-derive per device | |
| 70 | + | // A queue of admin writes that have not reached the server. Excluded | |
| 71 | + | // because restoring one would PERFORM it again: a backup taken while an | |
| 72 | + | // add-member was queued would, on restore, re-admit that person to the | |
| 73 | + | // group, possibly long after they were deliberately removed. The server is | |
| 74 | + | // the authority on membership and the queue is only ever a list of what has | |
| 75 | + | // not reached it yet, so a restored device should start with an empty one | |
| 76 | + | // and read the truth from the directory. | |
| 77 | + | "group_admin_queue", | |
| 70 | 78 | ]; | |
| 71 | 79 | ||
| 72 | 80 | /// Columns a backup deliberately does not carry, each with the reason. |
| @@ -35,25 +35,32 @@ | |||
| 35 | 35 | //! local crypto rather than a request. `my_group_pubkey` and | |
| 36 | 36 | //! `my_group_fingerprint` are `async` by declaration only; neither body awaits. | |
| 37 | 37 | //! | |
| 38 | - | //! # What is not here, and it is absent rather than drawn dead | |
| 38 | + | //! # The admin writes are here, and they are queued rather than performed | |
| 39 | 39 | //! | |
| 40 | - | //! Creating a group, adding a member, removing one, and the invitation flow. | |
| 41 | - | //! Every one is a conversation with a server, which is the half that stays | |
| 42 | - | //! host-bound, the same arrangement as Email's OAuth handshake and Sync's | |
| 43 | - | //! Connect. A control that is drawn and does nothing is worse than one that is | |
| 44 | - | //! not drawn. | |
| 40 | + | //! Creating a group, adding a member and removing one each open a conversation | |
| 41 | + | //! with a server, and a handler cannot await. So the description says "queue | |
| 42 | + | //! this" and [`crate::group_queue`] drains it a minute later, which is the | |
| 43 | + | //! outbox's answer applied a second time under Max's ruling behind it | |
| 44 | + | //! (`a3c76a24`). | |
| 45 | 45 | //! | |
| 46 | - | //! That leaves the section honest rather than whole, and the line is worth | |
| 47 | - | //! keeping in view: **a person can see their groups and be added to one from | |
| 48 | - | //! here, and cannot create one or admit anybody.** The admin half is what | |
| 49 | - | //! remains of goingson `7f36900b`. | |
| 46 | + | //! What that buys, beyond making the controls sayable at all: the write survives | |
| 47 | + | //! being offline, a failure is a row with the server's reason on it sitting in | |
| 48 | + | //! this section rather than a toast that has gone, and `add_member` can be asked | |
| 49 | + | //! for before the master key is loaded, because the queue turns "not right now" | |
| 50 | + | //! into "once you unlock". | |
| 50 | 51 | //! | |
| 51 | - | //! One write does reach a group without being here: `group_create` writes its new | |
| 52 | - | //! group into the directory on the way past, so a group made through the command | |
| 53 | - | //! is nameable immediately rather than at the next cycle. | |
| 52 | + | //! The queue is shown. A control whose effect is a minute away has to be, or the | |
| 53 | + | //! section looks like it lost the request. | |
| 54 | + | //! | |
| 55 | + | //! # What is still not here | |
| 56 | + | //! | |
| 57 | + | //! The invitation flow, which is six commands and a state machine rather than | |
| 58 | + | //! one call. `group_create_invite` and its five siblings queue as cleanly as | |
| 59 | + | //! these three; what they need first is a decision about what the section shows | |
| 60 | + | //! for an invitation in flight, and that is not this screen's to make alone. | |
| 54 | 61 | ||
| 55 | - | use quasi_router::screen::{Field, Row, Tag}; | |
| 56 | - | use quasi_router::{Node, RouteError}; | |
| 62 | + | use quasi_router::screen::{Act, Choice, Field, Row, Tag}; | |
| 63 | + | use quasi_router::{Action, Node, RouteError}; | |
| 57 | 64 | use synckit_client::store::directory; | |
| 58 | 65 | ||
| 59 | 66 | use crate::state::AppState; | |
| @@ -164,6 +171,108 @@ | |||
| 164 | 171 | ] | |
| 165 | 172 | } | |
| 166 | 173 | ||
| 174 | + | /// What is queued and has not landed yet. | |
| 175 | + | /// | |
| 176 | + | /// Shown rather than hidden: a control whose effect is a minute away has to be | |
| 177 | + | /// visible, or the section looks like it lost the request. A failed row carries | |
| 178 | + | /// the server's own words, which is the whole reason a queue beats a toast here. | |
| 179 | + | fn queue(app: &AppState) -> Result<Vec<Node>, RouteError> { | |
| 180 | + | let queued = crate::group_queue::pending(app).map_err(RouteError::internal)?; | |
| 181 | + | if queued.is_empty() { | |
| 182 | + | return Ok(Vec::new()); | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | let rows = queued.into_iter().map(|op| { | |
| 186 | + | let mut row = Row::new(op.describe()); | |
| 187 | + | row = if op.done_at.is_some() { | |
| 188 | + | row.token(Tag::badge("Done").tone(makeover_layout::Tone::Success)) | |
| 189 | + | } else if let Some(error) = op.last_error.as_deref() { | |
| 190 | + | // The server's own words. A queue that reported "failed" and kept | |
| 191 | + | // the reason would be worse than the toast it replaced. | |
| 192 | + | row.token(Tag::badge("Failed").tone(makeover_layout::Tone::Danger)) | |
| 193 | + | .secondary(format!("{error} Tried {} times so far.", op.attempts)) | |
| 194 | + | } else { | |
| 195 | + | row.token(Tag::badge("Waiting")) | |
| 196 | + | }; | |
| 197 | + | ||
| 198 | + | if op.done_at.is_none() { | |
| 199 | + | row.menu = vec![ | |
| 200 | + | Act::new( | |
| 201 | + | "Cancel", | |
| 202 | + | Action::post(format!("/settings/sharing/queue/{}/cancel", op.id)), | |
| 203 | + | ) | |
| 204 | + | .tone(makeover_layout::Tone::Danger), | |
| 205 | + | ]; | |
| 206 | + | } | |
| 207 | + | row | |
| 208 | + | }); | |
| 209 | + | ||
| 210 | + | Ok(vec![ | |
| 211 | + | Node::section("Waiting to reach the server"), | |
| 212 | + | Node::list(rows), | |
| 213 | + | ]) | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | /// The controls that queue an admin write. | |
| 217 | + | /// | |
| 218 | + | /// Offered only where they can mean something. Creating a group needs sync | |
| 219 | + | /// configured; adding and removing need a group this user administers. A control | |
| 220 | + | /// drawn where it cannot act is the thing this section spent a commit not doing. | |
| 221 | + | fn admin_acts(app: &AppState, groups: &[directory::KnownGroup]) -> Vec<Node> { | |
| 222 | + | if app.read_recovering().is_none() { | |
| 223 | + | return Vec::new(); | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | let mut nodes = vec![ | |
| 227 | + | Node::section("Make a group"), | |
| 228 | + | Node::Form { | |
| 229 | + | action: Action::post("/settings/sharing/groups"), | |
| 230 | + | submit: "Queue it".to_owned(), | |
| 231 | + | fields: vec![ | |
| 232 | + | Field::new(makeover_layout::FieldKind::Text, "name", "Group name") | |
| 233 | + | .required() | |
| 234 | + | .hint("It is created within a minute, and waits if you are offline."), | |
| 235 | + | ], | |
| 236 | + | }, | |
| 237 | + | ]; | |
| 238 | + | ||
| 239 | + | let administered: Vec<&directory::KnownGroup> = | |
| 240 | + | groups.iter().filter(|group| group.is_admin).collect(); | |
| 241 | + | if administered.is_empty() { | |
| 242 | + | return nodes; | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | nodes.push(Node::section("Admit somebody")); | |
| 246 | + | nodes.push(Node::text( | |
| 247 | + | "Ask them for the public key their own Sharing section shows, and check its fingerprint with them out of band. The key admits them to nothing until you seal the group key to it, which is what this does.", | |
| 248 | + | )); | |
| 249 | + | nodes.push(Node::Form { | |
| 250 | + | action: Action::post("/settings/sharing/members"), | |
| 251 | + | submit: "Queue it".to_owned(), | |
| 252 | + | fields: vec![ | |
| 253 | + | Field::select( | |
| 254 | + | "group_id", | |
| 255 | + | "Group", | |
| 256 | + | administered | |
| 257 | + | .iter() | |
| 258 | + | .map(|group| Choice::new(group.id.to_string(), &group.name)) | |
| 259 | + | .collect(), | |
| 260 | + | ) | |
| 261 | + | .required(), | |
| 262 | + | Field::new(makeover_layout::FieldKind::Email, "email", "Their address").required(), | |
| 263 | + | Field::new( | |
| 264 | + | makeover_layout::FieldKind::Text, | |
| 265 | + | "pubkey", | |
| 266 | + | "Their public key", | |
| 267 | + | ) | |
| 268 | + | .required() | |
| 269 | + | .hint("The long value from their Sharing section, pasted whole."), | |
| 270 | + | ], | |
| 271 | + | }); | |
| 272 | + | ||
| 273 | + | nodes | |
| 274 | + | } | |
| 275 | + | ||
| 167 | 276 | /// The Sharing pane. | |
| 168 | 277 | pub(super) fn pane(app: &AppState) -> Result<Vec<Node>, RouteError> { | |
| 169 | 278 | let (groups, refreshed) = known(app)?; | |
| @@ -188,13 +297,123 @@ | |||
| 188 | 297 | } | |
| 189 | 298 | } | |
| 190 | 299 | ||
| 300 | + | nodes.extend(queue(app)?); | |
| 301 | + | nodes.extend(admin_acts(app, &groups)); | |
| 302 | + | ||
| 191 | 303 | nodes.push(Node::section("Your identity key")); | |
| 192 | 304 | nodes.extend(identity(app)); | |
| 193 | 305 | ||
| 194 | - | nodes.push(Node::text( | |
| 195 | - | "Creating a group, adding a member and removing one are done elsewhere: \ | |
| 196 | - | each is a conversation with a server, which this screen does not hold.", | |
| 197 | - | )); | |
| 198 | - | ||
| 199 | 306 | Ok(nodes) | |
| 200 | 307 | } | |
| 308 | + | ||
| 309 | + | /// Queue a new group. | |
| 310 | + | /// | |
| 311 | + | /// The whole of what the handler does is a local insert, which is the point: the | |
| 312 | + | /// conversation with the server happens in [`crate::group_queue`], a minute | |
| 313 | + | /// later, where there is a runtime. | |
| 314 | + | pub(super) fn create_group( | |
| 315 | + | app: &AppState, | |
| 316 | + | request: &quasi_router::Request, | |
| 317 | + | ) -> Result<&'static str, RouteError> { | |
| 318 | + | let name = request | |
| 319 | + | .payload | |
| 320 | + | .get("name") | |
| 321 | + | .unwrap_or_default() | |
| 322 | + | .trim() | |
| 323 | + | .to_owned(); | |
| 324 | + | if name.is_empty() { | |
| 325 | + | return Err(RouteError::conflict("A group needs a name.")); | |
| 326 | + | } | |
| 327 | + | ||
| 328 | + | crate::group_queue::enqueue( | |
| 329 | + | app, | |
| 330 | + | &crate::group_queue::QueuedOp { | |
| 331 | + | id: uuid::Uuid::new_v4().to_string(), | |
| 332 | + | kind: "create_group".to_owned(), | |
| 333 | + | group_id: None, | |
| 334 | + | name: Some(name), | |
| 335 | + | email: None, | |
| 336 | + | pubkey: None, | |
| 337 | + | member_user_id: None, | |
| 338 | + | attempts: 0, | |
| 339 | + | last_error: None, | |
| 340 | + | done_at: None, | |
| 341 | + | }, | |
| 342 | + | ) | |
| 343 | + | .map_err(RouteError::internal)?; | |
| 344 | + | ||
| 345 | + | Ok("Queued. The group is created within a minute, or when you are next online.") | |
| 346 | + | } | |
| 347 | + | ||
| 348 | + | /// Queue an add-member. | |
| 349 | + | /// | |
| 350 | + | /// The three values are checked for shape and not for truth: whether the key is | |
| 351 | + | /// really theirs is a question only the fingerprint they read out can answer, and | |
| 352 | + | /// whether the server accepts it is the drainer's to report. | |
| 353 | + | pub(super) fn add_member( | |
| 354 | + | app: &AppState, | |
| 355 | + | request: &quasi_router::Request, | |
| 356 | + | ) -> Result<&'static str, RouteError> { | |
| 357 | + | let field = |name: &str| { | |
| 358 | + | request | |
| 359 | + | .payload | |
| 360 | + | .get(name) | |
| 361 | + | .unwrap_or_default() | |
| 362 | + | .trim() | |
| 363 | + | .to_owned() | |
| 364 | + | }; | |
| 365 | + | let (group_id, email, pubkey) = (field("group_id"), field("email"), field("pubkey")); | |
| 366 | + | ||
| 367 | + | if group_id.is_empty() || email.is_empty() || pubkey.is_empty() { | |
| 368 | + | return Err(RouteError::conflict( | |
| 369 | + | "A group, an address and a public key are all needed.", | |
| 370 | + | )); | |
| 371 | + | } | |
| 372 | + | // Refused here rather than queued to fail later: a key that is not a key can | |
| 373 | + | // never be sealed to, and the person who pasted it is on screen now. | |
| 374 | + | if synckit_client::identity::IdentityPublicKey::fingerprint_of_base64(&pubkey).is_err() { | |
| 375 | + | return Err(RouteError::conflict( | |
| 376 | + | "That does not look like a public key.", | |
| 377 | + | )); | |
| 378 | + | } | |
| 379 | + | ||
| 380 | + | crate::group_queue::enqueue( | |
| 381 | + | app, | |
| 382 | + | &crate::group_queue::QueuedOp { | |
| 383 | + | id: uuid::Uuid::new_v4().to_string(), | |
| 384 | + | kind: "add_member".to_owned(), | |
| 385 | + | group_id: Some(group_id), | |
| 386 | + | name: None, | |
| 387 | + | email: Some(email), | |
| 388 | + | pubkey: Some(pubkey), | |
| 389 | + | member_user_id: None, | |
| 390 | + | attempts: 0, | |
| 391 | + | last_error: None, | |
| 392 | + | done_at: None, | |
| 393 | + | }, | |
| 394 | + | ) | |
| 395 | + | .map_err(RouteError::internal)?; | |
| 396 | + | ||
| 397 | + | Ok("Queued. They are admitted within a minute, once your encryption key is loaded.") | |
| 398 | + | } | |
| 399 | + | ||
| 400 | + | /// Take a queued write back out. | |
| 401 | + | pub(super) fn cancel( | |
| 402 | + | app: &AppState, | |
| 403 | + | request: &quasi_router::Request, | |
| 404 | + | ) -> Result<&'static str, RouteError> { | |
| 405 | + | let id = request | |
| 406 | + | .captures | |
| 407 | + | .get("id") | |
| 408 | + | .ok_or_else(|| RouteError::not_found("no queued action"))?; | |
| 409 | + | if crate::group_queue::cancel(app, id).map_err(RouteError::internal)? { | |
| 410 | + | Ok("Taken back out of the queue.") | |
| 411 | + | } else { | |
| 412 | + | // Either it never existed or the drainer got to it first, and the second | |
| 413 | + | // is the interesting one: a cancel that raced a success must not report | |
| 414 | + | // that it undid anything, because it did not. | |
| 415 | + | Err(RouteError::not_found( | |
| 416 | + | "That action is not in the queue any more.", | |
| 417 | + | )) | |
| 418 | + | } | |
| 419 | + | } |
| @@ -440,18 +440,180 @@ | |||
| 440 | 440 | assert!(pane.contains("Group list last updated"), "{pane}"); | |
| 441 | 441 | } | |
| 442 | 442 | ||
| 443 | - | /// Absent rather than drawn dead. A control that does nothing is worse than one | |
| 444 | - | /// that is not there, and the section says where the missing half went. | |
| 443 | + | /// Offered only where they can mean something. Without sync configured there is | |
| 444 | + | /// no client to reach a server with, so a create form would be a control that | |
| 445 | + | /// cannot act, which is the thing this section spends its design not doing. | |
| 445 | 446 | #[tokio::test] | |
| 446 | - | async fn the_admin_writes_are_absent_and_the_section_says_so() { | |
| 447 | + | async fn the_admin_forms_are_withheld_when_there_is_no_sync_to_use_them() { | |
| 447 | 448 | let state = state().await; | |
| 448 | 449 | known_group(&state, 1, "The Firm", true); | |
| 449 | 450 | let pane = sharing(&state); | |
| 451 | + | assert!(!pane.contains("Make a group"), "{pane}"); | |
| 452 | + | } | |
| 450 | 453 | ||
| 451 | - | for absent in ["Create group", "Add member", "Remove member"] { | |
| 452 | - | assert!(!pane.contains(absent), "{absent} is drawn: {pane}"); | |
| 453 | - | } | |
| 454 | - | assert!(pane.contains("conversation with a server"), "{pane}"); | |
| 454 | + | /// A described handler cannot await, so the act is a local insert and the | |
| 455 | + | /// drainer does the talking. What the user is told has to say so. | |
| 456 | + | #[tokio::test] | |
| 457 | + | async fn queueing_a_group_writes_a_row_and_says_it_is_not_immediate() { | |
| 458 | + | let state = state().await; | |
| 459 | + | let mut params = Params::new(); | |
| 460 | + | params.insert("name".to_owned(), "The Firm".to_owned()); | |
| 461 | + | let response = router() | |
| 462 | + | .handle( | |
| 463 | + | &state, | |
| 464 | + | Request::post("/settings/sharing/groups").sending(params), | |
| 465 | + | ) | |
| 466 | + | .expect("the route answers"); | |
| 467 | + | ||
| 468 | + | let queued = crate::group_queue::pending(&state).unwrap(); | |
| 469 | + | assert_eq!(queued.len(), 1); | |
| 470 | + | assert_eq!(queued[0].kind, "create_group"); | |
| 471 | + | assert_eq!(queued[0].name.as_deref(), Some("The Firm")); | |
| 472 | + | assert!(queued[0].done_at.is_none(), "nothing reached a server"); | |
| 473 | + | ||
| 474 | + | let said = format!("{:?}", response.notice); | |
| 475 | + | assert!(said.contains("within a minute"), "{said}"); | |
| 476 | + | } | |
| 477 | + | ||
| 478 | + | #[tokio::test] | |
| 479 | + | async fn a_group_with_no_name_is_refused_rather_than_queued() { | |
| 480 | + | let state = state().await; | |
| 481 | + | let mut params = Params::new(); | |
| 482 | + | params.insert("name".to_owned(), " ".to_owned()); | |
| 483 | + | let error = router() | |
| 484 | + | .handle( | |
| 485 | + | &state, | |
| 486 | + | Request::post("/settings/sharing/groups").sending(params), | |
| 487 | + | ) | |
| 488 | + | .expect_err("a nameless group is refused"); | |
| 489 | + | assert_eq!(error.class, quasi_router::Class::Conflict); | |
| 490 | + | assert!(crate::group_queue::pending(&state).unwrap().is_empty()); | |
| 491 | + | } | |
| 492 | + | ||
| 493 | + | /// A key that is not a key can never be sealed to, and the person who pasted it | |
| 494 | + | /// is on screen now. Refused here rather than queued to fail a minute later. | |
| 495 | + | #[tokio::test] | |
| 496 | + | async fn a_public_key_that_is_not_one_is_refused_before_it_is_queued() { | |
| 497 | + | let state = state().await; | |
| 498 | + | let mut params = Params::new(); | |
| 499 | + | params.insert( | |
| 500 | + | "group_id".to_owned(), | |
| 501 | + | "00000000-0000-0000-0000-000000000001".to_owned(), | |
| 502 | + | ); | |
| 503 | + | params.insert("email".to_owned(), "them@localhost".to_owned()); | |
| 504 | + | params.insert("pubkey".to_owned(), "not a key".to_owned()); | |
| 505 | + | ||
| 506 | + | let error = router() | |
| 507 | + | .handle( | |
| 508 | + | &state, | |
| 509 | + | Request::post("/settings/sharing/members").sending(params), | |
| 510 | + | ) | |
| 511 | + | .expect_err("a bad key is refused"); | |
| 512 | + | assert_eq!(error.class, quasi_router::Class::Conflict); | |
| 513 | + | assert!(crate::group_queue::pending(&state).unwrap().is_empty()); | |
| 514 | + | } | |
| 515 | + | ||
| 516 | + | /// A control whose effect is a minute away has to be visible, or the section | |
| 517 | + | /// looks like it lost the request. | |
| 518 | + | #[tokio::test] | |
| 519 | + | async fn the_queue_is_shown_with_what_each_row_will_do() { | |
| 520 | + | let state = state().await; | |
| 521 | + | crate::group_queue::enqueue( | |
| 522 | + | &state, | |
| 523 | + | &crate::group_queue::QueuedOp { | |
| 524 | + | id: "q1".to_owned(), | |
| 525 | + | kind: "create_group".to_owned(), | |
| 526 | + | group_id: None, | |
| 527 | + | name: Some("The Firm".to_owned()), | |
| 528 | + | email: None, | |
| 529 | + | pubkey: None, | |
| 530 | + | member_user_id: None, | |
| 531 | + | attempts: 0, | |
| 532 | + | last_error: None, | |
| 533 | + | done_at: None, | |
| 534 | + | }, | |
| 535 | + | ) | |
| 536 | + | .unwrap(); | |
| 537 | + | ||
| 538 | + | let pane = sharing(&state); | |
| 539 | + | assert!(pane.contains("Waiting to reach the server"), "{pane}"); | |
| 540 | + | assert!(pane.contains("Create the group The Firm"), "{pane}"); | |
| 541 | + | assert!(pane.contains("Waiting"), "{pane}"); | |
| 542 | + | } | |
| 543 | + | ||
| 544 | + | /// The whole reason a queue beats a toast here: the server's own words, still on | |
| 545 | + | /// screen, where the person who caused them will look. | |
| 546 | + | #[tokio::test] | |
| 547 | + | async fn a_failed_row_carries_the_reason_it_failed() { | |
| 548 | + | let state = state().await; | |
| 549 | + | crate::group_queue::enqueue( | |
| 550 | + | &state, | |
| 551 | + | &crate::group_queue::QueuedOp { | |
| 552 | + | id: "q1".to_owned(), | |
| 553 | + | kind: "add_member".to_owned(), | |
| 554 | + | group_id: Some("00000000-0000-0000-0000-000000000001".to_owned()), | |
| 555 | + | name: None, | |
| 556 | + | email: Some("them@localhost".to_owned()), | |
| 557 | + | pubkey: Some("k".to_owned()), | |
| 558 | + | member_user_id: None, | |
| 559 | + | attempts: 0, | |
| 560 | + | last_error: None, | |
| 561 | + | done_at: None, | |
| 562 | + | }, | |
| 563 | + | ) | |
| 564 | + | .unwrap(); | |
| 565 | + | state | |
| 566 | + | .db | |
| 567 | + | .conn() | |
| 568 | + | .unwrap() | |
| 569 | + | .execute( | |
| 570 | + | "UPDATE group_admin_queue SET attempts = 3, last_error = 'No such account.'", | |
| 571 | + | [], | |
| 572 | + | ) | |
| 573 | + | .unwrap(); | |
| 574 | + | ||
| 575 | + | let pane = sharing(&state); | |
| 576 | + | assert!(pane.contains("Add them@localhost to a group"), "{pane}"); | |
| 577 | + | assert!(pane.contains("No such account."), "{pane}"); | |
| 578 | + | assert!(pane.contains("Tried 3 times"), "{pane}"); | |
| 579 | + | } | |
| 580 | + | ||
| 581 | + | /// The way out of a row that will never succeed, which is why a failure is held | |
| 582 | + | /// rather than deleted: the person who queued it decides, not the drainer. | |
| 583 | + | #[tokio::test] | |
| 584 | + | async fn a_queued_action_can_be_taken_back_out() { | |
| 585 | + | let state = state().await; | |
| 586 | + | crate::group_queue::enqueue( | |
| 587 | + | &state, | |
| 588 | + | &crate::group_queue::QueuedOp { | |
| 589 | + | id: "q1".to_owned(), | |
| 590 | + | kind: "create_group".to_owned(), | |
| 591 | + | group_id: None, | |
| 592 | + | name: Some("Mistake".to_owned()), | |
| 593 | + | email: None, | |
| 594 | + | pubkey: None, | |
| 595 | + | member_user_id: None, | |
| 596 | + | attempts: 0, | |
| 597 | + | last_error: None, | |
| 598 | + | done_at: None, | |
| 599 | + | }, | |
| 600 | + | ) | |
| 601 | + | .unwrap(); | |
| 602 | + | ||
| 603 | + | router() | |
| 604 | + | .handle(&state, Request::post("/settings/sharing/queue/q1/cancel")) | |
| 605 | + | .expect("the route answers"); | |
| 606 | + | assert!(crate::group_queue::pending(&state).unwrap().is_empty()); | |
| 607 | + | } | |
| 608 | + | ||
| 609 | + | /// A cancel that raced the drainer must not claim it undid anything. | |
| 610 | + | #[tokio::test] | |
| 611 | + | async fn cancelling_something_that_already_went_is_a_not_found() { | |
| 612 | + | let state = state().await; | |
| 613 | + | let error = router() | |
| 614 | + | .handle(&state, Request::post("/settings/sharing/queue/gone/cancel")) | |
| 615 | + | .expect_err("there is nothing to cancel"); | |
| 616 | + | assert_eq!(error.class, quasi_router::Class::NotFound); | |
| 455 | 617 | } | |
| 456 | 618 | ||
| 457 | 619 | /// Being added to a group takes no write from the person being added, so the |
| @@ -1,0 +1,67 @@ | |||
| 1 | + | -- A queue for the group-admin writes, so a described screen can offer them. | |
| 2 | + | -- | |
| 3 | + | -- WHAT LED HERE, the same road the outbox took. `create_group`, `add_member` | |
| 4 | + | -- and `remove_member` each open a conversation with a server. A described route | |
| 5 | + | -- handler is synchronous, by `quasi_router`'s Decision 6, which exists so egui | |
| 6 | + | -- and a terminal do not need a runtime. So Settings > Sharing could show the | |
| 7 | + | -- groups and could not change them: goingson `7f36900b`, and the reads were | |
| 8 | + | -- only unblocked at all by synckit 0.9.0 writing the directory down. | |
| 9 | + | -- | |
| 10 | + | -- Queueing is a local write, so it can be described. This is migration 069's | |
| 11 | + | -- argument applied a second time, and Max's ruling behind that one ("have | |
| 12 | + | -- GoingsOn use an outbox model explicitly", task a3c76a24) is what makes it a | |
| 13 | + | -- pattern rather than a workaround repeated. | |
| 14 | + | -- | |
| 15 | + | -- WHY A QUEUE IS BETTER HERE TOO, and it is not the same reason as the mail | |
| 16 | + | -- one. There is no send-later value in creating a group. What there is: | |
| 17 | + | -- | |
| 18 | + | -- - an admin write survives being offline instead of failing at the instant | |
| 19 | + | -- somebody pressed the button, which matters more for these than for mail | |
| 20 | + | -- because they are rare and deliberate and nobody retries them by habit; | |
| 21 | + | -- - a failure is a row with a reason on it, sitting where the person who | |
| 22 | + | -- caused it will look, rather than a toast that has already gone; | |
| 23 | + | -- - `add_member` needs the master key loaded to seal the group key. A queue | |
| 24 | + | -- turns "you cannot do this right now" into "this will happen once you | |
| 25 | + | -- unlock", which is the honest behaviour and not one a button could offer. | |
| 26 | + | -- | |
| 27 | + | -- A SEPARATE TABLE, unlike the outbox. A queued message is a draft that has | |
| 28 | + | -- been committed to send, so it stays in `emails` and the outbox is a query. | |
| 29 | + | -- These have no such home: there is no local `groups` table this app owns, and | |
| 30 | + | -- `sync_groups` is synckit's copy of the server's answer, which this must not | |
| 31 | + | -- write into. So the queue is its own store and the directory stays a mirror. | |
| 32 | + | -- | |
| 33 | + | -- LOCAL-ONLY, and deliberately absent from `syncstore::manifest`. An intention | |
| 34 | + | -- to add somebody to a group is this device's, and replicating it would have a | |
| 35 | + | -- second device perform the same admin write again. The server is the authority | |
| 36 | + | -- on membership; this table is only ever a list of what has not reached it yet. | |
| 37 | + | CREATE TABLE IF NOT EXISTS group_admin_queue ( | |
| 38 | + | id TEXT PRIMARY KEY NOT NULL, | |
| 39 | + | user_id TEXT NOT NULL, | |
| 40 | + | -- 'create_group' | 'add_member' | 'remove_member'. Not a CHECK constraint: | |
| 41 | + | -- a kind this build does not know is held rather than refused, the same way | |
| 42 | + | -- an unknown table is on the sync side, so a downgrade does not destroy a | |
| 43 | + | -- queued intention it merely cannot perform. | |
| 44 | + | kind TEXT NOT NULL, | |
| 45 | + | -- The target group, NULL for 'create_group' which is what makes one. | |
| 46 | + | group_id TEXT, | |
| 47 | + | -- What the kind needs. `create_group` reads `name`; `add_member` reads | |
| 48 | + | -- `email` and `pubkey`; `remove_member` reads `member_user_id`. Columns | |
| 49 | + | -- rather than a JSON blob, because there are three kinds and eleven fields | |
| 50 | + | -- between them would be worse than four nullable ones. | |
| 51 | + | name TEXT, | |
| 52 | + | email TEXT, | |
| 53 | + | pubkey TEXT, | |
| 54 | + | member_user_id TEXT, | |
| 55 | + | queued_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), | |
| 56 | + | -- Counted rather than capped: the drainer backs off on this, and a row that | |
| 57 | + | -- can never succeed sits with its reason on it rather than being deleted. | |
| 58 | + | attempts INTEGER NOT NULL DEFAULT 0, | |
| 59 | + | last_error TEXT, | |
| 60 | + | -- Set when the server has accepted it. A done row is kept until the | |
| 61 | + | -- directory refresh that proves it landed, then swept, so the screen can | |
| 62 | + | -- say "created" for the moment between the two. | |
| 63 | + | done_at TEXT | |
| 64 | + | ); | |
| 65 | + | ||
| 66 | + | CREATE INDEX IF NOT EXISTS idx_group_admin_queue_pending | |
| 67 | + | ON group_admin_queue(user_id) WHERE done_at IS NULL; |
| @@ -1,0 +1,525 @@ | |||
| 1 | + | //! The group-admin queue, and the drainer that empties it. | |
| 2 | + | //! | |
| 3 | + | //! <!-- wiki: quasi-overview --> | |
| 4 | + | //! | |
| 5 | + | //! # Why the app has one | |
| 6 | + | //! | |
| 7 | + | //! `create_group`, `add_member` and `remove_member` each open a conversation | |
| 8 | + | //! with a server. A described route handler is synchronous, by | |
| 9 | + | //! `quasi_router`'s Decision 6, which exists so egui and a terminal need no | |
| 10 | + | //! runtime. So Settings > Sharing could show the groups and could not change | |
| 11 | + | //! them. | |
| 12 | + | //! | |
| 13 | + | //! Queueing is a local write, so it can be described. This is the outbox's | |
| 14 | + | //! argument applied a second time, and Max's ruling behind that one ("have | |
| 15 | + | //! GoingsOn use an outbox model explicitly", `a3c76a24`) is what makes it a | |
| 16 | + | //! pattern rather than a workaround repeated. See [`crate::outbox`], which this | |
| 17 | + | //! is deliberately shaped like: a reader who has read that one already knows how | |
| 18 | + | //! this starts, stops, backs off and survives a failing tick. | |
| 19 | + | //! | |
| 20 | + | //! # What a queue buys here, which is not what it bought for mail | |
| 21 | + | //! | |
| 22 | + | //! There is no send-later value in creating a group. What there is: | |
| 23 | + | //! | |
| 24 | + | //! - the write survives being offline, instead of failing at the instant | |
| 25 | + | //! somebody pressed the button. That matters more for these than for mail, | |
| 26 | + | //! because they are rare and deliberate and nobody retries them by habit; | |
| 27 | + | //! - a failure is a row with a reason on it, sitting in the section that caused | |
| 28 | + | //! it, rather than a toast that has already gone. For `add_member` that is the | |
| 29 | + | //! difference between "it did not work" and "it did not work, and here is | |
| 30 | + | //! what the server said"; | |
| 31 | + | //! - `add_member` needs the master key loaded to seal the group key to the new | |
| 32 | + | //! member. A queue turns "you cannot do this right now" into "this happens | |
| 33 | + | //! once you unlock", which is honest and is not something a button could | |
| 34 | + | //! offer. | |
| 35 | + | //! | |
| 36 | + | //! # Nothing here is described, and that is the design | |
| 37 | + | //! | |
| 38 | + | //! The description says "queue this". What drains the queue is not a screen and | |
| 39 | + | //! has no address. That division is why a queue answers the async problem rather | |
| 40 | + | //! than moving it: the async lives out here, where there has always been a | |
| 41 | + | //! runtime. | |
| 42 | + | //! | |
| 43 | + | //! # What a failed attempt does | |
| 44 | + | //! | |
| 45 | + | //! Stamps the error, counts the attempt, and leaves the row queued, backing off | |
| 46 | + | //! on the count exactly as the outbox does. A row that can never succeed sits | |
| 47 | + | //! with its reason on it rather than being deleted, because an intention to add | |
| 48 | + | //! somebody to a group that silently disappeared is worse than one still visible | |
| 49 | + | //! and failing. | |
| 50 | + | ||
| 51 | + | use std::sync::Arc; | |
| 52 | + | ||
| 53 | + | use tauri::Manager; | |
| 54 | + | use tokio::time::{Duration, interval}; | |
| 55 | + | use tokio_util::sync::CancellationToken; | |
| 56 | + | use tracing::{debug, error, info}; | |
| 57 | + | ||
| 58 | + | use crate::state::{AppState, DESKTOP_USER_ID}; | |
| 59 | + | ||
| 60 | + | /// How often the drainer wakes. | |
| 61 | + | /// | |
| 62 | + | /// Matches [`crate::outbox`], and for the same reason: it is what makes "queue" | |
| 63 | + | /// acceptable as the only way to act. A group is created within a minute of | |
| 64 | + | /// being asked for, which is not immediate and is not a wait anybody watches. | |
| 65 | + | const CHECK_INTERVAL_SECS: u64 = 60; | |
| 66 | + | ||
| 67 | + | /// One queued admin write. | |
| 68 | + | #[derive(Debug, Clone)] | |
| 69 | + | pub struct QueuedOp { | |
| 70 | + | pub id: String, | |
| 71 | + | pub kind: String, | |
| 72 | + | pub group_id: Option<String>, | |
| 73 | + | pub name: Option<String>, | |
| 74 | + | pub email: Option<String>, | |
| 75 | + | pub pubkey: Option<String>, | |
| 76 | + | pub member_user_id: Option<String>, | |
| 77 | + | pub attempts: i32, | |
| 78 | + | pub last_error: Option<String>, | |
| 79 | + | pub done_at: Option<String>, | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | impl QueuedOp { | |
| 83 | + | /// How this reads in the section that queued it. | |
| 84 | + | /// | |
| 85 | + | /// Written here rather than in the screen because the screen draws a row and | |
| 86 | + | /// this is what the row says: a person who queued three of these wants to | |
| 87 | + | /// know which is which, and the payload is the only thing that tells them | |
| 88 | + | /// apart. | |
| 89 | + | #[must_use] | |
| 90 | + | pub fn describe(&self) -> String { | |
| 91 | + | match self.kind.as_str() { | |
| 92 | + | "create_group" => format!( | |
| 93 | + | "Create the group {}", | |
| 94 | + | self.name.as_deref().unwrap_or("(unnamed)") | |
| 95 | + | ), | |
| 96 | + | "add_member" => format!( | |
| 97 | + | "Add {} to a group", | |
| 98 | + | self.email.as_deref().unwrap_or("(no address)") | |
| 99 | + | ), | |
| 100 | + | "remove_member" => "Remove a member from a group".to_owned(), | |
| 101 | + | // A kind this build does not know, held rather than refused. It can | |
| 102 | + | // only come from a newer build that queued it, and saying so is | |
| 103 | + | // better than drawing a blank row. | |
| 104 | + | other => format!("An action this version does not understand ({other})"), | |
| 105 | + | } | |
| 106 | + | } | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | /// Queue an admin write. The whole of what a described handler does. | |
| 110 | + | pub fn enqueue(state: &AppState, op: &QueuedOp) -> Result<(), String> { | |
| 111 | + | let conn = state.db.conn().map_err(|e| e.to_string())?; | |
| 112 | + | conn.execute( | |
| 113 | + | "INSERT INTO group_admin_queue \ | |
| 114 | + | (id, user_id, kind, group_id, name, email, pubkey, member_user_id) \ | |
| 115 | + | VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", | |
| 116 | + | rusqlite::params![ | |
| 117 | + | op.id, | |
| 118 | + | DESKTOP_USER_ID.to_string(), | |
| 119 | + | op.kind, | |
| 120 | + | op.group_id, | |
| 121 | + | op.name, | |
| 122 | + | op.email, | |
| 123 | + | op.pubkey, | |
| 124 | + | op.member_user_id, | |
| 125 | + | ], | |
| 126 | + | ) | |
| 127 | + | .map_err(|e| e.to_string())?; | |
| 128 | + | Ok(()) | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | /// Everything still waiting, oldest first, plus anything done that the directory | |
| 132 | + | /// has not caught up with yet. | |
| 133 | + | /// | |
| 134 | + | /// Both, because the moment between "the server accepted it" and "a sync brought | |
| 135 | + | /// the group back" is real and a screen that showed neither would look like it | |
| 136 | + | /// lost the request. | |
| 137 | + | pub fn pending(state: &AppState) -> Result<Vec<QueuedOp>, String> { | |
| 138 | + | let conn = state.db.conn().map_err(|e| e.to_string())?; | |
| 139 | + | let mut stmt = conn | |
| 140 | + | .prepare( | |
| 141 | + | "SELECT id, kind, group_id, name, email, pubkey, member_user_id, \ | |
| 142 | + | attempts, last_error, done_at \ | |
| 143 | + | FROM group_admin_queue WHERE user_id = ?1 ORDER BY queued_at", | |
| 144 | + | ) | |
| 145 | + | .map_err(|e| e.to_string())?; | |
| 146 | + | let rows = stmt | |
| 147 | + | .query_map(rusqlite::params![DESKTOP_USER_ID.to_string()], |row| { | |
| 148 | + | Ok(QueuedOp { | |
| 149 | + | id: row.get(0)?, | |
| 150 | + | kind: row.get(1)?, | |
| 151 | + | group_id: row.get(2)?, | |
| 152 | + | name: row.get(3)?, | |
| 153 | + | email: row.get(4)?, | |
| 154 | + | pubkey: row.get(5)?, | |
| 155 | + | member_user_id: row.get(6)?, | |
| 156 | + | attempts: row.get(7)?, | |
| 157 | + | last_error: row.get(8)?, | |
| 158 | + | done_at: row.get(9)?, | |
| 159 | + | }) | |
| 160 | + | }) | |
| 161 | + | .map_err(|e| e.to_string())? | |
| 162 | + | .collect::<Result<Vec<_>, _>>() | |
| 163 | + | .map_err(|e| e.to_string())?; | |
| 164 | + | Ok(rows) | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | /// Take a queued write back out. | |
| 168 | + | /// | |
| 169 | + | /// The way out of a row that will never succeed, and the reason a failure is | |
| 170 | + | /// held rather than deleted: the person who queued it decides, not the drainer. | |
| 171 | + | pub fn cancel(state: &AppState, id: &str) -> Result<bool, String> { | |
| 172 | + | let conn = state.db.conn().map_err(|e| e.to_string())?; | |
| 173 | + | let changed = conn | |
| 174 | + | .execute( | |
| 175 | + | "DELETE FROM group_admin_queue WHERE id = ?1 AND user_id = ?2 AND done_at IS NULL", | |
| 176 | + | rusqlite::params![id, DESKTOP_USER_ID.to_string()], | |
| 177 | + | ) | |
| 178 | + | .map_err(|e| e.to_string())?; | |
| 179 | + | Ok(changed > 0) | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | /// Sweep the rows the server has accepted and the directory has caught up with. | |
| 183 | + | /// | |
| 184 | + | /// A done row is kept until the group it made is in the directory, so the | |
| 185 | + | /// section can say "created" for the moment between the two. Once the directory | |
| 186 | + | /// has it, the row has nothing left to say. | |
| 187 | + | fn sweep_settled(state: &AppState) { | |
| 188 | + | let Ok(conn) = state.db.conn() else { return }; | |
| 189 | + | // `create_group` is the only kind whose landing is observable in the | |
| 190 | + | // directory. The other two change a member list, which is only fetched for | |
| 191 | + | // groups this user administers and may legitimately not have refreshed yet, | |
| 192 | + | // so they are swept on age instead. | |
| 193 | + | let _ = conn.execute( | |
| 194 | + | "DELETE FROM group_admin_queue \ | |
| 195 | + | WHERE done_at IS NOT NULL \ | |
| 196 | + | AND (kind = 'create_group' \ | |
| 197 | + | AND EXISTS (SELECT 1 FROM sync_groups WHERE name = group_admin_queue.name) \ | |
| 198 | + | OR done_at < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-1 hour'))", | |
| 199 | + | [], | |
| 200 | + | ); | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | fn record_failure(state: &AppState, id: &str, message: &str) { | |
| 204 | + | let Ok(conn) = state.db.conn() else { return }; | |
| 205 | + | if let Err(error) = conn.execute( | |
| 206 | + | "UPDATE group_admin_queue SET attempts = attempts + 1, last_error = ?2 WHERE id = ?1", | |
| 207 | + | rusqlite::params![id, message], | |
| 208 | + | ) { | |
| 209 | + | error!("Group queue: could not record the failure: {error}"); | |
| 210 | + | } | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | fn record_done(state: &AppState, id: &str) { | |
| 214 | + | let Ok(conn) = state.db.conn() else { return }; | |
| 215 | + | if let Err(error) = conn.execute( | |
| 216 | + | "UPDATE group_admin_queue SET done_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), \ | |
| 217 | + | last_error = NULL WHERE id = ?1", | |
| 218 | + | rusqlite::params![id], | |
| 219 | + | ) { | |
| 220 | + | error!("Group queue: could not record the success: {error}"); | |
| 221 | + | } | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | /// Start the drainer. Runs until cancelled. | |
| 225 | + | pub async fn start_group_queue_drainer(app: tauri::AppHandle, cancel: CancellationToken) { | |
| 226 | + | let mut check_interval = interval(Duration::from_secs(CHECK_INTERVAL_SECS)); | |
| 227 | + | let mut tick: u64 = 0; | |
| 228 | + | ||
| 229 | + | info!("Group queue drainer started (checking every {CHECK_INTERVAL_SECS} seconds)"); | |
| 230 | + | ||
| 231 | + | loop { | |
| 232 | + | tokio::select! { | |
| 233 | + | () = cancel.cancelled() => { | |
| 234 | + | info!("Group queue drainer shutting down"); | |
| 235 | + | break; | |
| 236 | + | } | |
| 237 | + | _ = check_interval.tick() => {} | |
| 238 | + | } | |
| 239 | + | tick = tick.wrapping_add(1); | |
| 240 | + | ||
| 241 | + | let Some(state) = app.try_state::<Arc<AppState>>() else { | |
| 242 | + | debug!("Group queue drainer: state not ready yet"); | |
| 243 | + | continue; | |
| 244 | + | }; | |
| 245 | + | let state: Arc<AppState> = state.inner().clone(); | |
| 246 | + | ||
| 247 | + | drain_once(&state, tick).await; | |
| 248 | + | } | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | /// One pass over the queue. | |
| 252 | + | /// | |
| 253 | + | /// Split from the loop so a test can run a pass without a Tauri handle or a | |
| 254 | + | /// minute of waiting, exactly as [`crate::outbox::drain_once`] is. | |
| 255 | + | pub async fn drain_once(state: &Arc<AppState>, tick: u64) { | |
| 256 | + | sweep_settled(state); | |
| 257 | + | ||
| 258 | + | let queued = match pending(state) { | |
| 259 | + | Ok(queued) => queued, | |
| 260 | + | Err(error) => { | |
| 261 | + | error!("Group queue drainer: could not read the queue: {error}"); | |
| 262 | + | return; | |
| 263 | + | } | |
| 264 | + | }; | |
| 265 | + | ||
| 266 | + | for op in queued { | |
| 267 | + | if op.done_at.is_some() { | |
| 268 | + | continue; | |
| 269 | + | } | |
| 270 | + | // The outbox's curve, shared rather than re-derived: a row that has | |
| 271 | + | // failed once is retried on the next wake, one that has failed six times | |
| 272 | + | // every half hour or so, and the cap stops a long failure from becoming | |
| 273 | + | // a silent drop wearing a backoff's clothes. | |
| 274 | + | if !crate::outbox::due_on_tick(op.attempts, tick) { | |
| 275 | + | continue; | |
| 276 | + | } | |
| 277 | + | ||
| 278 | + | // Not configured is not a failure to count: a device that has never | |
| 279 | + | // signed in will never succeed at any of these, and counting attempts | |
| 280 | + | // against it would back the row off to never while the reason stays the | |
| 281 | + | // same. Left untouched, so it goes as soon as sync is set up. | |
| 282 | + | let Some(client) = state.read_recovering() else { | |
| 283 | + | continue; | |
| 284 | + | }; | |
| 285 | + | ||
| 286 | + | // Opened per row rather than held across the loop: keeping a pooled | |
| 287 | + | // connection in hand across an await takes it out of the pool for the | |
| 288 | + | // length of a network call, which is the shape that starves a pool. | |
| 289 | + | let mut conn = state.db.conn().ok(); | |
| 290 | + | let outcome = perform(&client, &op, conn.as_deref_mut()).await; | |
| 291 | + | drop(conn); | |
| 292 | + | ||
| 293 | + | match outcome { | |
| 294 | + | Ok(()) => record_done(state, &op.id), | |
| 295 | + | Err(message) => record_failure(state, &op.id, &message), | |
| 296 | + | } | |
| 297 | + | } | |
| 298 | + | } | |
| 299 | + | ||
| 300 | + | /// Perform one queued write against the server. | |
| 301 | + | async fn perform( | |
| 302 | + | client: &synckit_client::SyncKitClient, | |
| 303 | + | op: &QueuedOp, | |
| 304 | + | conn: Option<&mut rusqlite::Connection>, | |
| 305 | + | ) -> Result<(), String> { | |
| 306 | + | let group = |raw: &Option<String>| -> Result<synckit_client::GroupId, String> { | |
| 307 | + | raw.as_deref() | |
| 308 | + | .ok_or_else(|| "No group on the queued action.".to_owned())? | |
| 309 | + | .parse::<uuid::Uuid>() | |
| 310 | + | .map(synckit_client::GroupId::new) | |
| 311 | + | .map_err(|_| "The queued action names a group id that will not parse.".to_owned()) | |
| 312 | + | }; | |
| 313 | + | ||
| 314 | + | match op.kind.as_str() { | |
| 315 | + | "create_group" => { | |
| 316 | + | let name = op | |
| 317 | + | .name | |
| 318 | + | .as_deref() | |
| 319 | + | .ok_or_else(|| "No name on the queued action.".to_owned())?; | |
| 320 | + | let group = client.create_group(name).await.map_err(|e| e.to_string())?; | |
| 321 | + | // Into the directory here, the same as `commands::group::group_create` | |
| 322 | + | // does on its own path: the group is real on the server, and this | |
| 323 | + | // device would otherwise not know its name until the next cycle, so | |
| 324 | + | // the section that queued it would show nothing landing. | |
| 325 | + | if let Some(conn) = conn { | |
| 326 | + | let known = synckit_client::store::directory::KnownGroup { | |
| 327 | + | id: group.id, | |
| 328 | + | name: group.name, | |
| 329 | + | gck_version: group.gck_version, | |
| 330 | + | is_admin: true, | |
| 331 | + | }; | |
| 332 | + | if let Err(error) = synckit_client::store::directory::add_group(conn, &known) { | |
| 333 | + | // Not a failure of the write: the group exists. The next | |
| 334 | + | // cycle writes the whole directory anyway. | |
| 335 | + | error!("Group queue: could not record the new group: {error}"); | |
| 336 | + | } | |
| 337 | + | } | |
| 338 | + | Ok(()) | |
| 339 | + | } | |
| 340 | + | "add_member" => { | |
| 341 | + | let email = op | |
| 342 | + | ||
| 343 | + | .as_deref() | |
| 344 | + | .ok_or_else(|| "No address on the queued action.".to_owned())?; | |
| 345 | + | let pubkey = op | |
| 346 | + | .pubkey | |
| 347 | + | .as_deref() | |
| 348 | + | .ok_or_else(|| "No public key on the queued action.".to_owned())?; | |
| 349 | + | client | |
| 350 | + | .add_member(group(&op.group_id)?, email, pubkey) | |
| 351 | + | .await | |
| 352 | + | .map_err(|e| e.to_string()) | |
| 353 | + | } | |
| 354 | + | "remove_member" => { | |
| 355 | + | let member = op | |
| 356 | + | .member_user_id | |
| 357 | + | .as_deref() | |
| 358 | + | .ok_or_else(|| "No member on the queued action.".to_owned())? | |
| 359 | + | .parse::<uuid::Uuid>() | |
| 360 | + | .map(synckit_client::UserId::new) | |
| 361 | + | .map_err(|_| "The queued action names a user id that will not parse.".to_owned())?; | |
| 362 | + | client | |
| 363 | + | .remove_member(group(&op.group_id)?, member) | |
| 364 | + | .await | |
| 365 | + | .map_err(|e| e.to_string()) | |
| 366 | + | } | |
| 367 | + | // Held rather than refused, and the message says why so it does not read | |
| 368 | + | // as a bug. Only a newer build could have written it. | |
| 369 | + | other => Err(format!( | |
| 370 | + | "This version does not know how to perform `{other}`. It is kept, not lost." | |
| 371 | + | )), | |
| 372 | + | } | |
| 373 | + | } | |
| 374 | + | ||
| 375 | + | #[cfg(test)] | |
| 376 | + | mod tests { | |
| 377 | + | use super::*; | |
| 378 | + | ||
| 379 | + | async fn state() -> Arc<AppState> { | |
| 380 | + | let (state, _) = crate::test_utils::setup_test_state().await; | |
| 381 | + | state | |
| 382 | + | } | |
| 383 | + | ||
| 384 | + | fn op(kind: &str, id: &str) -> QueuedOp { | |
| 385 | + | QueuedOp { | |
| 386 | + | id: id.to_owned(), | |
| 387 | + | kind: kind.to_owned(), | |
| 388 | + | group_id: Some("00000000-0000-0000-0000-000000000001".to_owned()), | |
| 389 | + | name: Some("The Firm".to_owned()), | |
| 390 | + | email: Some("them@localhost".to_owned()), | |
| 391 | + | pubkey: Some("k".to_owned()), | |
| 392 | + | member_user_id: Some("00000000-0000-0000-0000-000000000002".to_owned()), | |
| 393 | + | attempts: 0, | |
| 394 | + | last_error: None, | |
| 395 | + | done_at: None, | |
| 396 | + | } | |
| 397 | + | } | |
| 398 | + | ||
| 399 | + | /// A person who queued three of these wants to know which is which, and the | |
| 400 | + | /// payload is the only thing that tells them apart. | |
| 401 | + | #[test] | |
| 402 | + | fn each_kind_says_what_it_will_do() { | |
| 403 | + | assert_eq!( | |
| 404 | + | op("create_group", "a").describe(), | |
| 405 | + | "Create the group The Firm" | |
| 406 | + | ); | |
| 407 | + | assert_eq!( | |
| 408 | + | op("add_member", "a").describe(), | |
| 409 | + | "Add them@localhost to a group" | |
| 410 | + | ); | |
| 411 | + | assert_eq!( | |
| 412 | + | op("remove_member", "a").describe(), | |
| 413 | + | "Remove a member from a group" | |
| 414 | + | ); | |
| 415 | + | } | |
| 416 | + | ||
| 417 | + | /// Only a newer build could have written it, so saying so beats a blank row | |
| 418 | + | /// and beats refusing to draw the queue at all. | |
| 419 | + | #[test] | |
| 420 | + | fn a_kind_from_a_newer_build_still_reads_as_something() { | |
| 421 | + | let described = op("invite_member", "a").describe(); | |
| 422 | + | assert!(described.contains("does not understand"), "{described}"); | |
| 423 | + | assert!(described.contains("invite_member"), "{described}"); | |
| 424 | + | } | |
| 425 | + | ||
| 426 | + | #[tokio::test] | |
| 427 | + | async fn a_queued_action_comes_back_out_in_the_order_it_went_in() { | |
| 428 | + | let state = state().await; | |
| 429 | + | enqueue(&state, &op("create_group", "first")).unwrap(); | |
| 430 | + | enqueue(&state, &op("add_member", "second")).unwrap(); | |
| 431 | + | ||
| 432 | + | let queued = pending(&state).unwrap(); | |
| 433 | + | assert_eq!(queued.len(), 2); | |
| 434 | + | assert_eq!(queued[0].id, "first"); | |
| 435 | + | assert_eq!(queued[1].id, "second"); | |
| 436 | + | } | |
| 437 | + | ||
| 438 | + | /// A device that has never signed in will never succeed at any of these, and | |
| 439 | + | /// counting attempts against it would back the row off to never while the | |
| 440 | + | /// reason stays the same. It has to go as soon as sync is set up. | |
| 441 | + | #[tokio::test] | |
| 442 | + | async fn a_pass_with_no_client_leaves_the_row_untouched() { | |
| 443 | + | let state = state().await; | |
| 444 | + | enqueue(&state, &op("create_group", "waiting")).unwrap(); | |
| 445 | + | ||
| 446 | + | drain_once(&state, 1).await; | |
| 447 | + | ||
| 448 | + | let queued = pending(&state).unwrap(); | |
| 449 | + | assert_eq!(queued.len(), 1, "still queued"); | |
| 450 | + | assert_eq!(queued[0].attempts, 0, "and not counted against"); | |
| 451 | + | assert!(queued[0].last_error.is_none()); | |
| 452 | + | } | |
| 453 | + | ||
| 454 | + | #[tokio::test] | |
| 455 | + | async fn cancelling_takes_it_out_and_says_whether_it_did() { | |
| 456 | + | let state = state().await; | |
| 457 | + | enqueue(&state, &op("create_group", "mistake")).unwrap(); | |
| 458 | + | ||
| 459 | + | assert!(cancel(&state, "mistake").unwrap()); | |
| 460 | + | assert!(pending(&state).unwrap().is_empty()); | |
| 461 | + | assert!(!cancel(&state, "mistake").unwrap(), "twice is not a lie"); | |
| 462 | + | } | |
| 463 | + | ||
| 464 | + | /// A cancel that raced the drainer must not report that it undid anything, | |
| 465 | + | /// because it did not: the server already has it. | |
| 466 | + | #[tokio::test] | |
| 467 | + | async fn a_row_the_server_accepted_cannot_be_cancelled() { | |
| 468 | + | let state = state().await; | |
| 469 | + | enqueue(&state, &op("create_group", "gone")).unwrap(); | |
| 470 | + | record_done(&state, "gone"); | |
| 471 | + | ||
| 472 | + | assert!(!cancel(&state, "gone").unwrap()); | |
| 473 | + | let queued = pending(&state).unwrap(); | |
| 474 | + | assert_eq!(queued.len(), 1); | |
| 475 | + | assert!(queued[0].done_at.is_some()); | |
| 476 | + | } | |
| 477 | + | ||
| 478 | + | /// The moment between "the server accepted it" and "a sync brought the group | |
| 479 | + | /// back" is real, and a section that showed neither would look like it lost | |
| 480 | + | /// the request. | |
| 481 | + | #[tokio::test] | |
| 482 | + | async fn a_done_row_survives_until_the_directory_catches_up() { | |
| 483 | + | let state = state().await; | |
| 484 | + | let conn = state.db.conn().unwrap(); | |
| 485 | + | synckit_client::store::directory::ensure_tables(&conn).unwrap(); | |
| 486 | + | drop(conn); | |
| 487 | + | ||
| 488 | + | enqueue(&state, &op("create_group", "landed")).unwrap(); | |
| 489 | + | record_done(&state, "landed"); | |
| 490 | + | ||
| 491 | + | drain_once(&state, 1).await; | |
| 492 | + | assert_eq!(pending(&state).unwrap().len(), 1, "the directory has not"); | |
| 493 | + | ||
| 494 | + | let mut conn = state.db.conn().unwrap(); | |
| 495 | + | synckit_client::store::directory::add_group( | |
| 496 | + | &mut conn, | |
| 497 | + | &synckit_client::store::directory::KnownGroup { | |
| 498 | + | id: synckit_client::GroupId::new(uuid::Uuid::from_u128(1)), | |
| 499 | + | name: "The Firm".to_owned(), | |
| 500 | + | gck_version: 1, |
Lines truncated