//! The logical API surface of `Skald`: one accessor per manager, named after the //! historical field, delegating into the domain bundle that now owns it. This is //! the intentional surface consumers (frontend handlers, plugin context) use — the //! bundles themselves stay internal. //! //! Now that the core is its own crate, this is a real boundary rather than a //! convention: everything here is `pub` because the `skald` binary lives outside, //! and everything not here is unreachable from it. Promote the block to a //! `SkaldApi` trait if the shells ever need to mock it. use std::sync::Arc; use sqlx::SqlitePool; use tokio::sync::RwLock; use tokio_util::sync::CancellationToken; use core_api::remote::RemoteAccess; use core_api::system_bus::SystemEventBus; use core_api::user_channel::UserChannelApi; use crate::approval::ApprovalManager; use crate::chat_event_bus::ChatEventBus; use crate::chat_hub::ChatHub; use crate::clarification::ClarificationManager; use crate::command::LlmCommandManager; use crate::config_store::GlobalConfigManager; use crate::cron::TaskManager; use crate::elicitation::ElicitationManager; use crate::image_generate::ImageGeneratorManager; use crate::inbox::Inbox; use crate::latex::LatexCompiler; use crate::llm::LlmManager; use crate::location::LocationManager; use crate::mcp::McpManager; use crate::memory::MemoryManager; use crate::plugin::PluginManager; use crate::provider::ProviderRegistry; use crate::run_context::RunContextManager; use crate::secrets::SecretsStore; use crate::session::manager::ChatSessionManager; use crate::system_agents::{AgentRunCtx, AgentScope, ManualRun, ManualRunError, SystemAgents}; use crate::tool_catalog::ToolCatalog; use crate::tools::ToolRegistry; use crate::transcribe::TranscribeManager; use crate::tts::TtsManager; use crate::users::UserManager; use super::Skald; impl Skald { // Runtime / cross-cutting pub fn db(&self) -> &Arc { &self.rt.db } pub fn users(&self) -> &Arc { &self.rt.users } /// The caller's per-user owner-bound runtime (chat/hub/cron/interaction), /// built lazily on first use. `None` when the user's database is still locked /// (not logged in). The pool is the unlock token (§9); a present pool means an /// unlocked database, so a context can be built for it. pub async fn user_context(&self, user_id: &str) -> Option> { let pool = self.rt.users.pool_of(user_id)?; self.rt_user_contexts().resolve(user_id, pool).await.ok() } fn rt_user_contexts(&self) -> &super::user_context::UserContextRegistry { &self.user_contexts } /// Declares the tools the running surface contributes to a chat session /// (the SPA's `show_file_to_user`, …). Called once by the shell after /// construction: the core owns the tools, the shell owns the policy of who /// gets them. Every per-user hub built from here on receives it, and every /// path that starts or resumes a turn consults it — see /// [`crate::chat_hub::InterfaceToolsBuilder`]. pub fn set_interface_tools_builder(&self, build: crate::chat_hub::InterfaceToolsBuilder) { self.rt_user_contexts().set_interface_tools_builder(build); } /// The user's runtime context IF it is already live (built), **without** /// building one — used to refresh a logged-in user in place. A user who never /// logged in has no snapshot to refresh; their next login builds a fresh one. pub async fn user_context_if_live(&self, user_id: &str) -> Option> { self.rt_user_contexts().peek(user_id).await } /// Revokes a user's live runtime: sessions, owner-bound loops, database key. /// /// Called when a user is **deactivated or deleted**. Writing `active = 0` (or /// deleting the row) only stops the *next* login: `login` checks the flag, but /// `require_auth` maps token → id without re-reading the row, so a session minted /// before the change would keep working, over a pool whose key is still in RAM. /// /// The order is load-bearing: /// /// 1. **Revoke the sessions** — the moment this returns, no token authenticates /// as this user. /// 2. **Evict the context** — cancels their cron loop, hub and per-user MCP /// runtime, so nothing is left to query the pool we are about to close. /// 3. **Lock the database** — `close()`s the pool, which invalidates every /// surviving clone and drops the DEK (§9). The user is opaque again. /// /// Synchronous by design: this is an authorization invariant, not reconciliation, /// so it must not ride the lossy system bus. The Docker half (stop or remove the /// container) *is* reconciliation and does ride it. /// /// Idempotent — a user with no live session and a locked database is a no-op. pub async fn revoke_user_runtime(&self, user_id: &str) { self.sessions().revoke_user(user_id); self.rt_user_contexts().evict(user_id).await; self.rt.users.lock(user_id).await; } /// Re-checks a live user's open sessions against their current role, degrading any /// security group the role no longer allows, and tells their open tabs about it. /// /// The durable half of this is in `ChatSessionManager::get_or_create_handler`, /// which reconciles on every load; this is the liveness half, for sessions already /// in RAM. Synchronous, like [`Self::revoke_user_runtime`] and for the same reason: /// narrowing someone's permissions is an authorization change, not reconciliation. /// /// No-op for a user who is not logged in — their next login loads through the /// reconcile anyway. pub async fn revalidate_security_groups_for_user(&self, user_id: &str) { let Some(ctx) = self.user_context_if_live(user_id).await else { return }; for (session_id, source, group) in ctx.sessions.revalidate_security_groups().await { ctx.chat_hub.emit(core_api::events::GlobalEvent { source: Some(source), // Tagged with the conversation: clients filter per conversation, so // an untagged degrade would leave every pill showing the old group. session_id: Some(session_id), event: core_api::events::ServerEvent::SecurityGroupSelected { group }, }); } } /// [`Self::revalidate_security_groups_for_user`] for every member of a role — /// called when the role's own group set changes, which can narrow many users at /// once. Members who are not logged in need nothing. pub async fn revalidate_security_groups_for_role(&self, role_id: &str) { let users = match crate::db::users::list(self.db()).await { Ok(u) => u, Err(e) => { tracing::warn!(role = %role_id, error = %e, "cannot list users to revalidate security groups"); return; } }; for user in users.into_iter().filter(|u| u.role_id == role_id) { self.revalidate_security_groups_for_user(&user.id).await; } } /// Applies a shared-folder membership change to a user (blueprint §6 remount). /// /// A container's bind mounts are fixed at `docker create` time, so the mount set /// changes only by recreating the container — done here with a graceful stop /// first ([`ContainerManager::recreate`](crate::container::ContainerManager::recreate)). /// If the user is **live**, the two snapshot-bound pieces are then refreshed in /// place against the fresh container: their filesystem view (which governs both /// the host-side fs-tools and `execute_cmd` path routing) and their per-user MCP /// runtime (whose `docker exec` children died with the old container). A user /// with no live context needs only the recreate — their next login builds a /// context that already reflects the change. /// /// Best-effort by contract: the membership row is already committed, so a Docker /// hiccup must not fail the caller; the state settles at the next login/boot. /// /// Covers both shared-folder and project membership changes — both feed /// `build_user_fs`, so a recreate reflows either mount set. pub async fn refresh_user_mounts(&self, user_id: &str) -> anyhow::Result<()> { // New mount topology (graceful stop → remove → recreate from current rows). self.container().recreate(user_id).await?; let Some(ctx) = self.user_context_if_live(user_id).await else { return Ok(()); // not logged in — next login builds a fresh context }; // fs view: swap the shared cell so every live session picks it up next call. let new_fs = crate::container::build_user_fs(self.db(), user_id).await?; ctx.sessions.refresh_fs(new_fs); // per-user MCP: the old container's `docker exec` children are gone. Stop the // stale handles, then reconnect the activated connectors against the fresh // container (same deterministic name). ctx.user_mcp.stop_all(); let rows = crate::db::mcp_user_servers::all_startable(&ctx.pool).await.unwrap_or_default(); if !rows.is_empty() { let container = crate::container::container_name(user_id); let mut specs = Vec::with_capacity(rows.len()); for r in &rows { // The home mount (and its `node_modules`/`.pydeps`) survives a // recreate, so this is normally a hash-match no-op; it still covers // the case where the source changed while the user was logged in. crate::mcp::prepare_local_connector(self.db(), user_id, &container, r).await; specs.push(crate::mcp::user_row_spec_resolved(r, &container, self.db()).await); } ctx.user_mcp.connect_all(specs, false).await; } Ok(()) } /// Rebuilds the frozen system prefix of the conversations a skills change made /// wrong (blueprint §6). /// /// The prefix is normally left alone until its conversation has been idle for /// twenty minutes, which is right for an *injected file* and wrong for the /// **index**: an admin who installs a skill and then asks the assistant to use /// it would be told, at length and in good faith, that no such skill exists. /// /// Called **directly** by the two skill tools, not through the system bus. /// Whoever writes a skill through a tool is inside this process and can say so; /// the bus (`SkillsChanged`) is for the other case — someone editing files on /// the box — where nothing in-process knows. A miss here is not a lost event, /// it is a user who is simply not logged in and whose next login builds a fresh /// prefix anyway. pub async fn invalidate_prompt_prefix(&self, scope: crate::skills::PromptScope) { for ctx in self.rt_user_contexts().all_live().await { let concerns = match &scope { // The group's tree is in everybody's index. crate::skills::PromptScope::Everyone => true, crate::skills::PromptScope::User(id) => *id == ctx.user_id, }; if concerns { ctx.sessions.loop_runtime().invalidate_prefixes(); } } } /// Refresh every live user's global-connector access set in place — call after an /// admin enables/deletes a global connector or changes who may use it, so running /// sessions see it without a restart (the §7 MCP twin of the §6 fs remount). The /// global runtime itself is already updated by the caller (`start_server` / /// `stop_server`); this only re-snapshots each user's access filter. Best-effort: /// a locked (not-live) user has no snapshot to refresh — their next login rebuilds /// it from the now-current tables. pub async fn refresh_global_mcp_access(&self) { for ctx in self.rt_user_contexts().all_live().await { if let Err(e) = ctx.refresh_global_access().await { tracing::warn!(user = %ctx.user_id, error = %e, "failed to refresh global MCP access"); } // Then rebuild the frozen prompt prefix, for the reason spelled out in // `invalidate_prompt_prefix`: refreshing the snapshot fixes what `mcp.tools()` // *offers*, while the `## MCP servers` table the model reads lives inside // `base`, which `PrefixCache` holds for twenty idle minutes. Without this the // admin enables a connector, asks for it in an open conversation, and is told // in good faith that it does not exist — with the tools sitting right there. // // After the refresh, never before: the table is rendered from the access // snapshot we just replaced. ctx.sessions.loop_runtime().invalidate_prefixes(); } } /// Pushes a marketplace **reinstall** into every live copy of the connector so /// active sessions pick up the new metadata (`llm_short_description`) and code /// without a re-login — the reinstall counterpart of the §6/§7 remount helpers. /// The reinstall has already rewritten `mcp_catalog`; this reconnects what runs: /// /// - **Global runtime**: install the connector's declared dependencies on the host /// (`ensure_installed_host`, once per folder), then for each *enabled* /// `mcp_global_servers` row snapshotting this catalog entry, re-snapshot its /// `description` from the catalog and restart it, so the running server's in-RAM /// description (and code) catches up. /// - **Per-user runtimes**: for each live user who has this connector *startable*, /// re-copy its files/deps into the container (`prepare_local_connector` — a hash /// no-op when the source is unchanged) and restart that one server. The rebuilt /// spec now carries the fresh catalog description (see `user_row_spec_resolved`). /// - **Prompt prefix**: last, invalidate it for every live user, so the /// `## MCP servers` table stops describing the version that was just replaced. /// /// Best-effort: the catalog write already committed, so a Docker/MCP hiccup here /// must not fail the reinstall — anything not refreshed settles at the user's next /// login. A fresh install (nothing live yet) is a cheap no-op: no row matches. pub async fn refresh_connector_after_reinstall(&self, catalog_name: &str) { // The metadata the reinstall just wrote — the source of truth to push out. let entry = match crate::db::mcp_catalog::get_by_name(self.db(), catalog_name).await { Ok(Some(e)) => e, Ok(None) => return, Err(e) => { tracing::warn!(connector = %catalog_name, error = %e, "reinstall refresh: catalog lookup failed"); return; } }; // 1. Global runtime. if let Ok(globals) = crate::db::mcp_global_servers::all_enabled(self.db()).await { let live: Vec<_> = globals .iter() .filter(|g| g.catalog_name.as_deref() == Some(catalog_name)) .collect(); // Dependencies before code. A global connector runs on the host, where // nothing reconciles it the way the container reconciler does below, and // `ensure_installed_host` was otherwise reachable from `global_enable` // alone — so an Update that *adds* a `requirements.txt` landed the file, // restarted the server, and never installed what it declared: the // connector came back exactly as broken as before, curable only by // re-saving its config from the UI. // // Once per connector folder rather than per row: the deps live beside the // files, so two runtime names snapshotting one catalog entry share them. // Not hash-guarded, unlike the per-user `ensure_installed` — it leans on // `pip`/`npm` being idempotent, so a no-change reinstall pays one fast // satisfied-requirements pass. Best-effort like the rest of this function. if !live.is_empty() && entry.source == "local_script" { match entry.script_path.as_deref().map(crate::mcp::split_script_path) { Some(Ok((folder, _))) => { if let Err(e) = crate::mcp::ensure_installed_host(folder).await { tracing::warn!(connector = %catalog_name, error = %e, "reinstall refresh: global dependency install failed"); } } Some(Err(e)) => tracing::warn!(connector = %catalog_name, error = %e, "reinstall refresh: unusable script_path, skipping dependency install"), None => tracing::warn!(connector = %catalog_name, "reinstall refresh: local_script entry has no script_path, skipping dependency install"), } } for g in live { if let Err(e) = crate::db::mcp_global_servers::set_description(self.db(), g.id, entry.description.as_deref()).await { tracing::warn!(connector = %catalog_name, error = %e, "reinstall refresh: failed to update global description"); continue; } match crate::db::mcp_global_servers::get(self.db(), g.id).await { Ok(Some(row)) => { let spec = crate::mcp::global_row_spec(&row); if let Err(e) = self.mcp().start_server(spec).await { tracing::warn!(connector = %catalog_name, error = %e, "reinstall refresh: failed to restart global server"); } } _ => tracing::warn!(connector = %catalog_name, "reinstall refresh: global row vanished before restart"), } } } // 2. Per-user runtimes — restart this one connector for each live user who runs it. for ctx in self.rt_user_contexts().all_live().await { let rows = crate::db::mcp_user_servers::all_startable(&ctx.pool).await.unwrap_or_default(); let Some(row) = rows.into_iter().find(|r| r.catalog_name.as_deref() == Some(catalog_name)) else { continue; }; let container = crate::container::container_name(&ctx.user_id); crate::mcp::prepare_local_connector(self.db(), &ctx.user_id, &container, &row).await; let spec = crate::mcp::user_row_spec_resolved(&row, &container, self.db()).await; if let Err(e) = ctx.user_mcp.start_server(spec).await { tracing::warn!(user = %ctx.user_id, connector = %catalog_name, error = %e, "reinstall refresh: failed to restart per-user connector"); } } // 3. Rebuild the frozen prompt prefix, for everyone — a reinstall changes the // connector's `llm_short_description`, which the model reads from the // `## MCP servers` table inside `base` rather than from the runtime it just // reconnected to. Restarting the servers alone left the prompt describing the // old version for up to twenty idle minutes. // // **Last, deliberately.** `render_mcp_list` renders the live runtime's in-RAM // state, so a prefix rebuilt before the restarts above would be repopulated // from the descriptions we are in the middle of replacing — and nothing would // invalidate it a second time. That the global dependency install can take // minutes is not a reason to move this earlier: those users were already // reading a stale table, and rebuilding it early would only freeze the stale // one in place. // // Everyone, not just the users who run this connector per-user: an enabled // global connector is in every granted user's table. for ctx in self.rt_user_contexts().all_live().await { ctx.sessions.loop_runtime().invalidate_prefixes(); } } pub fn sessions(&self) -> &Arc { &self.rt.sessions } pub fn config(&self) -> &Arc { &self.rt.config } pub fn config_properties(&self) -> &[core_api::ConfigSet] { &self.rt.config_properties } pub fn system_bus(&self) -> &Arc { &self.rt.system_bus } pub fn event_bus(&self) -> &Arc { &self.rt.event_bus } pub fn shutdown_token(&self) -> &CancellationToken { &self.rt.shutdown_token } // Models pub fn provider_registry(&self) -> &Arc { &self.models.provider_registry } pub fn llm_manager(&self) -> &Arc { &self.models.llm_manager } pub fn secrets(&self) -> &Arc { &self.models.secrets } pub fn memory_manager(&self) -> &Arc { &self.models.memory_manager } // Media pub fn image_generator_manager(&self) -> &Arc { &self.media.image_generator_manager } pub fn transcribe_manager(&self) -> &Arc { &self.media.transcribe_manager } pub fn tts_manager(&self) -> &Arc { &self.media.tts_manager } // Tools pub fn tools(&self) -> &Arc { &self.tools.tools } pub fn catalog(&self) -> &ToolCatalog { &self.tools.catalog } pub fn command_manager(&self) -> &Arc { &self.tools.command_manager } // Integrations pub fn mcp(&self) -> &Arc { &self.integrations.mcp } pub fn plugin_manager(&self) -> &Arc { &self.integrations.plugin_manager } // Tasks pub fn cron(&self) -> &Arc { &self.tasks.cron } // Conversation pub fn manager(&self) -> &Arc { &self.conversation.manager } pub fn chat_hub(&self) -> &Arc { &self.conversation.chat_hub } pub fn run_context_manager(&self) -> &Arc { &self.conversation.run_context_manager } // Interaction pub fn approval(&self) -> &Arc { &self.interaction.approval } pub fn inbox(&self) -> &Inbox { &self.interaction.inbox } pub fn clarification(&self) -> &Arc { &self.interaction.clarification } pub fn elicitation(&self) -> &Arc { &self.interaction.elicitation } // Infra pub fn latex_compiler(&self) -> &LatexCompiler { &self.infra.latex_compiler } pub fn location_manager(&self) -> &Arc { &self.infra.location_manager } pub fn remote(&self) -> &Arc>>> { &self.infra.remote } // System agents pub fn system_agents(&self) -> &Arc { &self.system_agents } /// Start one system-agent pass **now**, for `user_id`, because a human asked. /// /// The schedule answers *when* a pass runs, and the button is a person saying /// "now" — so due-ness is skipped, exactly as manual `/compact` skips the /// compactor's token threshold. The instance-wide **Enabled** switch is a /// different kind of setting and is honoured: it says *whether* the agent runs /// at all, and that is the admin's answer, not the caller's. /// /// The pass always runs **as the caller** — their pool, their sessions, their /// hub — so a member triggering the shared-memory lint gets their own report /// over the shared store, and their own run row. One consequence worth naming: /// the attempt is marked in the file the pass ran in, so a member's manual run /// of an instance-wide agent does not move the admin's scheduled clock. The two /// clocks were always per file; this only makes it visible. /// /// Returns as soon as the work is **scheduled**, not when it finishes: a pass is /// an LLM turn and no HTTP request should be held open for it. The run log is /// the progress surface — the `running` row exists before this returns to the /// browser. The one thing answered synchronously is /// [`SystemAgent::has_work`], which is cheap by contract and whose `false` /// leaves no row at all: without it the button would report "started" and the /// log would stay empty forever. pub async fn run_system_agent_now( self: &Arc, agent_id: &str, user_id: &str, ) -> Result { let agent = self.system_agents.get(agent_id).ok_or(ManualRunError::UnknownAgent)?.clone(); // A per-subject pass is about somebody else and picks its own subjects; // "run it for me" has no meaning for it. if agent.scope() == AgentScope::PerSubject { return Err(ManualRunError::Unsupported); } if !agent.is_enabled().await { return Err(ManualRunError::Disabled); } let ctx = self.user_context(user_id).await.ok_or(ManualRunError::Locked)?; // Taken before `has_work` so that two quick clicks cannot both look, both // find work, and both start. let claim = self .system_agents .claim(agent.id(), &SystemAgents::target_of(agent.as_ref(), user_id)) .ok_or(ManualRunError::AlreadyRunning)?; let run_ctx = AgentRunCtx { user_id, pool: &ctx.pool, sessions: &ctx.sessions, hub: &ctx.chat_hub, subject: None, run_id: None, }; if !agent.has_work(&run_ctx).await.map_err(ManualRunError::Failed)? { return Ok(ManualRun::NothingToDo); } let user_id = user_id.to_string(); self.rt.supervisor.spawn("system-agent-manual", async move { // Released when the task ends, whichever way it ends. let _claim = claim; let run_ctx = AgentRunCtx { user_id: &user_id, pool: &ctx.pool, sessions: &ctx.sessions, hub: &ctx.chat_hub, subject: None, run_id: None, }; if let Err(e) = crate::system_agents::run_and_record(agent.as_ref(), &run_ctx).await { // The failure is already recorded on the run row, which is where // the person who pressed the button will look for it. tracing::warn!(agent = agent.id(), user = %user_id, error = %e, "system-agents: manual pass failed"); } }); Ok(ManualRun::Started) } } // ── UserChannelApi ──────────────────────────────────────────────────────────── use super::user_context::UserContextHandle; #[async_trait::async_trait] impl UserChannelApi for Skald { async fn resolve_user(&self, user_id: &str) -> Option> { let ctx = self.user_context(user_id).await?; Some(std::sync::Arc::new(UserContextHandle::new(ctx))) } async fn plugin_access(&self, plugin_id: &str, user_id: &str) -> bool { // Admin short-circuit + grant lookup live in `db::plugin_access`; a // lookup error fails closed. crate::db::plugin_access::effective_access(self.db(), plugin_id, user_id) .await .unwrap_or(false) } async fn is_admin(&self, user_id: &str) -> bool { // Built-in admin role; an unknown user or a lookup error fails closed. sqlx::query_as::<_, (String,)>("SELECT role_id FROM users WHERE id = ?") .bind(user_id) .fetch_optional(self.db().as_ref()) .await .ok() .flatten() .map(|(r,)| r == crate::db::roles::ADMIN_ROLE_ID) .unwrap_or(false) } async fn user_for_session(&self, token: &str) -> Option { self.sessions().user_of(token) } }