Realizes blueprint §6: each user gets a permanent Docker container
(skald-{userid}, our own skald-runtime image with python+node) as their
execution sandbox. Docker is now a hard requirement — a missing daemon fails
Skald::new and the process exits at boot.
- ContainerManager (crates/skald-core/src/container/): docker availability
check, builds skald-runtime from the embedded Dockerfile, reconciles one
running container per active user at boot, stops them at shutdown, and
ensure/remove on user create/delete. Shells the docker CLI (no client crate).
- UserFs (core-api): pure value type carried in ToolContext, mapping the agent's
single namespace — ~/ → homes/{userid}, shared/{X}/ → shared/{X} (membership),
user-memory/ + shared-memory/ → SQLite — to host and container paths.
- execute_cmd now runs inside the caller's container via `docker exec`.
- fs-tools resolve every physical path through UserFs to the per-user host
workspace, host-side, with fail-closed symlink/`..` containment
(resolve_host_path: canonicalize + prefix-check). grep_files resolves its root
the same way but stays disk-only.
- shared_folders + shared_folder_members (registry, junction table with
can_write) back the shared-folder membership that drives both the container
mounts and the shared/{X} routing.
- Threading: UserContext.fs → ChatSessionManager → handler → ToolContext.fs.
Per-user MCP servers do not yet run in the container (next round).
140 lines
5.9 KiB
Rust
140 lines
5.9 KiB
Rust
//! `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;
|
|
use crate::container::ContainerManager;
|
|
|
|
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 {
|
|
rt: Runtime,
|
|
models: Models,
|
|
media: Media,
|
|
tools: Tools,
|
|
integrations: Integrations,
|
|
tasks: Tasks,
|
|
conversation: Conversation,
|
|
interaction: Interaction,
|
|
infra: Infra,
|
|
/// Per-user Docker containers (blueprint §6): the execution sandbox. Docker is a
|
|
/// hard requirement — `new()` fails if the daemon is unreachable.
|
|
container: ContainerManager,
|
|
/// 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 {
|
|
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);
|
|
|
|
// Docker is REQUIRED (blueprint §6): fail fast, before the heavy managers,
|
|
// if the daemon is unreachable — the shell then exits with this error.
|
|
let container = ContainerManager::new(Arc::clone(&rt.db));
|
|
container.check_docker().await?;
|
|
|
|
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(&rt, &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);
|
|
|
|
// 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,
|
|
));
|
|
|
|
// Build the runtime image and reconcile a container for every active user.
|
|
// A failed image build is fatal (nothing can run); a single container that
|
|
// won't start is logged, not fatal.
|
|
container.reconcile_all().await?;
|
|
|
|
let skald = Arc::new(Skald {
|
|
rt, models, media, tools, integrations, tasks, conversation, interaction, infra,
|
|
container,
|
|
user_contexts,
|
|
});
|
|
|
|
// 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;
|
|
// Stop the per-user containers (best-effort).
|
|
if let Err(e) = self.container.stop_all().await {
|
|
tracing::warn!(error = %e, "failed to stop user containers");
|
|
}
|
|
// 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;
|
|
}
|
|
|
|
/// The container manager, so the API layer can provision (on user create) or
|
|
/// remove (on user delete) a user's container.
|
|
pub fn container(&self) -> ContainerManager {
|
|
self.container.clone()
|
|
}
|
|
}
|