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:
2026-07-18 21:38:42 +01:00
parent 2b35312abd
commit 126886e309
109 changed files with 6228 additions and 1390 deletions
+49
View File
@@ -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 }
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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,
+16 -6
View File
@@ -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 ────────────────────────────────────────────────────