Nightly Build / build (push) Successful in 6m58s
Two variants of the same defect: an admin took away access and the running system kept granting it. Deactivating or deleting a user only stopped the *next* login. `login` checks the active flag, but `require_auth` maps token -> id without re-reading the row, so an already-open session kept working over a pool whose key was still in RAM. There was no way to stop one user either: the per-user cron, hub and MCP loops all observed the *instance* shutdown token. They now take a per-user child token stored on UserContext, and Skald::revoke_user_runtime tears a single user down in a load-bearing order — revoke every session, evict and cancel the context, then lock the database, so nothing is left querying a pool we are about to close. Revoking a security group had a durable version of the same problem. The group is validated when selected and then persisted on chat_sessions.run_context, which was replayed verbatim on every later load — so a group removed from a role stayed in force on sessions that already had it, across restarts. get_or_create_handler now runs the stored value through run_context::reconcile_group_for_user, making it advisory: every load re-checks it, whether or not anyone announced the change. The degrade target is the role's default group, never None: a context with no group resolves to the catch-all `default`, whose rules are the fallback tier under every other group, so clearing widens rather than narrows. The reconcile touches only security_group, so a project session's server-built project_root and system_prompt survive a permissions edit, and it leaves the stored group alone when the role cannot be resolved — guessing on a transient error could only widen. role_default_run_context moves into the core seam so the group a session starts on and the group it falls back to cannot drift apart. Both fixes run synchronously in their handlers. Only the container half of deactivation rides the bus, as the new UserActiveChanged event: a lossy 64-slot broadcast whose contract is "settles at the next login" is the wrong transport for taking access away. Tests: revoke_user drops all of one user's sessions and nobody else's, and is a no-op when nothing is live; the reconcile degrades a revoked group to the role default, keeps an allowed one, preserves project fields in both directions, never touches an admin, and stays put when the role is unresolvable. Not exercised at runtime: no Docker/live-server run, so the end-to-end paths (deactivating a logged-in user, editing a role with sessions open) are covered by unit tests only.
163 lines
7.3 KiB
Rust
163 lines
7.3 KiB
Rust
//! Post-construction wiring: the `OnceLock` cycle-breakers and the background-task
|
|
//! spawns, each concentrated in one readable place instead of being scattered
|
|
//! through the constructor.
|
|
//!
|
|
//! Owner-bound background loops (cron, session-cancel, ticket-listener, tic) have
|
|
//! moved per-user into `UserContextFactory::build`. What remains here are the
|
|
//! instance-wide tasks: LLM-log cleanup on the registry pool, MCP server
|
|
//! initialization, and the user-lifecycle reconciler (which needs the finished
|
|
//! `Arc<Skald>` and is therefore spawned separately, after construction).
|
|
|
|
use std::sync::Arc;
|
|
|
|
use core_api::system_bus::{RecvError, SystemEvent};
|
|
use tracing::{info, warn};
|
|
|
|
use crate::config::CoreConfig;
|
|
use crate::elicitation::ElicitationBridge;
|
|
|
|
use super::bundles::{Conversation, Integrations, Interaction, Tasks};
|
|
use super::runtime::Runtime;
|
|
|
|
/// Resolves the construction cycles (`cron ↔ session ↔ hub`, `ticket → cron`,
|
|
/// `mcp → elicitation`) via the managers' `OnceLock` setters.
|
|
///
|
|
/// These wire the **global** bundles, which are transitional and will be removed
|
|
/// once all call-sites are per-user (Phase 6). They remain constructed so any
|
|
/// not-yet-migrated accessor does not panic on a `None` OnceLock.
|
|
pub(super) fn wire(
|
|
tasks: &Tasks,
|
|
conversation: &Conversation,
|
|
integrations: &Integrations,
|
|
interaction: &Interaction,
|
|
) {
|
|
tasks.cron.set_session(Arc::clone(&conversation.manager));
|
|
tasks.cron.set_hub(Arc::clone(&conversation.chat_hub));
|
|
tasks.cron.set_self_arc(Arc::clone(&tasks.cron));
|
|
conversation.chat_hub.set_task_mgr(Arc::clone(&tasks.cron));
|
|
integrations.mcp.set_elicitation_handler(ElicitationBridge::new(Arc::clone(&interaction.elicitation)));
|
|
info!("ChatHub initialised");
|
|
}
|
|
|
|
/// Spawns the instance-wide background tasks.
|
|
///
|
|
/// Owner-bound loops (cron, session-cancel, ticket-listener, tic) are **not**
|
|
/// spawned here — they run per-user inside `UserContext`. Session cancellation is
|
|
/// handled directly by the API handlers (which have `AuthUser` and resolve the
|
|
/// per-user context). TIC is deferred until connectors return (§13).
|
|
pub(super) fn spawn_background(
|
|
rt: &Runtime,
|
|
_tasks: &Tasks,
|
|
_conversation: &Conversation,
|
|
integrations: &Integrations,
|
|
config: &CoreConfig,
|
|
) {
|
|
// LLM request-log retention/cleanup — first run 1 min after startup, then 12h.
|
|
if let Some(cfg) = config.llm.requests_log.clone().filter(|r| r.enabled) {
|
|
rt.supervisor.adopt_one(
|
|
"llm-log-cleanup",
|
|
crate::db::llm_requests::cleanup::spawn(
|
|
Arc::clone(&rt.db),
|
|
cfg,
|
|
rt.shutdown_token.clone(),
|
|
),
|
|
);
|
|
}
|
|
|
|
// MCP servers connect in the background. `initialize()` does not itself observe
|
|
// the cancellation token, so race it against shutdown: on cancel the task exits
|
|
// promptly (dropping the in-flight connection attempts) instead of blocking the
|
|
// shutdown join until the deadline.
|
|
{
|
|
let mcp = Arc::clone(&integrations.mcp);
|
|
let sd = rt.shutdown_token.clone();
|
|
rt.supervisor.spawn("mcp-init", async move {
|
|
tokio::select! {
|
|
_ = sd.cancelled() => {}
|
|
_ = mcp.initialize() => {}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
/// Spawns the **user-lifecycle reconciler** — the single subscriber that turns
|
|
/// user and membership events into container work (blueprint §6).
|
|
///
|
|
/// Producers (the Users admin page, the setup wizard, the shared-folder and
|
|
/// project membership endpoints) only announce *what changed*; none of them
|
|
/// reaches into [`ContainerManager`](crate::container::ContainerManager). That
|
|
/// is the point of routing this through the bus rather than calling the manager
|
|
/// from each handler: a future endpoint that grants membership cannot forget to
|
|
/// remount, because remounting was never its job.
|
|
///
|
|
/// Every reaction is **best-effort by contract**: the row is already committed
|
|
/// when the event fires, so a Docker hiccup is logged, never surfaced to the
|
|
/// caller — the state settles at the user's next login or at boot
|
|
/// reconciliation. Events are handled **sequentially**, which also serialises
|
|
/// concurrent `docker` operations on the same container.
|
|
///
|
|
/// Spawned after `Skald` is fully built (like `set_skald`) because
|
|
/// [`Skald::refresh_user_mounts`] is an accessor on the finished instance. The
|
|
/// back-reference is [`std::sync::Weak`], so this task never keeps `Skald` alive.
|
|
pub(super) fn spawn_user_lifecycle(skald: &Arc<super::Skald>) {
|
|
let weak = Arc::downgrade(skald);
|
|
let shutdown = skald.rt.shutdown_token.clone();
|
|
let mut rx = skald.rt.system_bus.subscribe();
|
|
|
|
skald.rt.supervisor.spawn("user-lifecycle", async move {
|
|
loop {
|
|
let event = tokio::select! {
|
|
_ = shutdown.cancelled() => break,
|
|
event = rx.recv() => match event {
|
|
Ok(e) => e,
|
|
// A dropped event costs a stale container until the user's next
|
|
// login/boot — never a lost row, since the DB write came first.
|
|
Err(RecvError::Lagged(n)) => {
|
|
warn!(n, "user-lifecycle: system_bus lagged; container state may be stale until next login/boot");
|
|
continue;
|
|
}
|
|
Err(RecvError::Closed) => break,
|
|
},
|
|
};
|
|
|
|
let Some(skald) = weak.upgrade() else { break };
|
|
match event {
|
|
SystemEvent::UserCreated { user_id } => {
|
|
if let Err(e) = skald.container().ensure(&user_id).await {
|
|
warn!(user = %user_id, error = %e,
|
|
"user-lifecycle: failed to provision container (retried at next boot)");
|
|
}
|
|
}
|
|
SystemEvent::UserDeleted { user_id } => {
|
|
if let Err(e) = skald.container().remove(&user_id).await {
|
|
warn!(user = %user_id, error = %e,
|
|
"user-lifecycle: failed to remove container");
|
|
}
|
|
}
|
|
// Match boot reconciliation, which keeps a container only for active
|
|
// users. The user's live runtime is already gone by now — the handler
|
|
// revoked it synchronously before emitting.
|
|
SystemEvent::UserActiveChanged { user_id, active } => {
|
|
let result = if active {
|
|
skald.container().ensure(&user_id).await
|
|
} else {
|
|
skald.container().stop(&user_id).await
|
|
};
|
|
if let Err(e) = result {
|
|
warn!(user = %user_id, active, error = %e,
|
|
"user-lifecycle: failed to apply active-state change to container");
|
|
}
|
|
}
|
|
SystemEvent::UserMountsChanged { user_id } => {
|
|
if let Err(e) = skald.refresh_user_mounts(&user_id).await {
|
|
warn!(user = %user_id, error = %e,
|
|
"user-lifecycle: remount failed (settles at next login/boot)");
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
info!("user-lifecycle: reconciler stopped");
|
|
});
|
|
}
|