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:
@@ -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 ────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user