Files
Skald-Circle/crates/plugin-mobile-connector/src/notifier.rs
T
2026-07-10 15:02:09 +01:00

153 lines
5.6 KiB
Rust

//! Delayed push notifier.
//!
//! The mobile push for an Inbox item is only valuable when the user is *away*
//! from the computer. When they're sitting at the chat, every approval /
//! clarification would otherwise fire an instant — and pointless — phone
//! notification, since they'll answer on the computer within seconds.
//!
//! `DelayedNotifier` debounces that: when a request enters the Inbox it starts a
//! timer; only if the request is still unresolved after `delay` does it push
//! (`broadcast_inbox`). If the user resolves it on the computer first, the timer
//! is cancelled and **no** push is sent. Once a push *has* gone out, the eventual
//! resolution is broadcast so the phone clears the item.
//!
//! Elicitations are the exception: they live only in the Inbox (never inline in
//! the chat), so there is no computer-side answer to debounce against and they
//! are pushed immediately regardless of `delay`.
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::Mutex;
use tokio::time::sleep;
use tokio_util::sync::CancellationToken;
use tracing::warn;
use crate::PLUGIN_ID;
use crate::app::RelayApp;
/// Which Inbox manager a `request_id` belongs to. Approvals and clarifications
/// use independent atomic counters, so the id alone is not unique.
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub enum Kind {
Approval,
Clarification,
Elicitation,
}
/// `(kind, request_id)` — unique across both Inbox managers.
type Key = (Kind, i64);
#[derive(Default)]
struct State {
/// Requested, timer armed, push not yet sent.
pending: HashMap<Key, CancellationToken>,
/// Push already sent, awaiting the resolution that clears the phone item.
notified: HashSet<Key>,
}
/// Debounces Inbox pushes to the phone. Cheap to clone (`Arc` inside).
pub struct DelayedNotifier {
app: Arc<RelayApp>,
delay: Duration,
state: Mutex<State>,
}
impl DelayedNotifier {
pub fn new(app: Arc<RelayApp>, delay: Duration) -> Arc<Self> {
Arc::new(Self { app, delay, state: Mutex::new(State::default()) })
}
/// A request entered the Inbox: arm a timer. If `delay` elapses before a
/// matching `on_resolved`, push the Inbox to the phone.
///
/// Exception: elicitations (e.g. MCP password prompts) live *only* in the
/// Inbox — never inline in the chat — so there is no computer-side answer to
/// debounce against. Waiting `delay` would just delay a push that is always
/// warranted, so they are pushed immediately (marked *notified* so the
/// eventual `on_resolved` refreshes the phone to clear the item).
pub async fn on_requested(self: &Arc<Self>, key: Key) {
if key.0 == Kind::Elicitation {
let newly_notified = {
let mut st = self.state.lock().await;
// Drop any stale armed timer, then mark notified.
if let Some(old) = st.pending.remove(&key) {
old.cancel();
}
st.notified.insert(key)
};
if newly_notified {
if let Err(e) = self.app.broadcast_inbox().await {
warn!(plugin = PLUGIN_ID, error = %e, "immediate elicitation push failed");
}
}
return;
}
let token = CancellationToken::new();
{
let mut st = self.state.lock().await;
// Re-arming an already-pending key: cancel the stale timer first.
if let Some(old) = st.pending.insert(key, token.clone()) {
old.cancel();
}
}
let this = Arc::clone(self);
let delay = self.delay;
tokio::spawn(async move {
tokio::select! {
_ = token.cancelled() => {} // resolved within the window — send nothing
_ = sleep(delay) => {
// Promote pending → notified under the lock, then push.
let still_armed = {
let mut st = this.state.lock().await;
if st.pending.remove(&key).is_some() {
st.notified.insert(key);
true
} else {
false
}
};
if still_armed {
if let Err(e) = this.app.broadcast_inbox().await {
warn!(plugin = PLUGIN_ID, error = %e, "delayed inbox push failed");
}
}
}
}
});
}
/// A request was resolved (approved / rejected / answered). Suppress the push
/// if it was still pending; otherwise refresh the phone so it clears the item.
pub async fn on_resolved(&self, key: Key) {
let broadcast = {
let mut st = self.state.lock().await;
if let Some(token) = st.pending.remove(&key) {
token.cancel(); // push hadn't fired yet — suppress entirely
false
} else {
// Push already went out, or key untracked: refresh the snapshot.
st.notified.remove(&key);
true
}
};
if broadcast {
if let Err(e) = self.app.broadcast_inbox().await {
warn!(plugin = PLUGIN_ID, error = %e, "inbox broadcast failed");
}
}
}
/// Cancel every armed timer (called on plugin stop).
pub async fn cancel_all(&self) {
let mut st = self.state.lock().await;
for (_, token) in st.pending.drain() {
token.cancel();
}
st.notified.clear();
}
}