Major rebranding, i18n, dashboard, shared folders, role capabilities
- Rebrand: new app/agent icons, SKALD.md, warm "paper" CSS palette (terracotta accent, --radius tokens, WCAG contrast, reduced-motion), updated favicon, tray icon, skaldkonur asset - i18n: backend crate (i18n.rs, locale column, ui_locale config), frontend library (web/lib/i18n.js, I18nMixin, t(key)), translation files (web/i18n/), every component wired - Dashboard: <dashboard-page> replaces old home-page content; <app-copilot> becomes the landing page (full/dock layout modes) - Shared folders: API endpoints (shared_folders.rs), frontend page, can_write membership, container mount topology, user_fs routing - Role capabilities: new db table & authorization seam (data not enums), roles.attrs JSON for ui_mode / interface select - Setup: skald-setup prompts for language + password, sets ui_locale - General: components migrated to CSS variables, Lit conventions cleanup, connectors/catalog/marketplace/approval refactoring
This commit is contained in:
@@ -18,6 +18,7 @@
|
||||
//! fs-tools *before* reaching here; `UserFs` only ever sees physical paths.
|
||||
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
/// One shared folder mounted into a user's container.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -128,6 +129,40 @@ fn strip_home_prefix(path: &str) -> &str {
|
||||
}
|
||||
}
|
||||
|
||||
/// A hot-swappable handle to a [`UserFs`] snapshot, shared by every holder that
|
||||
/// must observe a membership change without being rebuilt (blueprint §6 remount).
|
||||
///
|
||||
/// Cloning shares the *same* cell. `store` replaces the snapshot for all clones at
|
||||
/// once; each `load` returns the current `Arc<UserFs>`. A live chat session's
|
||||
/// handler holds a clone, so a shared-folder change reaches it on its next tool
|
||||
/// call — no handler eviction, and no cross-session race (the swap is a single
|
||||
/// pointer store behind the lock, and each `ToolContext` takes a consistent
|
||||
/// snapshot for the duration of its call).
|
||||
#[derive(Clone)]
|
||||
pub struct SharedFs(Arc<RwLock<Arc<UserFs>>>);
|
||||
|
||||
impl SharedFs {
|
||||
pub fn new(fs: UserFs) -> Self {
|
||||
Self(Arc::new(RwLock::new(Arc::new(fs))))
|
||||
}
|
||||
|
||||
/// The current snapshot. Cheap — clones an `Arc`.
|
||||
pub fn load(&self) -> Arc<UserFs> {
|
||||
Arc::clone(&self.0.read().expect("SharedFs lock poisoned"))
|
||||
}
|
||||
|
||||
/// Replace the snapshot seen by every holder of this cell.
|
||||
pub fn store(&self, fs: UserFs) {
|
||||
*self.0.write().expect("SharedFs lock poisoned") = Arc::new(fs);
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for SharedFs {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_tuple("SharedFs").field(&*self.load()).finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure lexical normalization (resolve `.`/`..`), no filesystem access.
|
||||
fn normalize(p: &Path) -> PathBuf {
|
||||
let mut out = PathBuf::new();
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
use std::path::PathBuf;
|
||||
use std::process::Stdio;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use sqlx::SqlitePool;
|
||||
@@ -39,6 +40,9 @@ pub const HOMES_DIR: &str = "homes";
|
||||
pub const SHARED_DIR: &str = "shared";
|
||||
/// Home mount point inside the container.
|
||||
pub const CONTAINER_HOME: &str = "/root";
|
||||
/// Grace window `docker stop` gives in-container processes (SIGTERM → SIGKILL)
|
||||
/// before force-killing — enough for a shell or MCP `docker exec` child to exit.
|
||||
const STOP_GRACE: Duration = Duration::from_secs(10);
|
||||
|
||||
/// The deterministic container name for a user — derivable without any manager,
|
||||
/// so `UserFs` can carry it and `execute_cmd` can exec into it directly.
|
||||
@@ -203,6 +207,38 @@ impl ContainerManager {
|
||||
let _ = docker(&["rm", "-f", &name]).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Gracefully shuts down a user's container: `docker stop` sends SIGTERM to the
|
||||
/// in-container processes and waits up to `STOP_GRACE` before SIGKILL, so an
|
||||
/// in-flight `execute_cmd` shell (and any per-user MCP `docker exec` child) gets
|
||||
/// a window to exit cleanly instead of vanishing mid-write. Best-effort: a
|
||||
/// missing or already-stopped container is fine.
|
||||
pub async fn stop(&self, user_id: &str) -> Result<()> {
|
||||
let name = container_name(user_id);
|
||||
let secs = STOP_GRACE.as_secs().to_string();
|
||||
if let Err(e) = docker(&["stop", "-t", &secs, &name]).await {
|
||||
tracing::debug!(container = %name, error = %e, "container stop (ignored)");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Cleanly recreates a user's container so it picks up a changed mount topology
|
||||
/// — e.g. a shared-folder membership change (§6), whose mounts are fixed at
|
||||
/// `docker create` time and cannot be altered on a live container. Graceful
|
||||
/// [`stop`](Self::stop) → remove → [`ensure`](Self::ensure) (which rebuilds the
|
||||
/// mount set from the current memberships and recreates the host dirs). The
|
||||
/// container holds no durable state — everything lives in the bind mounts — so a
|
||||
/// recreate is safe by construction. A no-op-safe `rm` (the container is already
|
||||
/// stopped) precedes `ensure`, which then finds it absent and creates it fresh.
|
||||
///
|
||||
/// Caveat (caller's concern, not this method's): the per-user MCP runtime and a
|
||||
/// logged-in user's `UserFs` snapshot are both bound to the old container/
|
||||
/// membership and are NOT refreshed here — see the shared-folders remount wiring.
|
||||
pub async fn recreate(&self, user_id: &str) -> Result<()> {
|
||||
self.stop(user_id).await?;
|
||||
let _ = docker(&["rm", &container_name(user_id)]).await;
|
||||
self.ensure(user_id).await
|
||||
}
|
||||
}
|
||||
|
||||
// ── docker CLI helpers ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -400,6 +400,7 @@ async fn create_registry_tables(pool: &SqlitePool) -> Result<()> {
|
||||
database_password BLOB,
|
||||
password_hash BLOB,
|
||||
active INTEGER NOT NULL DEFAULT 1,
|
||||
locale TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
CHECK (
|
||||
@@ -410,6 +411,8 @@ async fn create_registry_tables(pool: &SqlitePool) -> Result<()> {
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
// Per-user UI locale override is additive — reaches an existing DB in place.
|
||||
ensure_column(pool, "users", "locale", "TEXT").await?;
|
||||
|
||||
// Shared on-disk folders (blueprint §6/§0.1): a named directory
|
||||
// `{WD}/shared/{folder_name}` bind-mounted into the container of each member.
|
||||
@@ -422,11 +425,16 @@ async fn create_registry_tables(pool: &SqlitePool) -> Result<()> {
|
||||
"CREATE TABLE IF NOT EXISTS shared_folders (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
folder_name TEXT NOT NULL UNIQUE,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
// The folder's description is injected into the agent's system context so it
|
||||
// knows what each shared folder holds and when to read/write it. Additive —
|
||||
// reaches an existing DB in place (a no-op on the fresh CREATE above).
|
||||
ensure_column(pool, "shared_folders", "description", "TEXT NOT NULL DEFAULT ''").await?;
|
||||
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS shared_folder_members (
|
||||
|
||||
@@ -25,6 +25,12 @@ pub const REGISTER_LOCAL_SCRIPT: &str = "mcp.register_local_script";
|
||||
/// Curate the connector catalog (admin only).
|
||||
pub const MANAGE_CATALOG: &str = "mcp.manage_catalog";
|
||||
|
||||
/// Manage shared on-disk folders — create/describe/delete and grant membership
|
||||
/// (blueprint §6). Admin-only for now; not in [`DEFAULT_USER_CAPABILITIES`], so
|
||||
/// `admin` holds it implicitly (via [`has`]) and opening it to another role later
|
||||
/// is a single [`grant`], no code change.
|
||||
pub const MANAGE_SHARED_FOLDERS: &str = "folders.manage";
|
||||
|
||||
/// The default capabilities of an ordinary (non-admin) user role.
|
||||
pub const DEFAULT_USER_CAPABILITIES: &[&str] = &[REGISTER_REMOTE, REGISTER_LOCAL_FROM_CATALOG];
|
||||
|
||||
|
||||
@@ -15,6 +15,9 @@ use sqlx::SqlitePool;
|
||||
pub struct SharedFolder {
|
||||
pub id: i64,
|
||||
pub folder_name: String,
|
||||
/// What the folder holds — injected into the agent's system context so it
|
||||
/// knows what to store here and when to read it. Admin-authored (§6).
|
||||
pub description: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
@@ -59,25 +62,50 @@ pub async fn list_for_user(pool: &SqlitePool, user_id: &str) -> Result<Vec<Share
|
||||
}
|
||||
|
||||
pub async fn list_all(pool: &SqlitePool) -> Result<Vec<SharedFolder>> {
|
||||
let rows = sqlx::query_as::<_, (i64, String, String)>(
|
||||
"SELECT id, folder_name, created_at FROM shared_folders ORDER BY folder_name",
|
||||
let rows = sqlx::query_as::<_, (i64, String, String, String)>(
|
||||
"SELECT id, folder_name, description, created_at FROM shared_folders ORDER BY folder_name",
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|(id, folder_name, created_at)| SharedFolder { id, folder_name, created_at })
|
||||
.map(|(id, folder_name, description, created_at)| SharedFolder {
|
||||
id,
|
||||
folder_name,
|
||||
description,
|
||||
created_at,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn get(pool: &SqlitePool, folder_id: i64) -> Result<Option<SharedFolder>> {
|
||||
let row = sqlx::query_as::<_, (i64, String, String, String)>(
|
||||
"SELECT id, folder_name, description, created_at FROM shared_folders WHERE id = ?",
|
||||
)
|
||||
.bind(folder_id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(row.map(|(id, folder_name, description, created_at)| SharedFolder {
|
||||
id,
|
||||
folder_name,
|
||||
description,
|
||||
created_at,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn get_by_name(pool: &SqlitePool, folder_name: &str) -> Result<Option<SharedFolder>> {
|
||||
let row = sqlx::query_as::<_, (i64, String, String)>(
|
||||
"SELECT id, folder_name, created_at FROM shared_folders WHERE folder_name = ?",
|
||||
let row = sqlx::query_as::<_, (i64, String, String, String)>(
|
||||
"SELECT id, folder_name, description, created_at FROM shared_folders WHERE folder_name = ?",
|
||||
)
|
||||
.bind(folder_name)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(row.map(|(id, folder_name, created_at)| SharedFolder { id, folder_name, created_at }))
|
||||
Ok(row.map(|(id, folder_name, description, created_at)| SharedFolder {
|
||||
id,
|
||||
folder_name,
|
||||
description,
|
||||
created_at,
|
||||
}))
|
||||
}
|
||||
|
||||
/// The members of a folder — the set of users whose containers mount it.
|
||||
@@ -98,15 +126,27 @@ pub async fn members(pool: &SqlitePool, folder_id: i64) -> Result<Vec<FolderMemb
|
||||
|
||||
/// Creates a folder, returning its id. `folder_name` must already be validated as
|
||||
/// a safe path component (see [`is_valid_folder_name`]).
|
||||
pub async fn create(pool: &SqlitePool, folder_name: &str) -> Result<i64> {
|
||||
let id = sqlx::query("INSERT INTO shared_folders (folder_name) VALUES (?)")
|
||||
pub async fn create(pool: &SqlitePool, folder_name: &str, description: &str) -> Result<i64> {
|
||||
let id = sqlx::query("INSERT INTO shared_folders (folder_name, description) VALUES (?, ?)")
|
||||
.bind(folder_name)
|
||||
.bind(description)
|
||||
.execute(pool)
|
||||
.await?
|
||||
.last_insert_rowid();
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Updates a folder's description — the agent-facing text. No-op if `folder_id`
|
||||
/// no longer exists.
|
||||
pub async fn set_description(pool: &SqlitePool, folder_id: i64, description: &str) -> Result<()> {
|
||||
sqlx::query("UPDATE shared_folders SET description = ? WHERE id = ?")
|
||||
.bind(description)
|
||||
.bind(folder_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Adds (or updates the capability of) a member. Idempotent on the PK.
|
||||
pub async fn add_member(
|
||||
pool: &SqlitePool,
|
||||
|
||||
@@ -65,6 +65,8 @@ pub struct User {
|
||||
pub role_id: String,
|
||||
pub credentials: Credentials,
|
||||
pub active: bool,
|
||||
/// UI locale override (NULL = follow the instance default).
|
||||
pub locale: Option<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
@@ -78,6 +80,7 @@ pub struct UserSummary {
|
||||
pub role_id: String,
|
||||
pub encrypted: bool,
|
||||
pub active: bool,
|
||||
pub locale: Option<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
@@ -95,6 +98,7 @@ impl User {
|
||||
role_id: self.role_id.clone(),
|
||||
encrypted: self.is_encrypted(),
|
||||
active: self.active,
|
||||
locale: self.locale.clone(),
|
||||
created_at: self.created_at.clone(),
|
||||
updated_at: self.updated_at.clone(),
|
||||
}
|
||||
@@ -140,6 +144,7 @@ struct Row {
|
||||
database_password: Option<Vec<u8>>,
|
||||
password_hash: Option<Vec<u8>>,
|
||||
active: bool,
|
||||
locale: Option<String>,
|
||||
created_at: String,
|
||||
updated_at: String,
|
||||
}
|
||||
@@ -150,7 +155,7 @@ macro_rules! select {
|
||||
($tail:literal) => {
|
||||
concat!(
|
||||
"SELECT id, username, display_name, role_id, encrypted, kdf_params, kdf_salt, ",
|
||||
"database_password, password_hash, active, created_at, updated_at FROM users ",
|
||||
"database_password, password_hash, active, locale, created_at, updated_at FROM users ",
|
||||
$tail
|
||||
)
|
||||
};
|
||||
@@ -185,6 +190,7 @@ impl TryFrom<Row> for User {
|
||||
role_id: r.role_id,
|
||||
credentials,
|
||||
active: r.active,
|
||||
locale: r.locale,
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
})
|
||||
@@ -370,6 +376,22 @@ pub async fn rename(pool: &SqlitePool, id: &str, username: &str, display_name: O
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sets (or clears, with `None`) the user's UI locale override.
|
||||
pub async fn set_locale(pool: &SqlitePool, id: &str, locale: Option<&str>) -> Result<()> {
|
||||
let n = sqlx::query(
|
||||
"UPDATE users SET locale = ?2, updated_at = datetime('now') WHERE id = ?1",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(locale)
|
||||
.execute(pool)
|
||||
.await?
|
||||
.rows_affected();
|
||||
if n == 0 {
|
||||
bail!("no such user: {id}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Removes the directory row only. The caller still owns `database/{id}.db`:
|
||||
/// erasing a user means deleting that file too.
|
||||
pub async fn delete(pool: &SqlitePool, id: &str) -> Result<()> {
|
||||
@@ -556,6 +578,7 @@ mod tests {
|
||||
role_id: "admin".into(),
|
||||
credentials: encrypted(),
|
||||
active: true,
|
||||
locale: None,
|
||||
created_at: "now".into(),
|
||||
updated_at: "now".into(),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
//! UI localization knobs.
|
||||
//!
|
||||
//! The instance default locale lives in the registry `config` table under
|
||||
//! [`DEFAULT_LOCALE_KEY`], editable by the admin from the Settings page. Each
|
||||
//! user can override it on their own profile (`users.locale`); the frontend
|
||||
//! resolves user → instance → built-in English at boot.
|
||||
|
||||
use core_api::{ConfigProperty, ConfigSet, PropertyType};
|
||||
|
||||
pub const DEFAULT_LOCALE_KEY: &str = "ui_locale";
|
||||
|
||||
/// Locales the web UI ships dictionaries for. Anything else is rejected at
|
||||
/// write time (profile override, first-run setup) rather than silently
|
||||
/// falling back to English later.
|
||||
pub const SUPPORTED_LOCALES: &[&str] = &["en", "it", "fr"];
|
||||
|
||||
pub fn is_supported(locale: &str) -> bool {
|
||||
SUPPORTED_LOCALES.contains(&locale)
|
||||
}
|
||||
|
||||
/// Writes the instance default locale straight to the registry `config` table.
|
||||
/// Used by first-run provisioning shells (e.g. `skald-setup`), where no
|
||||
/// `GlobalConfigManager` — hence no system bus — exists. A running server
|
||||
/// should go through `GlobalConfigManager::set` instead, which also emits the
|
||||
/// change event.
|
||||
pub async fn set_default_locale(pool: &sqlx::SqlitePool, locale: &str) -> anyhow::Result<()> {
|
||||
anyhow::ensure!(is_supported(locale), "unsupported locale: {locale}");
|
||||
sqlx::query(
|
||||
"INSERT INTO config (key, value, updated_at) VALUES (?, ?, datetime('now'))
|
||||
ON CONFLICT(key) DO UPDATE SET
|
||||
value = excluded.value,
|
||||
updated_at = excluded.updated_at",
|
||||
)
|
||||
.bind(DEFAULT_LOCALE_KEY)
|
||||
.bind(locale)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn config_set() -> ConfigSet {
|
||||
ConfigSet {
|
||||
name: "Interface".into(),
|
||||
description: "Look and feel of the web interface.".into(),
|
||||
properties: vec![
|
||||
ConfigProperty {
|
||||
key: DEFAULT_LOCALE_KEY.into(),
|
||||
name: "Language".into(),
|
||||
description: "Default interface language for the whole instance (e.g. en, it). Each user can override it on their profile.".into(),
|
||||
property_type: PropertyType::String,
|
||||
default_value: Some("en".into()),
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ pub mod cron;
|
||||
pub mod db;
|
||||
pub mod events;
|
||||
pub mod image_generate;
|
||||
pub mod i18n;
|
||||
pub mod inbox;
|
||||
pub mod latex;
|
||||
pub mod llm;
|
||||
|
||||
@@ -268,6 +268,17 @@ impl McpManager {
|
||||
self.descriptions.write().unwrap().remove(name);
|
||||
}
|
||||
|
||||
/// Stops **every** running server (each dropped client → `kill_on_drop` kills
|
||||
/// its child process) and forgets them. Used when a per-user container is
|
||||
/// recreated (§6 remount): the old `docker exec -i` children are bound to the
|
||||
/// now-gone container, so they must be torn down before reconnecting against
|
||||
/// the fresh one via [`connect_all`](Self::connect_all).
|
||||
pub fn stop_all(&self) {
|
||||
self.servers.write().unwrap().clear();
|
||||
self.errors.write().unwrap().clear();
|
||||
self.descriptions.write().unwrap().clear();
|
||||
}
|
||||
|
||||
pub fn tools(&self) -> Vec<McpTool> {
|
||||
self.servers.read().unwrap().values()
|
||||
.flat_map(|s| s.tools().iter().cloned())
|
||||
|
||||
@@ -431,7 +431,9 @@ impl ChatSessionHandler {
|
||||
let ctx = ToolContext {
|
||||
session_id: self.session_id,
|
||||
pool: Arc::clone(&self.db),
|
||||
fs: Arc::clone(&self.fs),
|
||||
// Snapshot the fs cell for the duration of this tool call — a concurrent
|
||||
// shared-folder remount swaps the cell, the next call picks it up (§6).
|
||||
fs: self.fs.load(),
|
||||
};
|
||||
self.tools.run(name, &ctx, args)
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ use crate::config::DatetimeConfig;
|
||||
use crate::db::{chat_history, chat_sessions_stack};
|
||||
use crate::events::ServerEvent;
|
||||
use core_api::message_meta::MessageMetadata;
|
||||
use core_api::user_fs::UserFs;
|
||||
use core_api::user_fs::SharedFs;
|
||||
use crate::llm::LlmManager;
|
||||
use crate::mcp::McpProvider;
|
||||
use crate::image_generate::ImageGeneratorManager;
|
||||
@@ -272,8 +272,11 @@ pub struct ChatSessionHandler {
|
||||
pub(super) user_id: String,
|
||||
/// The owner's filesystem view (home + shared folders + container), threaded
|
||||
/// into every [`ToolContext`] so disk fs-tools resolve per-user host paths and
|
||||
/// `execute_cmd` execs into the owner's container (blueprint §6).
|
||||
pub(super) fs: Arc<UserFs>,
|
||||
/// `execute_cmd` execs into the owner's container (blueprint §6). A **shared
|
||||
/// swappable cell** (not a snapshot): a shared-folder membership change is
|
||||
/// applied in place (§6 remount), so a live session picks it up on its next
|
||||
/// tool call without being rebuilt — see [`SharedFs`].
|
||||
pub(super) fs: SharedFs,
|
||||
pub(super) llm_manager: Arc<LlmManager>,
|
||||
pub(super) max_history_messages: usize,
|
||||
pub(super) max_tool_rounds: usize,
|
||||
@@ -343,7 +346,7 @@ impl ChatSessionHandler {
|
||||
db: Arc<SqlitePool>,
|
||||
shared_pool: Arc<SqlitePool>,
|
||||
user_id: String,
|
||||
fs: Arc<UserFs>,
|
||||
fs: SharedFs,
|
||||
llm_manager: Arc<LlmManager>,
|
||||
max_history_messages: usize,
|
||||
max_tool_rounds: usize,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use core_api::user_fs::UserFs;
|
||||
use core_api::user_fs::{SharedFs, UserFs};
|
||||
|
||||
use sqlx::SqlitePool;
|
||||
use tokio::sync::Mutex;
|
||||
@@ -29,8 +29,9 @@ pub struct ChatSessionManager {
|
||||
shared_pool: Arc<SqlitePool>,
|
||||
user_id: String,
|
||||
/// The owner's filesystem view, threaded to each handler and on into every
|
||||
/// `ToolContext` (blueprint §6).
|
||||
user_fs: Arc<UserFs>,
|
||||
/// `ToolContext` (blueprint §6). A shared swappable cell so a shared-folder
|
||||
/// membership change ([`refresh_fs`](Self::refresh_fs)) reaches live sessions.
|
||||
user_fs: SharedFs,
|
||||
llm_manager: Arc<LlmManager>,
|
||||
max_history_messages: usize,
|
||||
max_tool_rounds: usize,
|
||||
@@ -60,7 +61,7 @@ impl ChatSessionManager {
|
||||
db: Arc<SqlitePool>,
|
||||
shared_pool: Arc<SqlitePool>,
|
||||
user_id: String,
|
||||
user_fs: Arc<UserFs>,
|
||||
user_fs: SharedFs,
|
||||
llm_manager: Arc<LlmManager>,
|
||||
max_history_messages: usize,
|
||||
max_tool_rounds: usize,
|
||||
@@ -171,7 +172,7 @@ impl ChatSessionManager {
|
||||
self.db.clone(),
|
||||
self.shared_pool.clone(),
|
||||
self.user_id.clone(),
|
||||
Arc::clone(&self.user_fs),
|
||||
self.user_fs.clone(),
|
||||
Arc::clone(&self.llm_manager),
|
||||
self.max_history_messages,
|
||||
self.max_tool_rounds,
|
||||
@@ -197,4 +198,12 @@ impl ChatSessionManager {
|
||||
self.active.lock().await.insert(session_id, handler.clone());
|
||||
Ok(handler)
|
||||
}
|
||||
|
||||
/// Swaps in a refreshed filesystem view for this owner (blueprint §6 remount).
|
||||
/// Every live session's handler shares the same [`SharedFs`] cell, so the new
|
||||
/// membership reaches each on its next tool call — no handler eviction, no
|
||||
/// cross-session race.
|
||||
pub fn refresh_fs(&self, fs: UserFs) {
|
||||
self.user_fs.store(fs);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +64,55 @@ impl Skald {
|
||||
}
|
||||
|
||||
fn rt_user_contexts(&self) -> &super::user_context::UserContextRegistry { &self.user_contexts }
|
||||
|
||||
/// 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<Arc<super::UserContext>> {
|
||||
self.rt_user_contexts().peek(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.
|
||||
pub async fn refresh_user_shared_folders(&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 {
|
||||
specs.push(crate::mcp::user_row_spec_resolved(r, &container, self.db()).await);
|
||||
}
|
||||
ctx.user_mcp.connect_all(specs, false).await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
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 }
|
||||
|
||||
@@ -346,7 +346,7 @@ impl Conversation {
|
||||
|
||||
// The ownerless manager is inert (no loops, no consumers — see §19): it takes
|
||||
// a placeholder UserFs purely to satisfy the type, never used to resolve a path.
|
||||
let ownerless_fs = Arc::new(core_api::user_fs::UserFs::new(
|
||||
let ownerless_fs = core_api::user_fs::SharedFs::new(core_api::user_fs::UserFs::new(
|
||||
String::new(),
|
||||
std::path::PathBuf::from("homes"),
|
||||
"skald-ownerless",
|
||||
|
||||
@@ -63,7 +63,7 @@ impl Runtime {
|
||||
users,
|
||||
sessions,
|
||||
config,
|
||||
config_properties: vec![crate::tic::config_set()],
|
||||
config_properties: vec![crate::i18n::config_set(), crate::tic::config_set()],
|
||||
system_bus,
|
||||
event_bus,
|
||||
global_tx,
|
||||
|
||||
@@ -35,7 +35,7 @@ use core_api::events::GlobalEvent;
|
||||
use core_api::inbox::InboxApi;
|
||||
use core_api::system_bus::SystemEventBus;
|
||||
use core_api::user_channel::UserChannelHandle;
|
||||
use core_api::user_fs::UserFs;
|
||||
use core_api::user_fs::SharedFs;
|
||||
|
||||
use crate::approval::ApprovalManager;
|
||||
use crate::chat_event_bus::ChatEventBus;
|
||||
@@ -66,8 +66,10 @@ pub struct UserContext {
|
||||
pub user_id: String,
|
||||
pub pool: Arc<SqlitePool>,
|
||||
/// The owner's filesystem view (home + shared folders + container, §6),
|
||||
/// threaded into every `ToolContext` this user's sessions produce.
|
||||
pub fs: Arc<UserFs>,
|
||||
/// threaded into every `ToolContext` this user's sessions produce. A shared
|
||||
/// swappable cell so a shared-folder membership change is applied in place
|
||||
/// (§6 remount) rather than requiring a fresh login — see [`SharedFs`].
|
||||
pub fs: SharedFs,
|
||||
pub event_bus: Arc<ChatEventBus>,
|
||||
pub sessions: Arc<ChatSessionManager>,
|
||||
pub chat_hub: Arc<ChatHub>,
|
||||
@@ -151,8 +153,9 @@ impl UserContextFactory {
|
||||
async fn build(&self, user_id: &str, pool: SqlitePool) -> Result<Arc<UserContext>> {
|
||||
let pool = Arc::new(pool);
|
||||
// The owner's filesystem view: private home + shared folders + container.
|
||||
// Snapshotted at login; a membership change takes effect on next login (v1).
|
||||
let fs = Arc::new(crate::container::build_user_fs(&self.registry_pool, user_id).await?);
|
||||
// A shared swappable cell — a shared-folder membership change is applied in
|
||||
// place while the user is live (§6 remount), not deferred to next login.
|
||||
let fs = SharedFs::new(crate::container::build_user_fs(&self.registry_pool, user_id).await?);
|
||||
let event_bus = Arc::new(ChatEventBus::new());
|
||||
let (global_tx, _) = broadcast::channel::<GlobalEvent>(512);
|
||||
|
||||
@@ -238,7 +241,7 @@ impl UserContextFactory {
|
||||
Arc::clone(&pool),
|
||||
Arc::clone(&self.registry_pool), // shared pool = system.db, for shared-memory injection
|
||||
user_id.to_string(),
|
||||
Arc::clone(&fs),
|
||||
fs.clone(),
|
||||
Arc::clone(&self.llm_manager),
|
||||
self.max_history_messages,
|
||||
self.max_tool_rounds,
|
||||
@@ -340,6 +343,13 @@ impl UserContextRegistry {
|
||||
guard.insert(user_id.to_string(), Arc::clone(&ctx));
|
||||
Ok(ctx)
|
||||
}
|
||||
|
||||
/// The user's context IF already built (live), **without** building one. A user
|
||||
/// who has not logged in has no live snapshot to refresh (blueprint §6 remount):
|
||||
/// their next login builds a fresh context that already reflects the change.
|
||||
pub(super) async fn peek(&self, user_id: &str) -> Option<Arc<UserContext>> {
|
||||
self.contexts.lock().await.get(user_id).cloned()
|
||||
}
|
||||
}
|
||||
|
||||
// ── UserChannelHandle impl ────────────────────────────────────────────────────
|
||||
|
||||
@@ -17,5 +17,6 @@ path = "src/main.rs"
|
||||
skald-core = { path = "../skald-core" }
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
|
||||
anyhow = "1"
|
||||
sqlx = { version = "0.9.0", features = ["runtime-tokio", "sqlite"] }
|
||||
# Reads a password without echoing it to the terminal.
|
||||
rpassword = "7"
|
||||
|
||||
@@ -94,10 +94,12 @@ fn usage() -> String {
|
||||
async fn run(mode: Mode) -> Result<std::process::ExitCode> {
|
||||
// Opening the pool creates `database/system.db` and its schema if absent —
|
||||
// the same call the server makes, so setup and server agree on the layout.
|
||||
let pool = db::init_system_pool(SYSTEM_DB_PATH)
|
||||
.await
|
||||
.context("opening the system database")?;
|
||||
let users = UserManager::new(std::sync::Arc::new(pool));
|
||||
let pool = std::sync::Arc::new(
|
||||
db::init_system_pool(SYSTEM_DB_PATH)
|
||||
.await
|
||||
.context("opening the system database")?,
|
||||
);
|
||||
let users = UserManager::new(std::sync::Arc::clone(&pool));
|
||||
|
||||
let has_admin = users.count().await.context("counting users")? > 0;
|
||||
|
||||
@@ -113,13 +115,13 @@ async fn run(mode: Mode) -> Result<std::process::ExitCode> {
|
||||
// Each is idempotent: it decides for itself whether there is work to do.
|
||||
// Today there is one. Provider and model setup will be added here as further
|
||||
// steps, in order, each skipping itself when already configured.
|
||||
step_first_user(&users, has_admin).await?;
|
||||
step_first_user(&users, &pool, has_admin).await?;
|
||||
|
||||
Ok(std::process::ExitCode::SUCCESS)
|
||||
}
|
||||
|
||||
/// Create the first admin, or do nothing if one already exists.
|
||||
async fn step_first_user(users: &UserManager, has_admin: bool) -> Result<()> {
|
||||
async fn step_first_user(users: &UserManager, pool: &sqlx::SqlitePool, has_admin: bool) -> Result<()> {
|
||||
if has_admin {
|
||||
// Idempotent re-run, or a second binary got there first.
|
||||
return Ok(());
|
||||
@@ -142,6 +144,7 @@ async fn step_first_user(users: &UserManager, has_admin: bool) -> Result<()> {
|
||||
let display_name = display_name.trim();
|
||||
let display_name = (!display_name.is_empty()).then_some(display_name);
|
||||
|
||||
let locale = prompt_locale()?;
|
||||
let encrypt = prompt_encrypt()?;
|
||||
let password = prompt_new_password()?;
|
||||
|
||||
@@ -150,6 +153,12 @@ async fn step_first_user(users: &UserManager, has_admin: bool) -> Result<()> {
|
||||
.await
|
||||
.context("creating the admin user")?;
|
||||
|
||||
// The first-run language choice is instance-wide: the registry config
|
||||
// default every user follows until they override it on their profile.
|
||||
skald_core::i18n::set_default_locale(pool, &locale)
|
||||
.await
|
||||
.context("saving the default language")?;
|
||||
|
||||
println!("\n✓ Admin user '{username}' created (id {id}).");
|
||||
if encrypt {
|
||||
println!(" Their private database is encrypted. There is no recovery if the password is lost.");
|
||||
@@ -177,6 +186,34 @@ fn prompt_username() -> Result<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Interface language, stored as the instance default (`ui_locale`). A menu
|
||||
/// rather than free text so a typo can never land in the config table.
|
||||
fn prompt_locale() -> Result<String> {
|
||||
println!("Interface language / Lingua dell'interfaccia:");
|
||||
for (i, l) in skald_core::i18n::SUPPORTED_LOCALES.iter().enumerate() {
|
||||
let label = match *l {
|
||||
"en" => "English",
|
||||
"it" => "Italiano",
|
||||
other => other,
|
||||
};
|
||||
println!(" {}) {}", i + 1, label);
|
||||
}
|
||||
loop {
|
||||
let ans = prompt_line("Language [1]: ")?;
|
||||
let ans = ans.trim();
|
||||
if ans.is_empty() {
|
||||
return Ok(skald_core::i18n::SUPPORTED_LOCALES[0].to_string());
|
||||
}
|
||||
match ans.parse::<usize>() {
|
||||
Ok(n) if n >= 1 && n <= skald_core::i18n::SUPPORTED_LOCALES.len() => {
|
||||
return Ok(skald_core::i18n::SUPPORTED_LOCALES[n - 1].to_string());
|
||||
}
|
||||
_ if skald_core::i18n::is_supported(ans) => return Ok(ans.to_string()),
|
||||
_ => println!(" Pick a number from the list."),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Default yes, with the honest caveat shown before the choice. For the admin —
|
||||
/// who owns the box — encryption guards against a stolen machine, not against
|
||||
/// the other users (§2/§4); and it has no recovery. The prompt says so.
|
||||
|
||||
Reference in New Issue
Block a user