Multuser part 1
This commit is contained in:
@@ -52,6 +52,17 @@ impl Skald {
|
||||
// Runtime / cross-cutting
|
||||
pub fn db(&self) -> &Arc<SqlitePool> { &self.rt.db }
|
||||
pub fn users(&self) -> &Arc<UserManager> { &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<Arc<super::UserContext>> {
|
||||
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 }
|
||||
pub fn sessions(&self) -> &Arc<crate::auth::SessionStore> { &self.rt.sessions }
|
||||
pub fn config(&self) -> &Arc<GlobalConfigManager> { &self.rt.config }
|
||||
pub fn config_properties(&self) -> &[core_api::ConfigSet] { &self.rt.config_properties }
|
||||
|
||||
@@ -226,7 +226,7 @@ impl Tools {
|
||||
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::cron_jobs::DeleteCronJob);
|
||||
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)));
|
||||
|
||||
@@ -23,10 +23,13 @@ mod accessors;
|
||||
mod bundles;
|
||||
mod runtime;
|
||||
mod supervisor;
|
||||
mod user_context;
|
||||
mod wiring;
|
||||
|
||||
use bundles::{Conversation, Infra, Integrations, Interaction, Media, Models, Tasks, Tools};
|
||||
use runtime::Runtime;
|
||||
use user_context::{UserContextFactory, UserContextRegistry};
|
||||
pub use user_context::UserContext;
|
||||
use wiring::{spawn_background, wire};
|
||||
|
||||
pub struct Skald {
|
||||
@@ -39,6 +42,10 @@ pub struct Skald {
|
||||
conversation: Conversation,
|
||||
interaction: Interaction,
|
||||
infra: Infra,
|
||||
/// Per-user owner-bound runtimes (chat/hub/cron/interaction), built lazily on
|
||||
/// first use after a user's pool is unlocked. The global bundles above still
|
||||
/// serve deferred subsystems and the not-yet-migrated call sites.
|
||||
user_contexts: UserContextRegistry,
|
||||
}
|
||||
|
||||
impl Skald {
|
||||
@@ -68,8 +75,15 @@ impl Skald {
|
||||
wire(&tasks, &conversation, &integrations, &interaction);
|
||||
spawn_background(&rt, &tasks, &conversation, &integrations, config);
|
||||
|
||||
// Per-user context factory: captures the global capability managers, so a
|
||||
// per-user chat/hub/cron/interaction stack can be stamped out on demand.
|
||||
let user_contexts = UserContextRegistry::new(UserContextFactory::new(
|
||||
&rt, &models, &media, &tools, &integrations, &conversation, config,
|
||||
));
|
||||
|
||||
let skald = Arc::new(Skald {
|
||||
rt, models, media, tools, integrations, tasks, conversation, interaction, infra,
|
||||
user_contexts,
|
||||
});
|
||||
|
||||
// Inject the fully-constructed instance into the plugin manager — the one
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
//! Per-user runtime context (blueprint §9 / §11 / §5.1).
|
||||
//!
|
||||
//! The owner-bound managers — chat sessions, chat hub, cron, and the
|
||||
//! approval/clarification/elicitation/inbox interaction stack — must operate on
|
||||
//! *one* user's `{userid}.db` and emit on *one* user's server→client channel, so
|
||||
//! that no chat content, WS event, job or pending approval crosses between users.
|
||||
//! They are built **lazily** on first use after the user's pool is unlocked, and
|
||||
//! live exactly as long as that pool (§9: from first login until restart).
|
||||
//!
|
||||
//! `UserManager` stays the pool-lifecycle owner (§11 boundary). This factory sits
|
||||
//! at the `Skald` layer, where the global *capability* managers (LLM, tools, MCP,
|
||||
//! memory, providers) are visible, and stamps out the per-user instances against a
|
||||
//! given pool, wiring their construction cycles and starting the per-user cron
|
||||
//! loop. Capability managers are shared by reference; only owner-bound state is
|
||||
//! per-user.
|
||||
//!
|
||||
//! Split of pools inside one context: session/history/jobs/hub use the **user
|
||||
//! pool**; approval *rules* and `known_tools` are instance-wide registry data, so
|
||||
//! `ApprovalManager` and `ToolDiscovery` read the **registry pool** (`system.db`)
|
||||
//! while `ApprovalManager` still emits on the user's channel and keeps its own
|
||||
//! pending map.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use chrono_tz::Tz;
|
||||
use sqlx::SqlitePool;
|
||||
use tokio::sync::{broadcast, Mutex};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use core_api::events::GlobalEvent;
|
||||
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::compactor::ContextCompactor;
|
||||
use crate::config::{CompactionConfig, CoreConfig, DatetimeConfig};
|
||||
use crate::cron::TaskManager;
|
||||
use crate::elicitation::ElicitationManager;
|
||||
use crate::image_generate::ImageGeneratorManager;
|
||||
use crate::inbox::Inbox;
|
||||
use crate::llm::LlmManager;
|
||||
use crate::mcp::McpManager;
|
||||
use crate::memory::MemoryManager;
|
||||
use crate::run_context::RunContextManager;
|
||||
use crate::session::handler::{DEFAULT_MAX_PARALLEL_SUBAGENTS, DEFAULT_MAX_TOOL_ROUNDS};
|
||||
use crate::session::manager::ChatSessionManager;
|
||||
use crate::tool_discovery::ToolDiscovery;
|
||||
use crate::tools::ToolRegistry;
|
||||
|
||||
use super::bundles::{Conversation, Integrations, Media, Models, Tools};
|
||||
use super::runtime::Runtime;
|
||||
|
||||
/// One unlocked user's owner-bound runtime. Lifetime = the pool's lifetime.
|
||||
pub struct UserContext {
|
||||
pub user_id: String,
|
||||
pub pool: Arc<SqlitePool>,
|
||||
pub event_bus: Arc<ChatEventBus>,
|
||||
pub sessions: Arc<ChatSessionManager>,
|
||||
pub chat_hub: Arc<ChatHub>,
|
||||
pub cron: Arc<TaskManager>,
|
||||
pub approval: Arc<ApprovalManager>,
|
||||
pub clarification: Arc<ClarificationManager>,
|
||||
pub elicitation: Arc<ElicitationManager>,
|
||||
pub inbox: Inbox,
|
||||
/// Per-user server→client push channel. WS handlers subscribe here (via the
|
||||
/// hub) so a user's `ServerEvent`s never reach another user's socket.
|
||||
pub global_tx: broadcast::Sender<GlobalEvent>,
|
||||
}
|
||||
|
||||
/// Captures the global capability managers + resolved config once, and stamps out
|
||||
/// a [`UserContext`] per unlocked pool.
|
||||
pub(super) struct UserContextFactory {
|
||||
registry_pool: Arc<SqlitePool>,
|
||||
llm_manager: Arc<LlmManager>,
|
||||
tools: Arc<ToolRegistry>,
|
||||
mcp: Arc<McpManager>,
|
||||
memory_manager: Arc<MemoryManager>,
|
||||
image_generator_manager: Arc<ImageGeneratorManager>,
|
||||
run_context_manager: Arc<RunContextManager>,
|
||||
system_bus: Arc<SystemEventBus>,
|
||||
supervisor: Arc<super::supervisor::TaskSupervisor>,
|
||||
shutdown_token: CancellationToken,
|
||||
max_history_messages: usize,
|
||||
max_tool_rounds: usize,
|
||||
max_parallel_subagents: usize,
|
||||
max_tool_result_chars: Option<usize>,
|
||||
datetime_config: DatetimeConfig,
|
||||
compaction: Option<CompactionConfig>,
|
||||
cron_tz: Option<Tz>,
|
||||
}
|
||||
|
||||
impl UserContextFactory {
|
||||
pub(super) fn new(
|
||||
rt: &Runtime,
|
||||
models: &Models,
|
||||
media: &Media,
|
||||
tools: &Tools,
|
||||
integrations: &Integrations,
|
||||
conversation: &Conversation,
|
||||
config: &CoreConfig,
|
||||
) -> Self {
|
||||
let cron_tz = config.timezone.as_deref().and_then(|s| s.parse::<Tz>().ok());
|
||||
Self {
|
||||
registry_pool: Arc::clone(&rt.db),
|
||||
llm_manager: Arc::clone(&models.llm_manager),
|
||||
tools: Arc::clone(&tools.tools),
|
||||
mcp: Arc::clone(&integrations.mcp),
|
||||
memory_manager: Arc::clone(&models.memory_manager),
|
||||
image_generator_manager: Arc::clone(&media.image_generator_manager),
|
||||
run_context_manager: Arc::clone(&conversation.run_context_manager),
|
||||
system_bus: Arc::clone(&rt.system_bus),
|
||||
supervisor: Arc::clone(&rt.supervisor),
|
||||
shutdown_token: rt.shutdown_token.clone(),
|
||||
max_history_messages: config.llm.max_history_messages,
|
||||
max_tool_rounds: config.llm.max_tool_rounds.unwrap_or(DEFAULT_MAX_TOOL_ROUNDS),
|
||||
max_parallel_subagents: config.llm.max_parallel_subagents.unwrap_or(DEFAULT_MAX_PARALLEL_SUBAGENTS),
|
||||
max_tool_result_chars: config.llm.max_tool_result_chars,
|
||||
datetime_config: DatetimeConfig { timezone: config.timezone.clone(), ..config.llm.datetime },
|
||||
compaction: config.llm.compaction.clone(),
|
||||
cron_tz,
|
||||
}
|
||||
}
|
||||
|
||||
async fn build(&self, user_id: &str, pool: SqlitePool) -> Result<Arc<UserContext>> {
|
||||
let pool = Arc::new(pool);
|
||||
let event_bus = Arc::new(ChatEventBus::new());
|
||||
let (global_tx, _) = broadcast::channel::<GlobalEvent>(512);
|
||||
|
||||
// Interaction stack, per-user. Approval reads the shared registry rules but
|
||||
// emits on this user's channel and keeps its own pending map — no cross-user
|
||||
// collision on request_id / session_id. Rules are seeded once by the global
|
||||
// ApprovalManager at boot, so no re-seeding here.
|
||||
let approval = Arc::new(ApprovalManager::new(Arc::clone(&self.registry_pool), global_tx.clone()));
|
||||
let clarification = ClarificationManager::new(global_tx.clone());
|
||||
let elicitation = ElicitationManager::new(global_tx.clone());
|
||||
let inbox = Inbox::new(
|
||||
Arc::clone(&approval),
|
||||
Arc::clone(&clarification),
|
||||
Arc::clone(&elicitation),
|
||||
Arc::clone(&self.tools),
|
||||
);
|
||||
|
||||
let compactor = self.compaction.as_ref().map(|cfg| {
|
||||
Arc::new(ContextCompactor::new(
|
||||
cfg.clone(),
|
||||
Arc::clone(&self.llm_manager),
|
||||
Arc::clone(&event_bus),
|
||||
))
|
||||
});
|
||||
|
||||
let manager = Arc::new(ChatSessionManager::new(
|
||||
Arc::clone(&pool),
|
||||
Arc::clone(&self.llm_manager),
|
||||
self.max_history_messages,
|
||||
self.max_tool_rounds,
|
||||
self.max_parallel_subagents,
|
||||
self.max_tool_result_chars,
|
||||
self.datetime_config.clone(),
|
||||
Arc::clone(&self.tools),
|
||||
Arc::clone(&self.mcp),
|
||||
Arc::clone(&approval),
|
||||
Arc::clone(&clarification),
|
||||
Arc::clone(&event_bus),
|
||||
Arc::clone(&self.memory_manager),
|
||||
Arc::clone(&self.image_generator_manager),
|
||||
compactor,
|
||||
Arc::clone(&self.run_context_manager),
|
||||
// known_tools is registry data → discovery writes to the registry pool.
|
||||
Arc::new(ToolDiscovery::new(Arc::clone(&self.registry_pool))),
|
||||
));
|
||||
|
||||
let chat_hub = ChatHub::new(
|
||||
Arc::clone(&pool),
|
||||
Arc::clone(&manager),
|
||||
Arc::clone(&approval),
|
||||
global_tx.clone(),
|
||||
self.shutdown_token.clone(),
|
||||
);
|
||||
chat_hub.register("web").await;
|
||||
chat_hub.register("talk").await;
|
||||
|
||||
let cron = TaskManager::new(Arc::clone(&pool), self.cron_tz, Arc::clone(&self.system_bus));
|
||||
cron.set_session(Arc::clone(&manager));
|
||||
cron.set_hub(Arc::clone(&chat_hub));
|
||||
cron.set_self_arc(Arc::clone(&cron));
|
||||
chat_hub.set_task_mgr(Arc::clone(&cron));
|
||||
|
||||
// Per-user cron loop. `start()` observes the shutdown token, so it stops on
|
||||
// shutdown; adopting it lets the supervisor also join it. The name is leaked
|
||||
// to satisfy the `&'static str` label — bounded by the (small) user count.
|
||||
let name: &'static str = Box::leak(format!("cron:{user_id}").into_boxed_str());
|
||||
self.supervisor.adopt(name, Arc::clone(&cron).start(self.shutdown_token.clone()));
|
||||
|
||||
Ok(Arc::new(UserContext {
|
||||
user_id: user_id.to_string(),
|
||||
pool,
|
||||
event_bus,
|
||||
sessions: manager,
|
||||
chat_hub,
|
||||
cron,
|
||||
approval,
|
||||
clarification,
|
||||
elicitation,
|
||||
inbox,
|
||||
global_tx,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// The live per-user contexts, keyed by user id, plus the factory that builds them.
|
||||
/// A `tokio::Mutex` serialises the build so a context (and its cron loop) is created
|
||||
/// at most once per user, even under concurrent first-use.
|
||||
pub(super) struct UserContextRegistry {
|
||||
factory: UserContextFactory,
|
||||
contexts: Mutex<HashMap<String, Arc<UserContext>>>,
|
||||
}
|
||||
|
||||
impl UserContextRegistry {
|
||||
pub(super) fn new(factory: UserContextFactory) -> Self {
|
||||
Self { factory, contexts: Mutex::new(HashMap::new()) }
|
||||
}
|
||||
|
||||
/// Returns the user's context, building it from `pool` on first use. Idempotent:
|
||||
/// once built, the same `Arc<UserContext>` is returned until restart.
|
||||
pub(super) async fn resolve(&self, user_id: &str, pool: SqlitePool) -> Result<Arc<UserContext>> {
|
||||
let mut guard = self.contexts.lock().await;
|
||||
if let Some(ctx) = guard.get(user_id) {
|
||||
return Ok(Arc::clone(ctx));
|
||||
}
|
||||
let ctx = self.factory.build(user_id, pool).await?;
|
||||
guard.insert(user_id.to_string(), Arc::clone(&ctx));
|
||||
Ok(ctx)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user