Nightly Build / build (push) Successful in 8m10s
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.
148 lines
6.1 KiB
Rust
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");
|
|
}
|