Users, file manager, image generation, budgets and agent workflow
- User management: local accounts (Argon2) and OpenID Connect, admin/user roles, per-user folders plus admin-shared folders, read-only session sharing, event filtering per user - File manager with CodeMirror editor and git client (status, diff, stage, commit, branches, fetch/pull/push, log, clone/init) - Image generation via fal.ai: generate_image tool and image page - Budgets: monthly limits per provider, provider balances/costs (OpenRouter, Anthropic admin cost report, fal.ai), price discovery - Subagents on other models, background tasks, concurrency limits per host/model/subagent - MCP tools loaded on demand (mcp_search/mcp_call), read-only hints - Permission mode "auto", safe read-only commands always allowed - Persistent message queue with attachments, deletable - Live reasoning incl. <think> tags, activity animations, CSS themes, mobile layout, statistics per user, upload size handling - Docker: named volume for state, localhost-bound port Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f3a2fe3816
commit
49262c612c
70 files changed
+8495
-714
No files matched your search
+155
-3
@@ -10,7 +10,7 @@ use serde_json::{Map, Value};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex, RwLock};
|
||||
use tokio::sync::{broadcast, oneshot};
|
||||
use tokio::sync::{OwnedSemaphorePermit, Semaphore, broadcast, oneshot};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)]
|
||||
@@ -39,8 +39,6 @@ pub struct Runtime {
|
||||
pub cancel: Option<CancellationToken>,
|
||||
pub read_files: HashSet<PathBuf>,
|
||||
pub approved: HashSet<String>,
|
||||
/// Prompts sent while the agent was busy; injected at the next step.
|
||||
pub queue: Vec<String>,
|
||||
}
|
||||
|
||||
pub struct App {
|
||||
@@ -53,6 +51,24 @@ pub struct App {
|
||||
runtime: Mutex<HashMap<String, Runtime>>,
|
||||
permissions: Mutex<HashMap<String, PendingPermission>>,
|
||||
questions: Mutex<HashMap<String, PendingQuestion>>,
|
||||
/// Subagent concurrency limits: key → (size, semaphore). Recreated when the size changes.
|
||||
subagent_limits: Mutex<HashMap<String, (usize, Arc<Semaphore>)>>,
|
||||
/// One-time code for creating the first admin (None once users exist).
|
||||
pub setup_code: Mutex<Option<String>>,
|
||||
pub oidc_pending: Mutex<HashMap<String, crate::auth::OidcPending>>,
|
||||
pub oidc_discovery: Mutex<Option<(String, Value)>>,
|
||||
/// session id → (root session id, owner) – both never change.
|
||||
owners: Mutex<HashMap<String, (String, Option<String>)>>,
|
||||
/// root session id → users it is shared with.
|
||||
shares: Mutex<HashMap<String, Vec<String>>>,
|
||||
}
|
||||
|
||||
/// What a user may do with a session.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Access {
|
||||
None,
|
||||
Read,
|
||||
Full,
|
||||
}
|
||||
|
||||
impl App {
|
||||
@@ -72,7 +88,14 @@ impl App {
|
||||
runtime: Mutex::default(),
|
||||
permissions: Mutex::default(),
|
||||
questions: Mutex::default(),
|
||||
subagent_limits: Mutex::default(),
|
||||
setup_code: Mutex::default(),
|
||||
oidc_pending: Mutex::default(),
|
||||
oidc_discovery: Mutex::default(),
|
||||
owners: Mutex::default(),
|
||||
shares: Mutex::default(),
|
||||
});
|
||||
crate::auth::bootstrap(&app)?;
|
||||
app.clone().start_mcp();
|
||||
Ok(app)
|
||||
}
|
||||
@@ -97,6 +120,81 @@ impl App {
|
||||
std::fs::canonicalize(&ws).unwrap_or(ws)
|
||||
}
|
||||
|
||||
/// Directories a session owner may work in: admins (and legacy sessions
|
||||
/// without owner) the whole workspace, users their own folder plus shared folders.
|
||||
pub fn user_roots(&self, owner_id: Option<&str>) -> Vec<PathBuf> {
|
||||
let ws = self.workspace();
|
||||
let Some(user) = owner_id.and_then(|id| self.store.user(id).ok().flatten()) else {
|
||||
return vec![ws];
|
||||
};
|
||||
if user.is_admin() {
|
||||
return vec![ws];
|
||||
}
|
||||
let own = ws.join(&user.username);
|
||||
let _ = std::fs::create_dir_all(&own);
|
||||
let mut roots = vec![own];
|
||||
for p in self.store.folders_for_user(&user.id).unwrap_or_default() {
|
||||
let p = PathBuf::from(p);
|
||||
if p.starts_with(&ws) && p.is_dir() {
|
||||
roots.push(p);
|
||||
}
|
||||
}
|
||||
roots
|
||||
}
|
||||
|
||||
/// The root that contains `cwd` (most specific first).
|
||||
pub fn root_for(&self, owner_id: Option<&str>, cwd: &Path) -> PathBuf {
|
||||
let mut roots = self.user_roots(owner_id);
|
||||
roots.sort_by_key(|r| std::cmp::Reverse(r.as_os_str().len()));
|
||||
roots.iter().find(|r| cwd.starts_with(r)).cloned().unwrap_or_else(|| roots.pop().unwrap_or_else(|| self.workspace()))
|
||||
}
|
||||
|
||||
fn owner_of(&self, session_id: &str) -> Option<(String, Option<String>)> {
|
||||
if let Some(v) = self.owners.lock().unwrap().get(session_id) {
|
||||
return Some(v.clone());
|
||||
}
|
||||
let root = self.root_session_id(session_id);
|
||||
let owner = self.store.get_session(&root).ok().flatten()?.owner_id;
|
||||
let v = (root, owner);
|
||||
self.owners.lock().unwrap().insert(session_id.to_string(), v.clone());
|
||||
Some(v)
|
||||
}
|
||||
|
||||
fn shared_with(&self, root: &str) -> Vec<String> {
|
||||
if let Some(v) = self.shares.lock().unwrap().get(root) {
|
||||
return v.clone();
|
||||
}
|
||||
let v = self.store.shares(root).unwrap_or_default();
|
||||
self.shares.lock().unwrap().insert(root.to_string(), v.clone());
|
||||
v
|
||||
}
|
||||
|
||||
pub fn set_shares(&self, root: &str, users: &[String]) -> Result<()> {
|
||||
self.store.set_shares(root, users)?;
|
||||
self.shares.lock().unwrap().insert(root.to_string(), users.to_vec());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn access(&self, user: &crate::store::User, session_id: &str) -> Access {
|
||||
let Some((root, owner)) = self.owner_of(session_id) else { return Access::None };
|
||||
if user.is_admin() || owner.as_deref() == Some(user.id.as_str()) {
|
||||
Access::Full
|
||||
} else if self.shared_with(&root).contains(&user.id) {
|
||||
Access::Read
|
||||
} else {
|
||||
Access::None
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether an event may be sent to this user's connections.
|
||||
pub fn event_visible(&self, user: &crate::store::User, ev: &Event) -> bool {
|
||||
match ev.session_id() {
|
||||
None => user.is_admin(),
|
||||
Some(_) if user.is_admin() => true,
|
||||
Some(id) => self.access(user, id) != Access::None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn data_dir(&self) -> PathBuf {
|
||||
self.config().data_dir()
|
||||
}
|
||||
@@ -197,6 +295,54 @@ impl App {
|
||||
id
|
||||
}
|
||||
|
||||
fn limit(&self, key: &str, size: usize) -> Arc<Semaphore> {
|
||||
let mut m = self.subagent_limits.lock().unwrap();
|
||||
match m.get(key) {
|
||||
Some((n, sem)) if *n == size => sem.clone(),
|
||||
_ => {
|
||||
let sem = Arc::new(Semaphore::new(size));
|
||||
m.insert(key.to_string(), (size, sem.clone()));
|
||||
sem
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Wait until a subagent of this type may run (per-agent limit, then the global
|
||||
/// limit – always in this order). Returns None if cancelled while waiting, and
|
||||
/// whether the call had to wait.
|
||||
pub async fn subagent_slot(
|
||||
&self,
|
||||
agent: &str,
|
||||
max_parallel: Option<usize>,
|
||||
session_id: &str,
|
||||
cancel: &CancellationToken,
|
||||
) -> (Option<Vec<OwnedSemaphorePermit>>, bool) {
|
||||
let mut sems = Vec::new();
|
||||
if let Some(n) = max_parallel.filter(|n| *n > 0) {
|
||||
sems.push((format!("subagent:{agent}"), self.limit(&format!("agent:{agent}"), n)));
|
||||
}
|
||||
let global = self.config().server.max_parallel_subagents;
|
||||
if global > 0 {
|
||||
sems.push(("subagent:*".to_string(), self.limit("*", global)));
|
||||
}
|
||||
let mut waited = false;
|
||||
let mut permits = Vec::new();
|
||||
for (reason, sem) in sems {
|
||||
if sem.available_permits() == 0 {
|
||||
waited = true;
|
||||
self.emit(Event::Waiting { session_id: session_id.to_string(), reason: Some(reason) });
|
||||
}
|
||||
tokio::select! {
|
||||
p = sem.acquire_owned() => match p {
|
||||
Ok(p) => permits.push(p),
|
||||
Err(_) => return (None, waited),
|
||||
},
|
||||
_ = cancel.cancelled() => return (None, waited),
|
||||
}
|
||||
}
|
||||
(Some(permits), waited)
|
||||
}
|
||||
|
||||
// --- user interaction -------------------------------------------------
|
||||
|
||||
/// Forward questions to the UI and wait. None = dismissed.
|
||||
@@ -227,6 +373,12 @@ impl App {
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
/// Session of a pending permission request or question.
|
||||
pub fn pending_session(&self, id: &str) -> Option<String> {
|
||||
let from_perm = self.permissions.lock().unwrap().get(id).and_then(|p| p.event.session_id().map(String::from));
|
||||
from_perm.or_else(|| self.questions.lock().unwrap().get(id).and_then(|q| q.event.session_id().map(String::from)))
|
||||
}
|
||||
|
||||
pub fn answer_question(&self, id: &str, answers: Option<Map<String, Value>>) -> Result<()> {
|
||||
let p = self.questions.lock().unwrap().remove(id).ok_or_else(|| anyhow!("question not pending"))?;
|
||||
let _ = p.tx.send(answers);
|
||||
|
||||
Reference in new issue
Block a user