Files
Skald-Circle/crates/plugin-mobile-connector/src/events.rs
T
Daniele 5980bdb5b9
Nightly Build / build (push) Successful in 8m10s
feat(users): unlock and start unencrypted users at boot
A login is what makes an *encrypted* database readable; for an
unencrypted one it gated nothing but the runtime — the file has no key
and is already readable by this process. The cost was user-visible and
read as a bug: after every restart the Telegram bot answered "your
account is locked, log in via the web app", cron fired nothing and no
background agent ran, until a human opened the SPA.

`Skald::new` now calls `UserManager::unlock_all_unencrypted`, which
registers the pools exactly as a login would and refuses an encrypted or
inactive user. Unlocking alone only makes the data readable, so
`wiring::spawn_unlocked_user_runtimes` then builds a `UserContext` for
each — cron, the notify queue, the hub and the per-user MCP runtime all
hang off it. That build is a background supervisor task rather than part
of `new()`: it starts every member's MCP servers inside their container,
and the HTTP listener must not wait behind it. The same two steps run
per user off the lifecycle bus (`UserCreated`,
`UserActiveChanged{active:true}`, after the container `ensure`), so a
member created at runtime does not wait for the next restart.

Two boundaries stay where they were. Authentication is untouched:
`SessionStore` sits above `UserManager`, so no HTTP request
authenticates as anyone because of this. And the auto-unlock is
deliberately not on a lazy path such as `Skald::user_context` —
`revoke_user_runtime` locks a pool synchronously and expects nothing to
re-open it, so the writers of that map stay boot, login and the bus.

`open_db` and the two unencrypted openers now share `register_unlocked`
and `open_unencrypted_file`; `open_unencrypted` (the supervision path)
still does not register its pool.
2026-08-10 12:16:33 +01:00

148 lines
6.1 KiB
Rust

//! Per-user event forwarders (blueprint §13), the mobile analogue of the Telegram
//! `ensure_forwarder`.
//!
//! One forwarder per unlocked user with bound devices subscribes to that user's
//! event stream ([`UserChannelHandle::subscribe`]) and routes the six Inbox
//! lifecycle events through the user's [`DelayedNotifier`], which decides whether
//! and when to push the Inbox to their phones.
//!
//! Unlike the Telegram forwarder there is **no `source` filter**: an approval
//! raised in the user's *web* session must still reach their phone. The relevant
//! events (`{Approval,Clarification,Elicitation}{Requested,Resolved}`) are
//! Inbox-scoped and carry request ids from the user's own pool, so no cross-user
//! collision is possible.
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::broadcast;
use tokio_util::sync::CancellationToken;
use tracing::{info, warn};
use core_api::events::ServerEvent;
use core_api::user_channel::UserChannelHandle;
use crate::PLUGIN_ID;
use crate::app::RelayApp;
use crate::notifier::{DelayedNotifier, Kind};
/// How often the reconcile loop picks up bound users who have unlocked since the
/// last pass. Pushes are best-effort, so a coarse cadence is fine.
const RECONCILE_INTERVAL: Duration = Duration::from_secs(60);
/// Periodically (re)spawns forwarders for bound + unlocked users.
///
/// This is load-bearing, not a nicety: an encrypted pool is locked at boot (§9),
/// so the eager start-time pass skips those users. They unlock later via
/// web/phone login, and there is no "user unlocked" system event to hook (an
/// unencrypted one is already unlocked by then). Without this loop a user
/// whose phone stays backgrounded would never get a forwarder — so no Inbox push
/// would ever be armed for them. `ensure_forwarder` dedups, so this is idempotent
/// and cheap (locked users resolve to `None` and are skipped without a build).
pub(crate) async fn reconcile_loop(app: Arc<RelayApp>) {
let cancel = app.cancel();
let mut ticker = tokio::time::interval(RECONCILE_INTERVAL);
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
loop {
tokio::select! {
_ = cancel.cancelled() => return,
_ = ticker.tick() => spawn_forwarders_for_bound_users(&app).await,
}
}
}
/// Spawns forwarders for every bound user whose context is already unlocked.
/// Called at plugin start, on binding changes, and by the reconcile loop. Users
/// who unlock later also get their forwarder spawned lazily on device activity.
pub(crate) async fn spawn_forwarders_for_bound_users(app: &Arc<RelayApp>) {
let user_ids = app.bindings.read().await.bound_user_ids();
for user_id in user_ids {
if let Some(handle) = app.user_channel.resolve_user(&user_id).await {
ensure_forwarder(Arc::clone(app), user_id, handle).await;
}
}
}
/// Spawns a per-user forwarder if one is not already running for `user_id`.
pub(crate) async fn ensure_forwarder(
app: Arc<RelayApp>,
user_id: String,
handle: Arc<dyn UserChannelHandle>,
) {
{
let mut forwarders = app.forwarders.lock().await;
if !forwarders.insert(user_id.clone()) {
return;
}
}
let cancel = app.cancel();
info!(plugin = PLUGIN_ID, user_id = %user_id, "spawning per-user forwarder");
tokio::spawn(user_forwarder(app, user_id, handle, cancel));
}
/// One forwarder per unlocked user. Subscribes to the user's event stream and
/// drives their `DelayedNotifier`. Exits when the stream closes (user context
/// dropped at restart / lock) or the plugin is cancelled — self-cleaning.
async fn user_forwarder(
app: Arc<RelayApp>,
user_id: String,
handle: Arc<dyn UserChannelHandle>,
cancel: CancellationToken,
) {
// Get or create this user's debounced notifier.
let notifier: Arc<DelayedNotifier> = {
let mut notifiers = app.notifiers.lock().await;
notifiers
.entry(user_id.clone())
.or_insert_with(|| DelayedNotifier::new(Arc::downgrade(&app), user_id.clone(), app.notify_delay))
.clone()
};
let mut rx = handle.subscribe();
loop {
let event: ServerEvent = tokio::select! {
_ = cancel.cancelled() => break,
result = rx.recv() => match result {
Ok(ge) => ge.event,
Err(broadcast::error::RecvError::Lagged(n)) => {
warn!(plugin = PLUGIN_ID, user_id = %user_id, skipped = n, "forwarder lagged");
continue;
}
Err(broadcast::error::RecvError::Closed) => {
info!(plugin = PLUGIN_ID, user_id = %user_id, "forwarder — user context closed, exiting");
break;
}
},
};
// `*Requested` arms a delayed push; `*Resolved` cancels it (or refreshes
// the phone if the push already went out). Any other event is ignored.
match event {
ServerEvent::ApprovalRequested { request_id, .. } => {
notifier.on_requested((Kind::Approval, request_id)).await;
}
ServerEvent::ApprovalResolved { request_id, .. } => {
notifier.on_resolved((Kind::Approval, request_id)).await;
}
ServerEvent::ClarificationRequested { request_id, .. } => {
notifier.on_requested((Kind::Clarification, request_id)).await;
}
ServerEvent::ClarificationResolved { request_id } => {
notifier.on_resolved((Kind::Clarification, request_id)).await;
}
ServerEvent::ElicitationRequested { request_id, .. } => {
notifier.on_requested((Kind::Elicitation, request_id)).await;
}
ServerEvent::ElicitationResolved { request_id } => {
notifier.on_resolved((Kind::Elicitation, request_id)).await;
}
_ => {}
}
}
// Clean up so a later reconnect can respawn.
app.forwarders.lock().await.remove(&user_id);
app.notifiers.lock().await.remove(&user_id);
info!(plugin = PLUGIN_ID, user_id = %user_id, "forwarder exited");
}