- Sidebar: three status LEDs (server, models, MCP) with an overview popover (/api/status) - Images: preview stage, purpose-based model choice and reference images (Nano Banana Pro edit refines the chosen preview) - Settings: restore a stored or uploaded backup (staged, applied on restart) - Provider form: test connection; saving or reloading re-checks the status at once - opencode Zen/Go: send x-opencode-session with a stable id per conversation - Releases are named vYYYYMM-NNNNN; scripts/release.sh numbers, tags, builds and publishes - Fix: backups made in the same second no longer overwrite each other Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
486 lines
18 KiB
Rust
486 lines
18 KiB
Rust
//! Shared application state.
|
||
|
||
use crate::config::{self, Config};
|
||
use crate::mcp::McpManager;
|
||
use crate::provider::Registry;
|
||
use crate::store::Store;
|
||
use crate::types::{Event, Question, Session, now_ms};
|
||
use anyhow::{Result, anyhow};
|
||
use serde_json::{Map, Value};
|
||
use std::collections::{HashMap, HashSet};
|
||
use std::path::{Path, PathBuf};
|
||
use std::sync::{Arc, Mutex, RwLock};
|
||
use tokio::sync::{OwnedSemaphorePermit, Semaphore, broadcast, oneshot};
|
||
use tokio_util::sync::CancellationToken;
|
||
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
|
||
#[serde(rename_all = "snake_case")]
|
||
pub enum PermissionReply {
|
||
/// Allow this one call.
|
||
Once,
|
||
/// Allow this tool (or bash command prefix) for the rest of the session.
|
||
Always,
|
||
Reject,
|
||
}
|
||
|
||
struct PendingPermission {
|
||
event: Event,
|
||
tx: oneshot::Sender<(PermissionReply, Option<String>, Option<Vec<usize>>)>,
|
||
}
|
||
|
||
struct PendingQuestion {
|
||
event: Event,
|
||
tx: oneshot::Sender<Option<Map<String, Value>>>,
|
||
}
|
||
|
||
/// Per-session runtime state (not persisted).
|
||
#[derive(Default)]
|
||
pub struct Runtime {
|
||
pub cancel: Option<CancellationToken>,
|
||
pub read_files: HashSet<PathBuf>,
|
||
pub approved: HashSet<String>,
|
||
/// The model request currently running (for clients that connect mid-step).
|
||
pub step: Option<Value>,
|
||
/// Reasoning effort chosen for the current turn (overrides the model's default).
|
||
pub effort: Option<String>,
|
||
}
|
||
|
||
pub struct App {
|
||
config_path: PathBuf,
|
||
config: RwLock<Arc<Config>>,
|
||
registry: RwLock<Arc<Registry>>,
|
||
pub store: Store,
|
||
events: broadcast::Sender<Event>,
|
||
pub mcp: Arc<McpManager>,
|
||
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>>>,
|
||
/// Failed sign-ins per key (ip or username): timestamps in ms.
|
||
pub login_failures: Mutex<HashMap<String, Vec<i64>>>,
|
||
/// State of an admin-triggered tool installation (Ghidra).
|
||
pub tool_install: crate::reverse::ToolInstall,
|
||
/// Background shells of all sessions.
|
||
pub shells: crate::shells::Shells,
|
||
/// Language servers (diagnostics after edits).
|
||
pub lsp: crate::lsp::Lsp,
|
||
/// Web Push (VAPID key, window visibility per user).
|
||
pub push: crate::push::Push,
|
||
/// Web terminals (PTYs).
|
||
pub terminals: crate::terminal::Terminals,
|
||
/// Newer release found by the update check (admins see it).
|
||
pub update: crate::update::UpdateState,
|
||
/// Provider reachability (checked every 5 minutes).
|
||
pub health: crate::health::Health,
|
||
/// Automatic backups (last result, run lock).
|
||
pub backup: crate::backup::BackupState,
|
||
}
|
||
|
||
/// What a user may do with a session.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum Access {
|
||
None,
|
||
Read,
|
||
Full,
|
||
}
|
||
|
||
impl App {
|
||
pub async fn new(config_path: PathBuf, cfg: Config) -> Result<Arc<Self>> {
|
||
let data = cfg.data_dir();
|
||
std::fs::create_dir_all(&data)?;
|
||
let store = Store::open(&data.join("codecraft.db"))?;
|
||
let registry = Registry::build(&cfg).await;
|
||
let (events, _) = broadcast::channel(4096);
|
||
let app = Arc::new(Self {
|
||
config_path,
|
||
config: RwLock::new(Arc::new(cfg)),
|
||
registry: RwLock::new(Arc::new(registry)),
|
||
store,
|
||
events,
|
||
mcp: Arc::new(McpManager::default()),
|
||
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(),
|
||
login_failures: Mutex::default(),
|
||
tool_install: Mutex::default(),
|
||
shells: Mutex::default(),
|
||
lsp: Default::default(),
|
||
push: crate::push::Push::new(&data),
|
||
terminals: Default::default(),
|
||
health: Default::default(),
|
||
backup: Default::default(),
|
||
update: Default::default(),
|
||
});
|
||
crate::auth::bootstrap(&app)?;
|
||
app.clone().start_mcp();
|
||
Ok(app)
|
||
}
|
||
|
||
fn start_mcp(self: Arc<Self>) {
|
||
let servers = self.config().mcp.clone();
|
||
let mcp = self.mcp.clone();
|
||
let weak = Arc::downgrade(&self);
|
||
tokio::spawn(async move { mcp.start_all(weak, servers).await });
|
||
}
|
||
|
||
pub fn config(&self) -> Arc<Config> {
|
||
self.config.read().unwrap().clone()
|
||
}
|
||
|
||
pub fn registry(&self) -> Arc<Registry> {
|
||
self.registry.read().unwrap().clone()
|
||
}
|
||
|
||
pub fn workspace(&self) -> PathBuf {
|
||
let ws = self.config().workspace();
|
||
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,
|
||
}
|
||
}
|
||
|
||
/// Record an action in the audit log.
|
||
pub fn audit(&self, user: Option<&crate::store::User>, action: &str, detail: impl AsRef<str>, ip: &str) {
|
||
self.store.audit(user.map(|u| u.id.as_str()), user.map(|u| u.username.as_str()).unwrap_or(""), action, detail.as_ref(), ip);
|
||
}
|
||
|
||
pub fn data_dir(&self) -> PathBuf {
|
||
self.config().data_dir()
|
||
}
|
||
|
||
pub fn config_text(&self) -> Result<String> {
|
||
Ok(std::fs::read_to_string(&self.config_path)?)
|
||
}
|
||
|
||
/// Validate, write and apply a new config text. Providers and MCP servers reload.
|
||
pub async fn save_config(self: &Arc<Self>, text: &str) -> Result<()> {
|
||
let cfg = Config::parse(text)?;
|
||
let tmp = self.config_path.with_extension("toml.tmp");
|
||
std::fs::write(&tmp, text)?;
|
||
std::fs::rename(&tmp, &self.config_path)?;
|
||
let registry = Registry::build(&cfg).await;
|
||
let mcp_changed = serde_json::to_value(&cfg.mcp)? != serde_json::to_value(&self.config().mcp)?;
|
||
let providers_changed = serde_json::to_value(&cfg.providers)? != serde_json::to_value(&self.config().providers)?;
|
||
*self.config.write().unwrap() = Arc::new(cfg);
|
||
*self.registry.write().unwrap() = Arc::new(registry);
|
||
if mcp_changed {
|
||
self.clone().start_mcp();
|
||
}
|
||
// A new key or URL should show its status right away, not at the next 5-minute check.
|
||
if providers_changed {
|
||
crate::health::check_all(self).await;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
pub async fn patch_config(self: &Arc<Self>, patches: &[config::ConfigPatch]) -> Result<()> {
|
||
let text = config::apply_patches(&self.config_text()?, patches)?;
|
||
self.save_config(&text).await
|
||
}
|
||
|
||
pub async fn reload_providers(&self) {
|
||
let registry = Registry::build(&self.config()).await;
|
||
*self.registry.write().unwrap() = Arc::new(registry);
|
||
crate::health::check_all(self).await;
|
||
}
|
||
|
||
// --- events ---------------------------------------------------------
|
||
|
||
pub fn emit(&self, ev: Event) {
|
||
match &ev {
|
||
Event::StepStarted { session_id, .. } => {
|
||
let v = serde_json::to_value(&ev).ok();
|
||
self.with_runtime(session_id, |r| r.step = v);
|
||
}
|
||
Event::SessionStatus { session_id, busy: false } => self.with_runtime(session_id, |r| r.step = None),
|
||
_ => {}
|
||
}
|
||
let _ = self.events.send(ev);
|
||
}
|
||
|
||
pub fn subscribe(&self) -> broadcast::Receiver<Event> {
|
||
self.events.subscribe()
|
||
}
|
||
|
||
/// Pending permission and question requests, re-sent to newly connected clients.
|
||
pub fn pending_events(&self) -> Vec<Event> {
|
||
let mut out: Vec<Event> = self.permissions.lock().unwrap().values().map(|p| p.event.clone()).collect();
|
||
out.extend(self.questions.lock().unwrap().values().map(|q| q.event.clone()));
|
||
out
|
||
}
|
||
|
||
// --- runtime --------------------------------------------------------
|
||
|
||
pub fn with_runtime<R>(&self, session_id: &str, f: impl FnOnce(&mut Runtime) -> R) -> R {
|
||
let mut rt = self.runtime.lock().unwrap();
|
||
f(rt.entry(session_id.to_string()).or_default())
|
||
}
|
||
|
||
pub fn is_busy(&self, session_id: &str) -> bool {
|
||
self.with_runtime(session_id, |r| r.cancel.is_some())
|
||
}
|
||
|
||
pub fn mark_read(&self, session_id: &str, path: &Path) {
|
||
self.with_runtime(session_id, |r| r.read_files.insert(path.to_path_buf()));
|
||
}
|
||
|
||
pub fn was_read(&self, session_id: &str, path: &Path) -> bool {
|
||
self.with_runtime(session_id, |r| r.read_files.contains(path))
|
||
}
|
||
|
||
pub fn session(&self, id: &str) -> Result<Session> {
|
||
self.store.get_session(id)?.ok_or_else(|| anyhow!("session not found"))
|
||
}
|
||
|
||
pub fn save_session(&self, mut s: Session) -> Result<Session> {
|
||
s.updated_at = now_ms();
|
||
self.store.save_session(&s)?;
|
||
self.emit(Event::SessionUpdated { session: s.clone() });
|
||
Ok(s)
|
||
}
|
||
|
||
pub fn set_todos(&self, session_id: &str, todos: Value) -> Result<()> {
|
||
let mut s = self.session(session_id)?;
|
||
s.todos = todos;
|
||
self.save_session(s)?;
|
||
Ok(())
|
||
}
|
||
|
||
/// Walk up the parent chain to the top-level session.
|
||
pub fn root_session_id(&self, session_id: &str) -> String {
|
||
let mut id = session_id.to_string();
|
||
for _ in 0..16 {
|
||
match self.store.get_session(&id).ok().flatten().and_then(|s| s.parent_id) {
|
||
Some(p) => id = p,
|
||
None => break,
|
||
}
|
||
}
|
||
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.
|
||
pub async fn ask_user(
|
||
&self,
|
||
session_id: &str,
|
||
source: &str,
|
||
questions: Vec<Question>,
|
||
cancel: CancellationToken,
|
||
) -> Result<Option<Map<String, Value>>> {
|
||
let id = crate::types::new_id();
|
||
let event = Event::QuestionRequest {
|
||
id: id.clone(),
|
||
session_id: session_id.to_string(),
|
||
root_session_id: self.root_session_id(session_id),
|
||
source: source.to_string(),
|
||
questions,
|
||
};
|
||
let (tx, rx) = oneshot::channel();
|
||
self.questions.lock().unwrap().insert(id.clone(), PendingQuestion { event: event.clone(), tx });
|
||
self.emit(event);
|
||
let res = tokio::select! {
|
||
r = rx => r.unwrap_or(None),
|
||
_ = cancel.cancelled() => None,
|
||
};
|
||
self.questions.lock().unwrap().remove(&id);
|
||
self.emit(Event::QuestionResolved { id, session_id: session_id.to_string() });
|
||
Ok(res)
|
||
}
|
||
|
||
/// "tool: title" of a pending permission request (for the audit log).
|
||
pub fn pending_title(&self, id: &str) -> Option<String> {
|
||
self.permissions.lock().unwrap().get(id).and_then(|p| match &p.event {
|
||
Event::PermissionRequest { tool, title, .. } => Some(format!("{tool}: {title}")),
|
||
_ => None,
|
||
})
|
||
}
|
||
|
||
/// 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);
|
||
Ok(())
|
||
}
|
||
|
||
/// Ask the user to approve a tool call. Returns the reply and an optional
|
||
/// message (feedback on rejection).
|
||
pub async fn request_permission(
|
||
&self,
|
||
session_id: &str,
|
||
tool: &str,
|
||
title: String,
|
||
input: Value,
|
||
diff: Option<String>,
|
||
cancel: CancellationToken,
|
||
) -> (PermissionReply, Option<String>, Option<Vec<usize>>) {
|
||
let id = crate::types::new_id();
|
||
let event = Event::PermissionRequest {
|
||
id: id.clone(),
|
||
session_id: session_id.to_string(),
|
||
root_session_id: self.root_session_id(session_id),
|
||
tool: tool.to_string(),
|
||
title,
|
||
input,
|
||
diff,
|
||
};
|
||
let (tx, rx) = oneshot::channel();
|
||
self.permissions.lock().unwrap().insert(id.clone(), PendingPermission { event: event.clone(), tx });
|
||
self.emit(event);
|
||
let res = tokio::select! {
|
||
r = rx => r.unwrap_or((PermissionReply::Reject, None, None)),
|
||
_ = cancel.cancelled() => (PermissionReply::Reject, Some("aborted".into()), None),
|
||
};
|
||
self.permissions.lock().unwrap().remove(&id);
|
||
self.emit(Event::PermissionResolved { id, session_id: session_id.to_string() });
|
||
res
|
||
}
|
||
|
||
/// `hunks`: for file edits, the indices of the diff hunks the user accepted (None = all).
|
||
pub fn reply_permission(&self, id: &str, reply: PermissionReply, message: Option<String>, hunks: Option<Vec<usize>>) -> Result<()> {
|
||
let p = self.permissions.lock().unwrap().remove(id).ok_or_else(|| anyhow!("permission not pending"))?;
|
||
let _ = p.tx.send((reply, message, hunks));
|
||
Ok(())
|
||
}
|
||
}
|