feat(users): UserManager with per-user SQLCipher, and extract skald-core crate

Two changes developed together in one session; they share the same module
structure (db/mod.rs, the core lib root) and only compile together, so they
land as one commit.

## UserManager + per-user encryption (§9/§11)

New `users::UserManager`: owns the system.db pool plus a map
`userid -> SqlitePool` of unlocked databases. The pool *is* the unlock token —
its connect options carry the DEK as SQLCipher's raw key, so an open pool means
the key is in RAM until restart and dropping it re-locks (§9). Knows nothing
about cookies.

New `crypto` module: envelope encryption. A random 256-bit DEK encrypts
`{userid}.db`; `users.database_password` holds it sealed with AES-256-GCM under
`Argon2id(password, salt)`. The AEAD tag is the password verifier — one
derivation both authenticates and yields the key, so encrypted users store no
second hash. Cleartext users store the Argon2id output directly, compared in
constant time. Argon2 runs in spawn_blocking behind a 2-permit semaphore
(256 MiB per derivation).

- SQLCipher via `libsqlite3-sys` `bundled-sqlcipher-vendored-openssl`, pinned
  <0.38 so it unifies with the one sqlx-sqlite links (a newer copy would apply
  the feature to a SQLite sqlx never uses). OpenSSL is vendored and static, so
  the binary stays self-contained.
- Schema split into `create_registry_tables` (instance-wide, no user key) and
  `create_owner_tables` (one owner's content, identical in every file). No FK in
  the owner bucket may reach the registry — enforced by a standalone test.
  Dropped `chat_history.model_db_id` (write-only, and the only registry-crossing
  key); moved `projects`/`project_tickets` into the owner bucket.
- Provisioning invariant: the file is written before the row, deleted after it,
  so a crash leaves an orphan file, never a user without a database. `open_db`
  never creates: a missing file is an error, not a silent empty database.

Not consumed yet: no login, call sites still use the shared system.db pool.

## Extract crates/skald-core

The headless core moves out of `src/` into its own crate; `skald` (server) and
the coming `skald-setup` are shells around it. Two dependencies on the shell
were inverted rather than dragged along, so the core names neither Tauri nor any
concrete plugin:

- `Plugin::tools(self: Arc<Self>)` — plugins contribute tools through this hook
  (sibling of `http_router`), so the core no longer downcasts to
  `MobileConnectorPlugin`.
- `tools::restart::set_restart_handler` — the desktop shell installs its
  teardown-and-respawn; the core defaults to the supervisor exit code. The core
  loses its `desktop` feature.
- `boot`'s stdout formatter moves to the binary (`src/boot_format.rs`); the core
  only emits tracing events.

All 79 core tests pass; the binary boots and serves in a clean directory, and
the mobile-connector tools still register through the new hook.
This commit is contained in:
2026-07-10 16:48:51 +01:00
parent 38494a85a9
commit 178a38357e
173 changed files with 2650 additions and 1106 deletions
+102
View File
@@ -0,0 +1,102 @@
//! 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 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::projects::tickets::ProjectTicketManager;
use crate::projects::ProjectManager;
use crate::provider::ProviderRegistry;
use crate::run_context::RunContextManager;
use crate::secrets::SecretsStore;
use crate::session::manager::ChatSessionManager;
use crate::tic::TicManager;
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<SqlitePool> { &self.rt.db }
pub fn users(&self) -> &Arc<UserManager> { &self.rt.users }
pub fn config(&self) -> &Arc<GlobalConfigManager> { &self.rt.config }
pub fn config_properties(&self) -> &[core_api::ConfigSet] { &self.rt.config_properties }
pub fn system_bus(&self) -> &Arc<SystemEventBus> { &self.rt.system_bus }
pub fn event_bus(&self) -> &Arc<ChatEventBus> { &self.rt.event_bus }
pub fn shutdown_token(&self) -> &CancellationToken { &self.rt.shutdown_token }
// Models
pub fn provider_registry(&self) -> &Arc<ProviderRegistry> { &self.models.provider_registry }
pub fn llm_manager(&self) -> &Arc<LlmManager> { &self.models.llm_manager }
pub fn secrets(&self) -> &Arc<SecretsStore> { &self.models.secrets }
pub fn memory_manager(&self) -> &Arc<MemoryManager> { &self.models.memory_manager }
// Media
pub fn image_generator_manager(&self) -> &Arc<ImageGeneratorManager> { &self.media.image_generator_manager }
pub fn transcribe_manager(&self) -> &Arc<TranscribeManager> { &self.media.transcribe_manager }
pub fn tts_manager(&self) -> &Arc<TtsManager> { &self.media.tts_manager }
// Tools
pub fn tools(&self) -> &Arc<ToolRegistry> { &self.tools.tools }
pub fn catalog(&self) -> &ToolCatalog { &self.tools.catalog }
pub fn command_manager(&self) -> &Arc<LlmCommandManager> { &self.tools.command_manager }
// Integrations
pub fn mcp(&self) -> &Arc<McpManager> { &self.integrations.mcp }
pub fn plugin_manager(&self) -> &Arc<PluginManager> { &self.integrations.plugin_manager }
// Tasks
pub fn cron(&self) -> &Arc<TaskManager> { &self.tasks.cron }
pub fn projects(&self) -> &Arc<ProjectManager> { &self.tasks.projects }
pub fn ticket_manager(&self) -> &Arc<ProjectTicketManager> { &self.tasks.ticket_manager }
// Conversation
pub fn manager(&self) -> &Arc<ChatSessionManager> { &self.conversation.manager }
pub fn chat_hub(&self) -> &Arc<ChatHub> { &self.conversation.chat_hub }
pub fn run_context_manager(&self) -> &Arc<RunContextManager> { &self.conversation.run_context_manager }
pub fn tic_manager(&self) -> &Arc<TicManager> { &self.conversation.tic_manager }
// Interaction
pub fn approval(&self) -> &Arc<ApprovalManager> { &self.interaction.approval }
pub fn inbox(&self) -> &Inbox { &self.interaction.inbox }
pub fn clarification(&self) -> &Arc<ClarificationManager> { &self.interaction.clarification }
pub fn elicitation(&self) -> &Arc<ElicitationManager> { &self.interaction.elicitation }
// Infra
pub fn latex_compiler(&self) -> &LatexCompiler { &self.infra.latex_compiler }
pub fn location_manager(&self) -> &Arc<LocationManager> { &self.infra.location_manager }
pub fn remote(&self) -> &Arc<RwLock<Option<Arc<dyn RemoteAccess>>>> { &self.infra.remote }
}
+407
View File
@@ -0,0 +1,407 @@
//! Domain bundles: the managers, grouped by cohesion, that make up `Skald`.
//!
//! Each bundle owns a `build()` that constructs its managers (plus their startup
//! logging and non-fatal `seed_*` calls) from the shared [`Runtime`] and whatever
//! sibling bundles it depends on at construction time. Cross-bundle *cycles* are
//! not expressed here — they are resolved by the managers' `OnceLock` setters,
//! called in one place by [`super::wiring::wire`]. Bundle structs never hold
//! references to each other.
use std::sync::Arc;
use anyhow::Result;
use tracing::{debug, info, warn};
use core_api::remote::RemoteAccess;
use crate::approval::ApprovalManager;
use crate::chat_hub::ChatHub;
use crate::clarification::ClarificationManager;
use crate::command::LlmCommandManager;
use crate::compactor::ContextCompactor;
use crate::config::{CoreConfig, DatetimeConfig};
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::projects::tickets::ProjectTicketManager;
use crate::projects::ProjectManager;
use crate::provider::ProviderRegistry;
use crate::run_context::RunContextManager;
use crate::secrets::SecretsStore;
use crate::session::handler::{DEFAULT_MAX_PARALLEL_SUBAGENTS, DEFAULT_MAX_TOOL_ROUNDS};
use crate::session::manager::ChatSessionManager;
use crate::tic::TicManager;
use crate::tool_catalog::ToolCatalog;
use crate::tool_discovery::ToolDiscovery;
use crate::tools::ToolRegistry;
use crate::transcribe::TranscribeManager;
use crate::tts::TtsManager;
use tokio::sync::RwLock;
use core_api::plugin::Plugin;
use super::runtime::Runtime;
// ── Models: LLM/provider stack ──────────────────────────────────────────────
pub(super) struct Models {
pub(super) provider_registry: Arc<ProviderRegistry>,
pub(super) llm_manager: Arc<LlmManager>,
pub(super) secrets: Arc<SecretsStore>,
pub(super) memory_manager: Arc<MemoryManager>,
}
impl Models {
pub(super) async fn build(rt: &Runtime, config: &CoreConfig) -> Result<Self> {
let mut provider_registry = ProviderRegistry::new(Arc::clone(&rt.system_bus));
provider_registry.register_builtin(crate::llm::providers::openai::OpenAiProvider);
provider_registry.register_builtin(crate::llm::providers::anthropic::AnthropicProvider::new());
provider_registry.register_builtin(crate::llm::providers::openrouter::OpenRouterProvider::new());
provider_registry.register_builtin(crate::llm::providers::ollama::OllamaProvider::new());
provider_registry.register_builtin(crate::llm::providers::lm_studio::LmStudioProvider::new());
provider_registry.register_builtin(crate::llm::providers::deepseek::DeepSeekProvider::new());
provider_registry.register_builtin(crate::llm::providers::zai::ZaiProvider::new());
let provider_registry = Arc::new(provider_registry);
info!("provider registry ready ({} built-in providers)", provider_registry.all().len());
let log_flags = config.llm.requests_log.as_ref().filter(|r| r.enabled).map(|r| {
use crate::chatbot::logging::LogSaveFlags;
LogSaveFlags {
request_payload: r.request_payload_save,
response_payload: r.response_payload_save,
request_headers: r.request_header_save,
response_headers: r.response_header_save,
}
});
let llm_manager = LlmManager::new(Arc::clone(&rt.db), Arc::clone(&provider_registry), log_flags).await?;
let client_count = llm_manager.client_names().await.len().saturating_sub(1);
let default_client = llm_manager.default_name().await;
info!(clients = client_count, default = %default_client, "LLM clients loaded");
let secrets = SecretsStore::new(Arc::clone(&rt.db));
info!("secrets store ready");
let memory_manager = Arc::new(MemoryManager::new());
info!("memory manager ready");
Ok(Models { provider_registry, llm_manager, secrets, memory_manager })
}
}
// ── Media: transcription / TTS / image generation ───────────────────────────
pub(super) struct Media {
pub(super) image_generator_manager: Arc<ImageGeneratorManager>,
pub(super) transcribe_manager: Arc<TranscribeManager>,
pub(super) tts_manager: Arc<TtsManager>,
}
impl Media {
pub(super) async fn build(rt: &Runtime, models: &Models) -> Result<Self> {
let image_generator_manager = ImageGeneratorManager::new(
Arc::clone(&rt.db),
Arc::clone(&models.provider_registry),
"data",
).await?;
// Evaluate the await outside the `info!` macro: leaving the temporary
// `tracing::Value` from the field expression alive across the await
// makes the surrounding future non-Send, which Tauri's runtime rejects.
let image_generator_models = image_generator_manager.list_models_info().await.len();
info!(
db_backed = image_generator_models,
"image generator manager ready",
);
let transcribe_manager = TranscribeManager::new(
Arc::clone(&rt.db),
Arc::clone(&models.provider_registry),
Arc::clone(&rt.system_bus),
rt.shutdown_token.clone(),
).await?;
let transcribe_models = transcribe_manager.list_models_info().await.len();
info!(
db_backed = transcribe_models,
"transcribe manager ready",
);
let tts_manager = TtsManager::new(
Arc::clone(&rt.db),
Arc::clone(&models.provider_registry),
Arc::clone(&rt.system_bus),
rt.shutdown_token.clone(),
).await?;
let tts_models = tts_manager.list_models_info().await.len();
info!(
db_backed = tts_models,
"tts manager ready",
);
Ok(Media { image_generator_manager, transcribe_manager, tts_manager })
}
}
// ── Integrations: MCP + plugins ─────────────────────────────────────────────
pub(super) struct Integrations {
pub(super) mcp: Arc<McpManager>,
pub(super) plugin_manager: Arc<PluginManager>,
}
impl Integrations {
/// Builds the MCP manager (its `initialize()` is deferred to `spawn_background`,
/// after the elicitation handler is wired) and the plugin manager (plugins are
/// injected by `main.rs`; `start_enabled()` runs later, from `WebFrontend`).
pub(super) fn build(rt: &Runtime, plugins: Vec<Arc<dyn Plugin>>) -> Self {
let mcp = Arc::new(McpManager::new(Arc::clone(&rt.db), rt.shutdown_token.clone(), "data"));
let mut plugin_manager = PluginManager::new(Arc::clone(&rt.db));
for plugin in plugins {
plugin_manager.register_arc(plugin);
}
info!("plugins registered");
let plugin_manager = Arc::new(plugin_manager);
Integrations { mcp, plugin_manager }
}
}
// ── Tasks: cron + projects/tickets ──────────────────────────────────────────
pub(super) struct Tasks {
pub(super) cron: Arc<TaskManager>,
pub(super) projects: Arc<ProjectManager>,
pub(super) ticket_manager: Arc<ProjectTicketManager>,
}
impl Tasks {
/// Built before `Tools` so cron tools can capture the `TaskManager`.
pub(super) fn build(rt: &Runtime, config: &CoreConfig) -> Self {
let cron_tz = config.timezone.as_deref().and_then(|s| {
match s.parse::<chrono_tz::Tz>() {
Ok(tz) => { info!("timezone: using {s}"); Some(tz) }
Err(_) => { warn!("timezone: unknown value '{s}', falling back to local time"); None }
}
});
let cron = TaskManager::new(Arc::clone(&rt.db), cron_tz, Arc::clone(&rt.system_bus));
let ticket_manager = ProjectTicketManager::new(Arc::clone(&rt.db));
let projects = Arc::new(ProjectManager::new(Arc::clone(&rt.db)));
info!("project manager ready");
Tasks { cron, projects, ticket_manager }
}
}
// ── Tools: registry + catalog + slash commands ──────────────────────────────
pub(super) struct Tools {
pub(super) tools: Arc<ToolRegistry>,
pub(super) catalog: ToolCatalog,
pub(super) command_manager: Arc<LlmCommandManager>,
}
impl Tools {
/// Captures sibling managers (mcp, plugins, cron, secrets) into the tool
/// registry. `execute_task` is deliberately NOT registered here — it is injected
/// per interactive session by `ChatHub::send_message`.
pub(super) fn build(integrations: &Integrations, tasks: &Tasks, models: &Models) -> Self {
let mut tool_registry = ToolRegistry::new();
crate::tools::fs::register_all(&mut tool_registry);
tool_registry.register(crate::tools::ast_outline::AstOutline::new());
tool_registry.register(crate::tools::exec::ExecuteCmd);
tool_registry.register(crate::tools::read_notification::ReadNotification);
tool_registry.register(crate::tools::restart::Restart);
// Unified listing / toggling across mcp, plugins, cron (+ agents for list).
tool_registry.register(crate::tools::list_items::ListItems::new(
Arc::clone(&integrations.mcp), Arc::clone(&integrations.plugin_manager), Arc::clone(&tasks.cron)));
tool_registry.register(crate::tools::toggle_item::ToggleItem::new(
Arc::clone(&integrations.mcp), Arc::clone(&integrations.plugin_manager), Arc::clone(&tasks.cron)));
tool_registry.register(crate::tools::register_mcp::RegisterMcp::new(Arc::clone(&integrations.mcp)));
tool_registry.register(crate::tools::register_mcp::DeleteMcp::new(Arc::clone(&integrations.mcp)));
tool_registry.register(crate::tools::cron_jobs::DeleteCronJob(Arc::clone(&tasks.cron)));
tool_registry.register(crate::tools::set_secret::SetSecret(Arc::clone(&models.secrets)));
tool_registry.register(crate::tools::list_secrets::ListSecrets(Arc::clone(&models.secrets)));
tool_registry.register(crate::tools::configure_plugin::ConfigurePlugin(Arc::clone(&integrations.plugin_manager)));
// Tools contributed by plugins (plugin.md §11), via `Plugin::tools()`.
// The core never names a plugin crate: each one hands over whatever tools
// it wants, bound to its own handle. They are built before the plugins'
// runloops start, so they must tolerate being called while stopped.
for plugin in integrations.plugin_manager.all() {
let id = plugin.id().to_string();
let tools = Arc::clone(plugin).tools();
if tools.is_empty() {
continue;
}
let n = tools.len();
for tool in tools {
tool_registry.register_arc(tool);
}
info!(plugin = %id, count = n, "plugin tools registered");
}
debug!("tool registry built");
let tools = Arc::new(tool_registry);
let catalog = ToolCatalog::new(Arc::clone(&tools), Arc::clone(&integrations.mcp));
let command_manager = Arc::new(LlmCommandManager::new());
Tools { tools, catalog, command_manager }
}
}
// ── Interaction: approval + inbox + clarification + elicitation ─────────────
pub(super) struct Interaction {
pub(super) approval: Arc<ApprovalManager>,
pub(super) inbox: Inbox,
pub(super) clarification: Arc<ClarificationManager>,
pub(super) elicitation: Arc<ElicitationManager>,
}
impl Interaction {
pub(super) async fn build(rt: &Runtime, tools: &Tools) -> Result<Self> {
let approval = Arc::new(ApprovalManager::new(Arc::clone(&rt.db), rt.global_tx.clone()));
if let Err(e) = approval.seed_defaults().await {
warn!(error = %e, "failed to seed default approval rules (non-fatal)");
}
if let Err(e) = approval.migrate_legacy_fs_rules().await {
warn!(error = %e, "failed to migrate legacy filesystem rules (non-fatal)");
}
if let Err(e) = approval.seed_fs_path_rules().await {
warn!(error = %e, "failed to seed File System path rules (non-fatal)");
}
if let Err(e) = approval.seed_default_catch_all().await {
warn!(error = %e, "failed to seed default catch-all rule (non-fatal)");
}
info!("approval manager ready");
let clarification = ClarificationManager::new(rt.global_tx.clone());
let elicitation = ElicitationManager::new(rt.global_tx.clone());
let inbox = Inbox::new(
Arc::clone(&approval),
Arc::clone(&clarification),
Arc::clone(&elicitation),
Arc::clone(&tools.tools),
);
Ok(Interaction { approval, inbox, clarification, elicitation })
}
}
// ── Conversation: session manager + chat hub + run context + TIC ────────────
pub(super) struct Conversation {
pub(super) manager: Arc<ChatSessionManager>,
pub(super) chat_hub: Arc<ChatHub>,
pub(super) run_context_manager: Arc<RunContextManager>,
/// TIC lives here (rather than in `Tasks`) because it is constructed from and
/// drives the conversation stack (session manager + chat hub + run context);
/// this keeps every bundle a single-shot `build()` with no two-phase init.
pub(super) tic_manager: Arc<TicManager>,
}
impl Conversation {
#[allow(clippy::too_many_arguments)]
pub(super) async fn build(
rt: &Runtime,
models: &Models,
media: &Media,
tools: &Tools,
integrations: &Integrations,
interaction: &Interaction,
config: &CoreConfig,
) -> Result<Self> {
let run_context_manager =
Arc::new(RunContextManager::new(Arc::clone(&rt.db), Arc::clone(&interaction.approval)));
if let Err(e) = run_context_manager.seed_defaults().await {
warn!(error = %e, "failed to seed default permission group (non-fatal)");
}
info!("run_context manager ready");
let compactor = config.llm.compaction.as_ref().map(|cfg| {
info!(
threshold_tokens = cfg.threshold_tokens,
keep_recent = cfg.keep_recent,
?cfg.strength,
"context compactor enabled"
);
Arc::new(ContextCompactor::new(
cfg.clone(),
Arc::clone(&models.llm_manager),
Arc::clone(&rt.event_bus),
))
});
if compactor.is_none() {
info!("context compactor disabled (no compaction config)");
}
let manager = Arc::new(ChatSessionManager::new(
Arc::clone(&rt.db),
Arc::clone(&models.llm_manager),
config.llm.max_history_messages,
config.llm.max_tool_rounds.unwrap_or(DEFAULT_MAX_TOOL_ROUNDS),
config.llm.max_parallel_subagents.unwrap_or(DEFAULT_MAX_PARALLEL_SUBAGENTS),
config.llm.max_tool_result_chars,
DatetimeConfig { timezone: config.timezone.clone(), ..config.llm.datetime },
Arc::clone(&tools.tools),
Arc::clone(&integrations.mcp),
Arc::clone(&interaction.approval),
Arc::clone(&interaction.clarification),
Arc::clone(&rt.event_bus),
Arc::clone(&models.memory_manager),
Arc::clone(&media.image_generator_manager),
compactor,
Arc::clone(&run_context_manager),
Arc::new(ToolDiscovery::new(Arc::clone(&rt.db))),
));
let chat_hub = ChatHub::new(
Arc::clone(&rt.db),
Arc::clone(&manager),
Arc::clone(&interaction.approval),
rt.global_tx.clone(),
rt.shutdown_token.clone(),
);
chat_hub.register("web").await;
chat_hub.register("talk").await;
let tic_manager = TicManager::new(
Arc::clone(&rt.db),
Arc::clone(&manager),
Arc::clone(&chat_hub),
config.tic.clone(),
Arc::clone(&rt.config),
Arc::clone(&run_context_manager),
Arc::clone(&rt.system_bus),
);
Ok(Conversation { manager, chat_hub, run_context_manager, tic_manager })
}
}
// ── Infra: leftover singletons ──────────────────────────────────────────────
pub(super) struct Infra {
pub(super) latex_compiler: LatexCompiler,
pub(super) location_manager: Arc<LocationManager>,
pub(super) remote: Arc<RwLock<Option<Arc<dyn RemoteAccess>>>>,
}
impl Infra {
pub(super) fn build() -> Self {
Infra {
latex_compiler: LatexCompiler::new(),
location_manager: Arc::new(LocationManager::new()),
remote: Arc::new(RwLock::new(None)),
}
}
}
+99
View File
@@ -0,0 +1,99 @@
//! `Skald` — the headless application core.
//!
//! `Skald` owns every manager but is no longer a God Object: the ~30 managers are
//! grouped into a cross-cutting [`Runtime`] context plus eight cohesive domain
//! bundles (see [`bundles`]). Construction is a staged composition root (each bundle
//! has its own `build()`); the construction cycles are resolved in one place by
//! [`wiring::wire`]; every background task is registered with a [`TaskSupervisor`]
//! so shutdown joins them uniformly. The frontend and plugin context consume `Skald`
//! only through the accessor methods in [`accessors`], never its fields — that
//! accessor surface is the logical boundary a future `skald-core` crate would keep.
use std::sync::Arc;
use anyhow::Result;
use sqlx::SqlitePool;
use tracing::info;
use core_api::plugin::Plugin;
use super::config::CoreConfig;
mod accessors;
mod bundles;
mod runtime;
mod supervisor;
mod wiring;
use bundles::{Conversation, Infra, Integrations, Interaction, Media, Models, Tasks, Tools};
use runtime::Runtime;
use wiring::{spawn_background, wire};
pub struct Skald {
rt: Runtime,
models: Models,
media: Media,
tools: Tools,
integrations: Integrations,
tasks: Tasks,
conversation: Conversation,
interaction: Interaction,
infra: Infra,
}
impl Skald {
pub async fn new(pool: Arc<SqlitePool>, config: &CoreConfig, plugins: Vec<Arc<dyn Plugin>>) -> Result<Arc<Self>> {
let discovered = super::agents::discover()?;
info!(
count = discovered.len(),
agents = discovered.iter().map(|a| a.id.as_str()).collect::<Vec<_>>().join(", "),
"agents discovered"
);
// ── Composition root: build the runtime context, then each domain bundle
// in dependency order. `Tasks` precedes `Tools` (tools capture cron);
// `Interaction` and `Conversation` come last (they need the tool registry
// and each other's managers).
let rt = Runtime::bootstrap(pool);
let models = Models::build(&rt, config).await?;
let media = Media::build(&rt, &models).await?;
let integrations = Integrations::build(&rt, plugins);
let tasks = Tasks::build(&rt, config);
let tools = Tools::build(&integrations, &tasks, &models);
let interaction = Interaction::build(&rt, &tools).await?;
let conversation = Conversation::build(&rt, &models, &media, &tools, &integrations, &interaction, config).await?;
let infra = Infra::build();
// Resolve construction cycles, then start background tasks.
wire(&tasks, &conversation, &integrations, &interaction);
spawn_background(&rt, &tasks, &conversation, &integrations, config);
let skald = Arc::new(Skald {
rt, models, media, tools, integrations, tasks, conversation, interaction, infra,
});
// Inject the fully-constructed instance into the plugin manager — the one
// Arc<Skald> back-reference. start_enabled()/start_config_watcher() run later,
// from WebFrontend::start, once the router factory is wired.
skald.plugin_manager().set_skald(Arc::clone(&skald));
Ok(skald)
}
pub fn subscribe_chat_events(&self) -> tokio::sync::broadcast::Receiver<core_api::bus::BusEvent> {
self.rt.event_bus.subscribe()
}
pub fn subscribe_system_events(&self) -> tokio::sync::broadcast::Receiver<core_api::system_bus::SystemEvent> {
self.rt.system_bus.subscribe()
}
pub async fn shutdown(self: Arc<Self>) {
self.rt.shutdown_token.cancel();
self.rt.supervisor.join_all(tokio::time::Duration::from_secs(10)).await;
self.integrations.plugin_manager.stop_all().await;
// Last: every user key leaves RAM. A restarted box is opaque again until
// each user unlocks their own database (§9).
self.rt.users.lock_all().await;
}
}
+70
View File
@@ -0,0 +1,70 @@
//! Cross-cutting runtime context.
//!
//! `Runtime` holds the primitives every domain bundle needs: the DB pool, the
//! global config manager, the two event buses, the server→client broadcast
//! channel, the shutdown token and the background-task supervisor. It is built
//! first and passed by reference into each bundle builder, so no bundle has to
//! depend on another purely to reach a shared primitive. This is also the natural
//! seam a future extracted `skald-core` crate would expose as its root context.
use std::sync::Arc;
use sqlx::SqlitePool;
use tokio::sync::broadcast;
use tokio_util::sync::CancellationToken;
use tracing::info;
use core_api::events::GlobalEvent;
use core_api::system_bus::SystemEventBus;
use crate::chat_event_bus::ChatEventBus;
use crate::config_store::GlobalConfigManager;
use crate::users::UserManager;
use super::supervisor::TaskSupervisor;
pub(super) struct Runtime {
/// The registry pool (`system.db`). Still the only pool anything reads or
/// writes: nothing has moved to per-user pools yet. `users` owns those.
pub(super) db: Arc<SqlitePool>,
pub(super) users: Arc<UserManager>,
pub(super) config: Arc<GlobalConfigManager>,
pub(super) config_properties: Vec<core_api::ConfigSet>,
pub(super) system_bus: Arc<SystemEventBus>,
pub(super) event_bus: Arc<ChatEventBus>,
/// Server→client push channel (`ServerEvent` wrapped in `GlobalEvent`). Shared
/// into approval / clarification / elicitation / chat_hub; consumed by the WS
/// handlers. Hoisted here so it exists before the bundles that need it.
pub(super) global_tx: broadcast::Sender<GlobalEvent>,
pub(super) shutdown_token: CancellationToken,
pub(super) supervisor: Arc<TaskSupervisor>,
}
impl Runtime {
/// Wires the cross-cutting primitives. Infallible.
pub(super) fn bootstrap(pool: Arc<SqlitePool>) -> Self {
let config = Arc::new(GlobalConfigManager::new(Arc::clone(&pool)));
let users = Arc::new(UserManager::new(Arc::clone(&pool)));
let system_bus = Arc::new(SystemEventBus::new());
info!("system event bus ready");
let event_bus = Arc::new(ChatEventBus::new());
info!("chat event bus ready");
let (global_tx, _) = broadcast::channel::<GlobalEvent>(512);
Runtime {
db: pool,
users,
config,
config_properties: vec![crate::tic::config_set()],
system_bus,
event_bus,
global_tx,
shutdown_token: CancellationToken::new(),
supervisor: TaskSupervisor::new(),
}
}
}
+59
View File
@@ -0,0 +1,59 @@
//! Background-task supervision.
//!
//! Every long-lived task spawned during `Skald::new` is registered here by name so
//! that `Skald::shutdown` can join them all against a single deadline and report any
//! laggards individually. This replaces the previous `bg_handles` vec, which only
//! tracked a subset of the spawned tasks (leaving the log-cleanup loop and
//! `mcp.initialize` fire-and-forget and never awaited).
use std::future::Future;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tokio::task::JoinHandle;
use tracing::warn;
/// Tracks named background-task handles for graceful shutdown.
pub struct TaskSupervisor {
handles: Mutex<Vec<(&'static str, JoinHandle<()>)>>,
}
impl TaskSupervisor {
pub fn new() -> Arc<Self> {
Arc::new(Self { handles: Mutex::new(Vec::new()) })
}
/// Spawn a named future and track its handle.
pub fn spawn<F>(&self, name: &'static str, fut: F)
where
F: Future<Output = ()> + Send + 'static,
{
self.handles.lock().unwrap().push((name, tokio::spawn(fut)));
}
/// Adopt an already-spawned handle (for managers whose `start()` returns one).
pub fn adopt_one(&self, name: &'static str, handle: JoinHandle<()>) {
self.handles.lock().unwrap().push((name, handle));
}
/// Adopt a batch of handles (e.g. `cron.start()` returns `Vec<JoinHandle<()>>`).
pub fn adopt(&self, name: &'static str, handles: Vec<JoinHandle<()>>) {
let mut guard = self.handles.lock().unwrap();
for h in handles {
guard.push((name, h));
}
}
/// Join all tracked tasks against a shared deadline, logging any that do not
/// finish in time by name. Dropping a timed-out `JoinHandle` does not abort the
/// task; every task is already signalled via the shutdown `CancellationToken`.
pub async fn join_all(&self, timeout: Duration) {
let handles = std::mem::take(&mut *self.handles.lock().unwrap());
let deadline = tokio::time::Instant::now() + timeout;
for (name, handle) in handles {
if tokio::time::timeout_at(deadline, handle).await.is_err() {
warn!(task = name, "background task did not finish within shutdown deadline");
}
}
}
}
+101
View File
@@ -0,0 +1,101 @@
//! 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.
use std::sync::Arc;
use tracing::info;
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.
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));
tasks.ticket_manager.set_task_manager(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 every long-lived background task, each registered by name with the
/// supervisor so it is joined on shutdown. MCP `initialize()` is spawned here —
/// after `wire()` has installed the elicitation handler — so stdio servers start
/// with a handler for server-initiated `elicitation/create` requests.
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(),
),
);
}
// Session-cancellation subscriber: fans SessionCancelled events on the system
// bus into cancel_session() so any in-flight turn / approval / clarification
// all unblock.
{
let manager_ref = Arc::clone(&conversation.manager);
let mut rx = rt.system_bus.subscribe();
let sd = rt.shutdown_token.clone();
rt.supervisor.spawn("session-cancel", async move {
loop {
tokio::select! {
_ = sd.cancelled() => break,
event = rx.recv() => match event {
Ok(core_api::system_bus::SystemEvent::SessionCancelled { session_id }) => {
manager_ref.cancel_session(session_id).await;
}
Ok(_) => {}
Err(_) => break,
}
}
}
});
}
// 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() => {}
}
});
}
rt.supervisor.adopt("cron", Arc::clone(&tasks.cron).start(rt.shutdown_token.clone()));
info!("cron scheduler started");
rt.supervisor.adopt_one(
"ticket-listener",
Arc::clone(&tasks.ticket_manager).start_listener(Arc::clone(&rt.system_bus), rt.shutdown_token.clone()),
);
rt.supervisor.adopt_one("tic", Arc::clone(&conversation.tic_manager).start(rt.shutdown_token.clone()));
info!("TicManager started");
}