Skip to main content

trustee_api/
state.rs

1//! Shared server state: per-user multi-session registry, broadcast channels, and auth state.
2//!
3//! ## Multi-Session Per User (MSU)
4//!
5//! Each authenticated user gets their own [`UserSessions`] containing N independent
6//! [`UserSessionEntry`] instances (default max 4). Each entry has:
7//! - An independent `Session` (workflow state, output, etc.)
8//! - A dedicated broadcast channel for WebSocket fan-out
9//! - Creation and last-active timestamps
10//!
11//! Sessions are keyed by user identity (`sub` claim from JWT, or `dev:email` for
12//! dev mode). Unauthenticated deployments use a single `"default"` key, preserving
13//! backward compatibility with single-user CLI operation.
14
15use std::sync::Arc;
16
17use dashmap::DashMap;
18use tokio::sync::{broadcast, mpsc, Mutex};
19use trustee_core::session::Session;
20use trustee_core::types::TuiMessage;
21
22use crate::auth::AuthState;
23
24// ---------------------------------------------------------------------------
25// Multi-session types
26// ---------------------------------------------------------------------------
27
28/// A single session with its own broadcast channel.
29pub struct UserSessionEntry {
30    /// The agent session, protected by a mutex.
31    pub session: Arc<Mutex<Session>>,
32    /// Broadcast sender for this session's WebSocket fan-out.
33    pub ws_tx: broadcast::Sender<String>,
34    /// When this session was created.
35    pub created_at: chrono::DateTime<chrono::Utc>,
36    /// Last time a command was submitted or state changed.
37    /// Updated on every /sessions/{id}/command and /sessions/{id}/cancel call.
38    pub last_active: Arc<Mutex<chrono::DateTime<chrono::Utc>>>,
39}
40
41/// All sessions belonging to one authenticated user.
42pub struct UserSessions {
43    /// session_id → session entry
44    pub sessions: DashMap<String, UserSessionEntry>,
45    /// Shared token store for all this user's sessions (MCP credential isolation).
46    pub token_store: Arc<pep::MemoryTokenStore>,
47    /// Which session_id is "active" for legacy /session/* routes.
48    pub active_session_id: Mutex<String>,
49}
50
51/// Summary of an active session for listing (serializable for API responses).
52#[derive(Debug, serde::Serialize)]
53pub struct SessionListItem {
54    pub session_id: String,
55    pub session_name: Option<String>,
56    pub workflow_state: String,
57    pub created_at: String,
58    pub last_active: String,
59    /// Number of handoff rotations this session has performed (Bug 5). The
60    /// in-memory registry key never changes across a rotation — only the
61    /// checkpoint-chain name does — so a card whose name changed without this
62    /// hint looks like it silently renamed. 0 = never rotated.
63    pub handoff_count: u32,
64}
65
66/// Errors from multi-session operations.
67#[derive(Debug)]
68pub enum SessionError {
69    /// User has reached max_sessions_per_user limit.
70    MaxSessionsReached(usize),
71    /// Session ID not found for this user.
72    NotFound(String),
73    /// Session is not Idle (cannot destroy/overwrite a running session).
74    NotIdle(String),
75}
76
77impl std::fmt::Display for SessionError {
78    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79        match self {
80            SessionError::MaxSessionsReached(n) => {
81                write!(f, "Maximum {} sessions per user reached", n)
82            }
83            SessionError::NotFound(id) => write!(f, "Session {} not found", id),
84            SessionError::NotIdle(state) => write!(f, "Session is not idle (state: {})", state),
85        }
86    }
87}
88
89impl std::error::Error for SessionError {}
90
91/// Top-level registry: user_key → user's session collection.
92pub type SessionRegistry = Arc<DashMap<String, UserSessions>>;
93
94// ---------------------------------------------------------------------------
95// ServerState
96// ---------------------------------------------------------------------------
97
98/// Shared state accessible by all axum handlers.
99#[derive(Clone)]
100pub struct ServerState {
101    /// Per-user multi-session registry (MSU).
102    pub sessions: SessionRegistry,
103    /// Broadcast sender for backward compat — delegates to the default user's channel.
104    pub ws_tx: broadcast::Sender<String>,
105    /// Auth state (None = auth disabled, all endpoints open).
106    pub auth: Option<Arc<AuthState>>,
107    /// Shared config TOML (all users share the same agent config).
108    pub config_toml: Option<String>,
109    /// Shared secrets (injected into every per-user session).
110    pub secrets: Option<std::collections::HashMap<String, String>>,
111    /// Shared build info (injected into every per-user session).
112    pub build_info: Option<trustee_core::types::BuildInfo>,
113    /// Global concurrency limiter — limits the number of simultaneous workflows
114    /// across all users. Default: 8 concurrent workflows.
115    pub workflow_semaphore: Arc<tokio::sync::Semaphore>,
116    /// Maximum number of concurrent sessions per user. Default: 4.
117    pub max_sessions_per_user: usize,
118}
119
120impl ServerState {
121    /// Create new shared state from a default session, broadcast sender, and optional auth.
122    pub fn new(
123        session: Session,
124        ws_tx: broadcast::Sender<String>,
125        auth: Option<Arc<AuthState>>,
126    ) -> Self {
127        let sessions = Arc::new(DashMap::new());
128
129        // Store the default user's UserSessions with an initial session
130        let token_store = Arc::new(pep::MemoryTokenStore::new());
131        let (ws_tx_entry, _) = broadcast::channel::<String>(256);
132
133        let now = chrono::Utc::now();
134        let initial_entry = UserSessionEntry {
135            session: Arc::new(Mutex::new(session)),
136            ws_tx: ws_tx_entry,
137            created_at: now,
138            last_active: Arc::new(Mutex::new(now)),
139        };
140
141        let user_sessions = UserSessions {
142            sessions: DashMap::new(),
143            token_store,
144            active_session_id: Mutex::new(String::new()),
145        };
146        user_sessions.sessions.insert("default".to_string(), initial_entry);
147
148        sessions.insert("default".to_string(), user_sessions);
149
150        Self {
151            sessions,
152            ws_tx,
153            auth,
154            config_toml: None,
155            secrets: None,
156            build_info: None,
157            workflow_semaphore: Arc::new(tokio::sync::Semaphore::new(8)),
158            max_sessions_per_user: 4,
159        }
160    }
161
162    pub fn with_config_toml(mut self, config_toml: String) -> Self {
163        self.config_toml = Some(config_toml);
164        self
165    }
166
167    pub fn with_secrets(mut self, secrets: std::collections::HashMap<String, String>) -> Self {
168        self.secrets = Some(secrets);
169        self
170    }
171
172    pub fn with_build_info(mut self, build_info: trustee_core::types::BuildInfo) -> Self {
173        self.build_info = Some(build_info);
174        self
175    }
176
177    pub fn with_max_concurrent_workflows(mut self, max: usize) -> Self {
178        self.workflow_semaphore = Arc::new(tokio::sync::Semaphore::new(max));
179        self
180    }
181
182    /// Set the max sessions per user.
183    pub fn with_max_sessions_per_user(mut self, max: usize) -> Self {
184        self.max_sessions_per_user = max;
185        self
186    }
187
188    // -----------------------------------------------------------------------
189    // MSU: Multi-session methods
190    // -----------------------------------------------------------------------
191
192    /// Create a new session for a user. Returns the session_id.
193    ///
194    /// Creates a fresh `Session::new()`, copies shared config, sets per-user
195    /// isolation, creates a broadcast channel, spawns a drain task, and inserts
196    /// into the user's session DashMap. The new session becomes the "active" one.
197    pub async fn create_session(
198        &self,
199        user_key: &str,
200        session_name: Option<String>,
201        identity: Option<String>,
202    ) -> Result<String, SessionError> {
203        // Get or create the user's UserSessions entry
204        let user_sessions = self
205            .sessions
206            .entry(user_key.to_string())
207            .or_insert_with(|| UserSessions {
208                sessions: DashMap::new(),
209                token_store: Arc::new(pep::MemoryTokenStore::new()),
210                active_session_id: Mutex::new(String::new()),
211            });
212
213        // Check session limit
214        if user_sessions.sessions.len() >= self.max_sessions_per_user {
215            return Err(SessionError::MaxSessionsReached(self.max_sessions_per_user));
216        }
217
218        // Create new Session
219        let (mut session, workflow_rx) = Session::new();
220
221        // Copy shared config
222        if let Some(ref config_toml) = self.config_toml {
223            session.config_toml = Some(config_toml.clone());
224            session.parse_auto_handoff_config();
225            if let Ok(table) = config_toml.parse::<toml::Value>() {
226                if let Some(name) = table
227                    .get("agent")
228                    .and_then(|a| a.get("name"))
229                    .and_then(|n| n.as_str())
230                {
231                    session.agent_name = name.to_string();
232                }
233            }
234        }
235
236        session.secrets = self.secrets.clone();
237        session.build_info = self.build_info.clone();
238
239        // Per-user isolation
240        self.apply_user_isolation(&mut session, user_key);
241
242        // Apply session_name if provided
243        session.session_name = session_name;
244
245        // Apply agent identity if provided
246        session.identity = identity;
247
248        // Create broadcast channel
249        let (ws_tx_entry, _) = broadcast::channel::<String>(256);
250
251        // Generate session_id
252        let session_id = format!(
253            "session_{}_{}",
254            chrono::Utc::now().format("%Y_%m_%d_%H_%M"),
255            &uuid::Uuid::new_v4().to_string()[..8]
256        );
257
258        let now = chrono::Utc::now();
259
260        // Insert into user's sessions DashMap
261        user_sessions.sessions.insert(
262            session_id.clone(),
263            UserSessionEntry {
264                session: Arc::new(Mutex::new(session)),
265                ws_tx: ws_tx_entry.clone(),
266                created_at: now,
267                last_active: Arc::new(Mutex::new(now)),
268            },
269        );
270
271        // Set as active session
272        *user_sessions.active_session_id.lock().await = session_id.clone();
273
274        // Spawn drain task
275        let session_arc = user_sessions
276            .sessions
277            .get(&session_id)
278            .map(|e| e.session.clone());
279        if let Some(session_arc) = session_arc {
280            self.spawn_user_drain_task(
281                session_id.clone(),
282                session_arc,
283                ws_tx_entry,
284                workflow_rx,
285            );
286        }
287
288        Ok(session_id)
289    }
290
291    /// Get a specific session by user_key + session_id.
292    /// Updates last_active on the session entry.
293    pub async fn get_session(
294        &self,
295        user_key: &str,
296        session_id: &str,
297    ) -> Option<(Arc<Mutex<Session>>, broadcast::Sender<String>)> {
298        let user_sessions = self.sessions.get(user_key)?;
299        let entry = user_sessions.sessions.get(session_id)?;
300
301        // Update last_active
302        let now = chrono::Utc::now();
303        *entry.last_active.lock().await = now;
304
305        Some((entry.session.clone(), entry.ws_tx.clone()))
306    }
307
308    /// Get a session by EITHER its live MSU registry key OR its
309    /// checkpoint/session identity (`session.session_id`).
310    ///
311    /// The web frontend tracks `currentSessionId` from the `ResumeInfo` WS
312    /// message, which carries the auto-derived checkpoint id
313    /// (`session_YYYY_MM_DD_HH_MM_uuid8`) — NOT the live MSU registry key
314    /// (`"default"` or the key from `create_session()`). External clients
315    /// like Torpi/THQ pass the live registry key. This resolver accepts both:
316    ///
317    /// 1. Try registry-key lookup first (precise, used by Torpi/THQ).
318    /// 2. Fall back to scanning the user's live sessions for one whose
319    ///    `session.session_id` matches the requested id (used by the
320    ///    embedded web UI after a command or resume).
321    ///
322    /// Returns `(live_registry_key, session_arc, ws_tx)`, or `None` if not
323    /// found. The live key is returned so callers that need to set it as
324    /// active (or otherwise reference the registry) use the real key.
325    pub async fn get_session_by_any_id(
326        &self,
327        user_key: &str,
328        id: &str,
329    ) -> Option<(String, Arc<Mutex<Session>>, broadcast::Sender<String>)> {
330        // Fast path: registry key match.
331        let user_sessions = self.sessions.get(user_key)?;
332        if let Some(entry) = user_sessions.sessions.get(id) {
333            // Update last_active
334            let now = chrono::Utc::now();
335            *entry.last_active.lock().await = now;
336            return Some((id.to_string(), entry.session.clone(), entry.ws_tx.clone()));
337        }
338
339        // Slow path: scan live sessions for a matching session.session_id.
340        for entry in user_sessions.sessions.iter() {
341            let session = entry.session.lock().await;
342            if session.session_id.as_deref() == Some(id) {
343                let key = entry.key().clone();
344                let ws_tx = entry.ws_tx.clone();
345                drop(session);
346                // Update last_active
347                let now = chrono::Utc::now();
348                *entry.last_active.lock().await = now;
349                return Some((key, entry.session.clone(), ws_tx));
350            }
351        }
352
353        None
354    }
355
356    /// List all active sessions for a user, sorted by last_active desc.
357    pub async fn list_sessions(&self, user_key: &str) -> Vec<SessionListItem> {
358        let Some(user_sessions) = self.sessions.get(user_key) else {
359            return Vec::new();
360        };
361
362        let mut items = Vec::new();
363        for entry in user_sessions.sessions.iter() {
364            let session = entry.session.lock().await;
365            let workflow_state = match session.workflow_state {
366                trustee_core::types::WorkflowState::Idle => "Idle",
367                trustee_core::types::WorkflowState::Running => "Running",
368                trustee_core::types::WorkflowState::Cancelling => "Cancelling",
369            };
370            let last_active = entry.last_active.lock().await;
371            items.push(SessionListItem {
372                session_id: entry.key().clone(),
373                session_name: session.session_name.clone(),
374                workflow_state: workflow_state.to_string(),
375                created_at: entry.created_at.to_rfc3339(),
376                last_active: last_active.to_rfc3339(),
377                handoff_count: session.handoff_count,
378            });
379        }
380        drop(user_sessions);
381
382        // Sort by last_active descending
383        items.sort_by(|a, b| b.last_active.cmp(&a.last_active));
384        items
385    }
386
387    /// Destroy a session. The session must be Idle.
388    pub async fn destroy_session(
389        &self,
390        user_key: &str,
391        session_id: &str,
392    ) -> Result<(), SessionError> {
393        let user_sessions = self
394            .sessions
395            .get(user_key)
396            .ok_or_else(|| SessionError::NotFound(session_id.to_string()))?;
397
398        // Check workflow state before removing
399        {
400            let entry = user_sessions
401                .sessions
402                .get(session_id)
403                .ok_or_else(|| SessionError::NotFound(session_id.to_string()))?;
404            let session = entry.session.lock().await;
405            if session.workflow_state != trustee_core::types::WorkflowState::Idle {
406                let state_str = match session.workflow_state {
407                    trustee_core::types::WorkflowState::Running => "Running",
408                    trustee_core::types::WorkflowState::Cancelling => "Cancelling",
409                    _ => "Unknown",
410                };
411                return Err(SessionError::NotIdle(state_str.to_string()));
412            }
413        }
414
415        // Remove from DashMap
416        user_sessions.sessions.remove(session_id);
417
418        // If this was the active session, pick a new active
419        let mut active_id = user_sessions.active_session_id.lock().await;
420        if &*active_id == session_id {
421            // Pick the most recently active remaining session
422            let mut newest: Option<(String, chrono::DateTime<chrono::Utc>)> = None;
423            for entry in user_sessions.sessions.iter() {
424                let la = entry.last_active.lock().await;
425                if newest.as_ref().map_or(true, |(_, t)| *la > *t) {
426                    newest = Some((entry.key().clone(), *la));
427                }
428            }
429            *active_id = newest.map(|(id, _)| id).unwrap_or_default();
430        }
431
432        Ok(())
433    }
434
435    /// Get or create the user's "active" session for legacy routes.
436    ///
437    /// Behavior:
438    /// 1. If user has no sessions → create one
439    /// 2. If active session exists → return it
440    /// 3. If active session was destroyed → create a new one
441    ///
442    /// Returns: (session_id, session_arc, ws_tx, token_store)
443    pub async fn ensure_active_session(
444        &self,
445        user_key: &str,
446    ) -> (
447        String,
448        Arc<Mutex<Session>>,
449        broadcast::Sender<String>,
450        Arc<pep::MemoryTokenStore>,
451    ) {
452        // Get or create user's UserSessions
453        let token_store = {
454            let user_sessions = self
455                .sessions
456                .entry(user_key.to_string())
457                .or_insert_with(|| UserSessions {
458                    sessions: DashMap::new(),
459                    token_store: Arc::new(pep::MemoryTokenStore::new()),
460                    active_session_id: Mutex::new(String::new()),
461                });
462            user_sessions.token_store.clone()
463        };
464
465        // Check if active session exists
466        let active_id = {
467            let user_sessions = self.sessions.get(user_key).unwrap();
468            let guard = user_sessions.active_session_id.lock().await;
469            guard.clone()
470        };
471
472        if !active_id.is_empty() {
473            if let Some((session, ws_tx)) = self.get_session(user_key, &active_id).await {
474                return (active_id, session, ws_tx, token_store);
475            }
476            // Active session was destroyed, fall through to create
477        }
478
479        // Need to create a new session
480        // For the "default" user, we may already have a "default" session entry
481        // from ServerState::new() — check for it
482        let existing_session: Option<(String, Arc<Mutex<Session>>, broadcast::Sender<String>)> = {
483            let user_sessions = self.sessions.get(user_key).unwrap();
484            let result = user_sessions.sessions.iter().next().map(|first| {
485                (
486                    first.key().clone(),
487                    first.session.clone(),
488                    first.ws_tx.clone(),
489                )
490            });
491            result
492        };
493        if let Some((id, session, ws_tx)) = existing_session {
494            let now = chrono::Utc::now();
495            if let Some(entry) = self.sessions.get(user_key) {
496                if let Some(e) = entry.sessions.get(&id) {
497                    *e.last_active.lock().await = now;
498                }
499                *entry.active_session_id.lock().await = id.clone();
500            }
501
502            return (id, session, ws_tx, token_store);
503        }
504
505        // Create a brand new session
506        let session_id = self
507            .create_session(user_key, None, None)
508            .await
509            .unwrap_or_else(|_| "default".to_string());
510
511        let (session, ws_tx) = self
512            .get_session(user_key, &session_id)
513            .await
514            .expect("just-created session must exist");
515
516        (session_id, session, ws_tx, token_store)
517    }
518
519    /// DEPRECATED: Use ensure_active_session() instead.
520    /// Kept for backward compatibility — same 3-tuple return type.
521    pub async fn ensure_user_session(
522        &self,
523        user_key: &str,
524    ) -> (Arc<Mutex<Session>>, broadcast::Sender<String>, Arc<pep::MemoryTokenStore>) {
525        let (_id, session, ws_tx, token_store) = self.ensure_active_session(user_key).await;
526        (session, ws_tx, token_store)
527    }
528
529    /// Set a session as the user's active session.
530    pub async fn set_active_session(&self, user_key: &str, session_id: &str) {
531        if let Some(user_sessions) = self.sessions.get(user_key) {
532            if user_sessions.sessions.contains_key(session_id) {
533                *user_sessions.active_session_id.lock().await = session_id.to_string();
534            }
535        }
536    }
537
538    // -----------------------------------------------------------------------
539    // Read-only helpers (no session creation side effects)
540    // -----------------------------------------------------------------------
541
542    /// Resolve a user's home_dir without creating an in-memory session.
543    ///
544    /// This is the read-only equivalent of the isolation logic in
545    /// `apply_user_isolation`. Used by endpoints that only need to read
546    /// checkpoint data from disk (history, session list, session detail)
547    /// and must NOT create ghost sessions as a side effect.
548    pub fn get_user_home_dir(&self, user_key: &str) -> Option<std::path::PathBuf> {
549        use sha2::{Digest, Sha256};
550        let mut hasher = Sha256::new();
551        hasher.update(user_key.as_bytes());
552        let hash_bytes = hasher.finalize();
553        let user_hash = format!(
554            "{:016x}",
555            u64::from_be_bytes(hash_bytes[..8].try_into().unwrap())
556        );
557        dirs::home_dir().map(|home| home.join(".trustee").join("users").join(&user_hash))
558    }
559
560    /// Resolve config_toml and home_dir without creating an in-memory session.
561    ///
562    /// Returns `(config_toml, home_dir)`. If config is not loaded,
563    /// config_toml will be None.
564    pub fn get_user_config_and_home(&self, user_key: &str) -> (Option<String>, Option<std::path::PathBuf>) {
565        (self.config_toml.clone(), self.get_user_home_dir(user_key))
566    }
567
568    // -----------------------------------------------------------------------
569    // Private helpers
570    // -----------------------------------------------------------------------
571
572    /// Apply per-user isolation: SHA-256 hash → home_dir + project_id.
573    fn apply_user_isolation(&self, session: &mut Session, user_key: &str) {
574        use sha2::{Digest, Sha256};
575        let mut hasher = Sha256::new();
576        hasher.update(user_key.as_bytes());
577        let hash_bytes = hasher.finalize();
578        let user_hash = format!(
579            "{:016x}",
580            u64::from_be_bytes(hash_bytes[..8].try_into().unwrap())
581        );
582
583        // Set per-user home directory for checkpoint isolation
584        let user_home = if let Some(home) = dirs::home_dir() {
585            let user_home = home.join(".trustee").join("users").join(&user_hash);
586            session.home_dir = Some(user_home.clone());
587            Some(user_home)
588        } else {
589            None
590        };
591
592        session.project_id = Some(format!("web{}", &user_hash[..16]));
593
594        // ── Per-user .env (Task 2) ──────────────────────────────────────
595        //
596        // Load per-user secrets from ~/.trustee/users/{hash}/.env
597        // These are merged on top of shared secrets (per-user wins).
598        // They are NEVER set as process env vars — used only for ${VAR}
599        // substitution in the config TOML below.
600        let shared_secrets = session.secrets.clone().unwrap_or_default();
601        let mut merged_secrets = shared_secrets.clone();
602
603        if let Some(ref user_home) = user_home {
604            if let Ok(merged) = self.load_user_secrets(user_home, &merged_secrets) {
605                merged_secrets = merged;
606            }
607        }
608
609        // ── Per-user config overlay (Task 3) ────────────────────────────
610        if let Some(ref user_home) = user_home {
611            if let Some(merged) = self.merge_user_config(user_home, session.config_toml.as_deref()) {
612                session.config_toml = Some(merged);
613                tracing::debug!("Merged per-user config into session");
614            }
615        }
616
617        // ── ${VAR} substitution (Task 4) ────────────────────────────────
618        if let Some(ref mut config_toml) = session.config_toml {
619            substitute_env_vars(config_toml, &merged_secrets);
620        }
621
622        // ── Strip per-user secrets (Task 5) ────────────────────────────
623        session.secrets = Some(shared_secrets);
624    }
625
626    /// Load secrets from a user's ~/.trustee/users/{hash}/.env and merge
627    /// on top of `base` (user wins). Returns the merged map.
628    fn load_user_secrets(
629        &self,
630        user_home: &std::path::Path,
631        base: &std::collections::HashMap<String, String>,
632    ) -> std::io::Result<std::collections::HashMap<String, String>> {
633        let user_env_path = user_home.join(".env");
634        if !user_env_path.exists() {
635            return Ok(base.clone());
636        }
637        let content = std::fs::read_to_string(&user_env_path)?;
638        let mut merged = base.clone();
639        for line in content.lines() {
640            let line = line.trim();
641            if line.is_empty() || line.starts_with('#') {
642                continue;
643            }
644            if let Some((key, value)) = line.split_once('=') {
645                let key = key.trim().to_string();
646                let value = value
647                    .trim()
648                    .trim_matches('"')
649                    .trim_matches('\'')
650                    .to_string();
651                merged.insert(key, value);
652            }
653        }
654        tracing::debug!("Loaded per-user secrets from {}", user_env_path.display());
655        Ok(merged)
656    }
657
658    /// Deep-merge a user's per-user config overlay on top of the shared
659    /// config. Returns the merged TOML string, or None if no overlay exists.
660    fn merge_user_config(
661        &self,
662        user_home: &std::path::Path,
663        shared_config: Option<&str>,
664    ) -> Option<String> {
665        let user_config_path = user_home.join("config").join("trustee.toml");
666        if !user_config_path.exists() {
667            return None;
668        }
669        let user_config_toml = std::fs::read_to_string(&user_config_path).ok()?;
670        let shared = shared_config
671            .unwrap_or("")
672            .parse::<toml::Value>()
673            .ok()?;
674        let overlay = user_config_toml.parse::<toml::Value>().ok()?;
675        let mut shared = shared;
676        deep_merge_toml(&mut shared, &overlay);
677        let merged = toml::to_string(&shared).ok()?;
678        tracing::debug!("Merged per-user config from {}", user_config_path.display());
679        Some(merged)
680    }
681
682    /// Resolve the fully-merged config TOML for a user WITHOUT creating a session.
683    ///
684    /// This is the read-only equivalent of the config resolution in
685    /// `apply_user_isolation`: shared config + per-user overlay + ${VAR}
686    /// substitution. Used by endpoints that need to inspect config (e.g.
687    /// listing available LLM models) without spawning a ghost session.
688    ///
689    /// Returns None if no shared config is loaded.
690    pub fn resolve_user_config(&self, user_key: &str) -> Option<String> {
691        let config_toml = self.config_toml.clone()?;
692
693        // Resolve user home dir (same hash scheme as apply_user_isolation)
694        let user_home = self.get_user_home_dir(user_key)?;
695
696        // Start from shared secrets; merge per-user .env on top
697        let mut merged_secrets = self.secrets.clone().unwrap_or_default();
698        if let Ok(merged) = self.load_user_secrets(&user_home, &merged_secrets) {
699            merged_secrets = merged;
700        }
701
702        // Merge per-user config overlay
703        let mut resolved = config_toml;
704        if let Some(merged) = self.merge_user_config(&user_home, Some(&resolved)) {
705            resolved = merged;
706        }
707
708        // Substitute ${VAR} from merged secrets
709        substitute_env_vars(&mut resolved, &merged_secrets);
710
711        Some(resolved)
712    }
713
714    /// Spawn a background drain task for a specific session's workflow receiver.
715    fn spawn_user_drain_task(
716        &self,
717        session_id: String,
718        session: Arc<Mutex<Session>>,
719        ws_tx: broadcast::Sender<String>,
720        mut workflow_rx: mpsc::UnboundedReceiver<TuiMessage>,
721    ) {
722        // Bug 6: broadcast StateChanged only on actual transitions. Without
723        // this, every WS message was accompanied by a duplicate StateChanged,
724        // doubling traffic and re-triggering frontend updateState on hot
725        // StreamDelta/ReasoningDelta bursts. New subscribers learn the current
726        // state from the WS snapshot, so the initial None is safe.
727        let mut last_broadcast_state: Option<String> = None;
728        tokio::spawn(async move {
729            while let Some(msg) = workflow_rx.recv().await {
730                {
731                    let mut session = session.lock().await;
732                    session.handle_workflow_message(msg.clone());
733
734                    let state_str = match session.workflow_state {
735                        trustee_core::types::WorkflowState::Idle => "Idle",
736                        trustee_core::types::WorkflowState::Running => "Running",
737                        trustee_core::types::WorkflowState::Cancelling => "Cancelling",
738                    };
739                    if last_broadcast_state.as_deref() != Some(state_str) {
740                        last_broadcast_state = Some(state_str.to_string());
741                        let state_msg = serde_json::json!({
742                            "type": "StateChanged",
743                            "state": state_str
744                        });
745                        let _ = ws_tx.send(state_msg.to_string());
746                    }
747                }
748
749                let json =
750                    serde_json::to_string(&SerializableMessage(&msg)).unwrap_or_default();
751                let _ = ws_tx.send(json);
752            }
753            tracing::debug!("Drain task ended for session: {}", session_id);
754        });
755    }
756
757    /// Spawn the default user's drain task (backward compatibility).
758    /// Called during server startup for the initial session.
759    pub fn spawn_drain_task(self, mut workflow_rx: mpsc::UnboundedReceiver<TuiMessage>) {
760        // Get the default user's first session
761        let default_user = self
762            .sessions
763            .get("default")
764            .expect("default user must exist");
765        let first_entry = default_user
766            .sessions
767            .iter()
768            .next()
769            .expect("default user must have at least one session");
770        let session = first_entry.session.clone();
771        let ws_tx = first_entry.ws_tx.clone();
772        let session_id = first_entry.key().clone();
773        drop(first_entry);
774        drop(default_user);
775
776        // Bug 6: transition-only StateChanged broadcasts (see
777        // spawn_user_drain_task). The task starts in Running state because
778        // spawn_drain_task is only called after a command began executing.
779        let mut last_broadcast_state = Some("Running".to_string());
780        tokio::spawn(async move {
781            while let Some(msg) = workflow_rx.recv().await {
782                {
783                    let mut session = session.lock().await;
784                    session.handle_workflow_message(msg.clone());
785
786                    let state_str = match session.workflow_state {
787                        trustee_core::types::WorkflowState::Idle => "Idle",
788                        trustee_core::types::WorkflowState::Running => "Running",
789                        trustee_core::types::WorkflowState::Cancelling => "Cancelling",
790                    };
791                    if last_broadcast_state.as_deref() != Some(state_str) {
792                        last_broadcast_state = Some(state_str.to_string());
793                        let state_msg = serde_json::json!({
794                            "type": "StateChanged",
795                            "state": state_str
796                        });
797                        let _ = ws_tx.send(state_msg.to_string());
798                    }
799                }
800
801                let json =
802                    serde_json::to_string(&SerializableMessage(&msg)).unwrap_or_default();
803                let _ = ws_tx.send(json);
804            }
805            tracing::debug!("Drain task ended for session: {}", session_id);
806        });
807    }
808
809    /// Resolve the user key from request headers.
810    pub async fn resolve_user_key(&self, headers: &axum::http::HeaderMap) -> String {
811        let Some(ref auth) = self.auth else {
812            return "default".to_string();
813        };
814
815        // Try Bearer header first
816        if let Some(token) = headers
817            .get(axum::http::header::AUTHORIZATION)
818            .and_then(|v| v.to_str().ok())
819            .and_then(|v| v.strip_prefix("Bearer "))
820            .map(|s| s.to_string())
821        {
822            if token.starts_with("dev:") {
823                let parts: Vec<&str> = token.splitn(4, ':').collect();
824                if parts.len() >= 4 {
825                    return format!("dev:{}", parts[1]);
826                }
827            }
828            if let Ok(claims) = auth.validate_token(&token).await {
829                return claims.sub;
830            }
831        }
832
833        // Try cookie
834        let cookie_session_id = headers
835            .get(axum::http::header::COOKIE)
836            .and_then(|v| v.to_str().ok())
837            .and_then(|cookies| {
838                cookies
839                    .split(';')
840                    .map(|c| c.trim())
841                    .find_map(|c| {
842                        c.strip_prefix(&format!("{}=", auth.config.cookie_name))
843                            .map(|s| s.to_string())
844                    })
845            });
846
847        if let Some(session_id) = cookie_session_id {
848            if session_id.starts_with("dev:") {
849                let parts: Vec<&str> = session_id.splitn(4, ':').collect();
850                if parts.len() >= 4 {
851                    return format!("dev:{}", parts[1]);
852                }
853            }
854
855            if let Ok(access_token) = auth.session_manager.get_token(&session_id).await {
856                if let Ok(claims) = auth.validate_token(&access_token).await {
857                    return claims.sub;
858                }
859            }
860        }
861
862        "default".to_string()
863    }
864}
865
866// ---------------------------------------------------------------------------
867// SerializableMessage (unchanged)
868// ---------------------------------------------------------------------------
869
870/// Wrapper to serialize `TuiMessage` as JSON with a `type` discriminator.
871struct SerializableMessage<'a>(&'a TuiMessage);
872
873impl<'a> serde::Serialize for SerializableMessage<'a> {
874    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
875    where
876        S: serde::Serializer,
877    {
878        use serde::ser::SerializeStruct;
879
880        match self.0 {
881            TuiMessage::OutputLine(line) => {
882                let mut s = serializer.serialize_struct("msg", 2)?;
883                s.serialize_field("type", "OutputLine")?;
884                s.serialize_field("line", line)?;
885                s.end()
886            }
887            TuiMessage::StreamDelta(delta) => {
888                let mut s = serializer.serialize_struct("msg", 2)?;
889                s.serialize_field("type", "StreamDelta")?;
890                s.serialize_field("delta", delta)?;
891                s.end()
892            }
893            TuiMessage::ReasoningDelta(delta) => {
894                let mut s = serializer.serialize_struct("msg", 2)?;
895                s.serialize_field("type", "ReasoningDelta")?;
896                s.serialize_field("delta", delta)?;
897                s.end()
898            }
899            TuiMessage::WorkflowCompleted => {
900                let mut s = serializer.serialize_struct("msg", 2)?;
901                s.serialize_field("type", "WorkflowCompleted")?;
902                s.serialize_field("state", "Idle")?;
903                s.end()
904            }
905            TuiMessage::WorkflowError(err) => {
906                let mut s = serializer.serialize_struct("msg", 2)?;
907                s.serialize_field("type", "WorkflowError")?;
908                s.serialize_field("error", err)?;
909                s.end()
910            }
911            TuiMessage::ResumeInfo(info) => match info {
912                Some(ri) => {
913                    let mut s = serializer.serialize_struct("msg", 5)?;
914                    s.serialize_field("type", "ResumeInfo")?;
915                    s.serialize_field("state", "Idle")?;
916                    s.serialize_field("session_id", &ri.session_id)?;
917                    s.serialize_field("checkpoint_id", &ri.checkpoint_id)?;
918                    s.serialize_field("iteration", &ri.iteration)?;
919                    s.end()
920                }
921                None => {
922                    let mut s = serializer.serialize_struct("msg", 2)?;
923                    s.serialize_field("type", "ResumeInfo")?;
924                    s.serialize_field("state", "Idle")?;
925                    s.end()
926                }
927            },
928            TuiMessage::TodoUpdate(content) => {
929                let mut s = serializer.serialize_struct("msg", 2)?;
930                s.serialize_field("type", "TodoUpdate")?;
931                s.serialize_field("content", content)?;
932                s.end()
933            }
934            TuiMessage::WorkflowCancelled => {
935                let mut s = serializer.serialize_struct("msg", 2)?;
936                s.serialize_field("type", "WorkflowCancelled")?;
937                s.serialize_field("state", "Idle")?;
938                s.end()
939            }
940            TuiMessage::HandoffReady(briefing) => {
941                let mut s = serializer.serialize_struct("msg", 3)?;
942                s.serialize_field("type", "HandoffReady")?;
943                s.serialize_field("state", "Idle")?;
944                s.serialize_field("briefing", briefing)?;
945                s.end()
946            }
947            TuiMessage::SessionRotated { old, new } => {
948                let mut s = serializer.serialize_struct("msg", 3)?;
949                s.serialize_field("type", "SessionRotated")?;
950                s.serialize_field("old", old)?;
951                s.serialize_field("new", new)?;
952                s.end()
953            }
954            TuiMessage::HandoffFailed => {
955                let mut s = serializer.serialize_struct("msg", 2)?;
956                s.serialize_field("type", "HandoffFailed")?;
957                s.serialize_field("state", "Idle")?;
958                s.end()
959            }
960            TuiMessage::ToolPending {
961                tool_name,
962                hint,
963            } => {
964                let mut s = serializer.serialize_struct("msg", 3)?;
965                s.serialize_field("type", "ToolPending")?;
966                s.serialize_field("tool_name", tool_name)?;
967                s.serialize_field("hint", hint)?;
968                s.end()
969            }
970            TuiMessage::ToolDone {
971                tool_name,
972                success,
973                hint,
974            } => {
975                let mut s = serializer.serialize_struct("msg", 4)?;
976                s.serialize_field("type", "ToolDone")?;
977                s.serialize_field("tool_name", tool_name)?;
978                s.serialize_field("success", success)?;
979                s.serialize_field("hint", hint)?;
980                s.end()
981            }
982            TuiMessage::ContextTokensUpdated(count) => {
983                let mut s = serializer.serialize_struct("msg", 2)?;
984                s.serialize_field("type", "ContextTokensUpdated")?;
985                s.serialize_field("count", count)?;
986                s.end()
987            }
988            TuiMessage::McpServerStatus {
989                name,
990                connected,
991                tool_count,
992                error,
993            } => {
994                let mut s = serializer.serialize_struct("msg", 5)?;
995                s.serialize_field("type", "McpServerStatus")?;
996                s.serialize_field("name", name)?;
997                s.serialize_field("connected", connected)?;
998                s.serialize_field("tool_count", tool_count)?;
999                s.serialize_field("error", error)?;
1000                s.end()
1001            }
1002            TuiMessage::SessionTitleUpdated(title) => {
1003                let mut s = serializer.serialize_struct("msg", 2)?;
1004                s.serialize_field("type", "SessionTitleUpdated")?;
1005                s.serialize_field("title", title)?;
1006                s.end()
1007            }
1008        }
1009    }
1010}
1011
1012// ---------------------------------------------------------------------------
1013// Per-user config helpers
1014// ---------------------------------------------------------------------------
1015
1016/// Deep-merge a TOML overlay on top of a base value (in-place).
1017///
1018/// - Tables: recursively merge key-by-key (overlay wins on conflict).
1019/// - Arrays: overlay replaces base entirely (no merging).
1020/// - Scalars: overlay replaces base.
1021/// - If a key exists in overlay but not base, it's added.
1022fn deep_merge_toml(base: &mut toml::Value, overlay: &toml::Value) {
1023    match (base, overlay) {
1024        (toml::Value::Table(base_table), toml::Value::Table(overlay_table)) => {
1025            for (key, overlay_val) in overlay_table {
1026                match base_table.get_mut(key) {
1027                    Some(base_val) => {
1028                        // Both exist — recurse if both are tables, else replace
1029                        deep_merge_toml(base_val, overlay_val);
1030                    }
1031                    None => {
1032                        // Key only in overlay — insert
1033                        base_table.insert(key.clone(), overlay_val.clone());
1034                    }
1035                }
1036            }
1037        }
1038        // Non-table: overlay replaces base
1039        (base, overlay) => {
1040            *base = overlay.clone();
1041        }
1042    }
1043}
1044
1045/// Replace `${VAR_NAME}` references in a string with values from a secrets map.
1046///
1047/// Falls back to process environment if the variable is not in the map.
1048/// Variables not found in either are left as-is.
1049fn substitute_env_vars(s: &mut String, secrets: &std::collections::HashMap<String, String>) {
1050    // Simple state machine: scan for ${, read until }, replace.
1051    let mut result = String::with_capacity(s.len());
1052    let bytes = s.as_bytes();
1053    let mut i = 0;
1054
1055    while i < bytes.len() {
1056        if i + 1 < bytes.len() && bytes[i] == b'$' && bytes[i + 1] == b'{' {
1057            // Find closing }
1058            if let Some(end) = s[i + 2..].find('}') {
1059                let var_name = &s[i + 2..i + 2 + end];
1060                // Look up in per-user secrets first, then process env
1061                if let Some(value) = secrets.get(var_name) {
1062                    result.push_str(value);
1063                } else if let Ok(value) = std::env::var(var_name) {
1064                    result.push_str(&value);
1065                } else {
1066                    // Not found — leave as-is
1067                    result.push_str(&s[i..i + 2 + end + 1]);
1068                }
1069                i = i + 2 + end + 1;
1070            } else {
1071                // No closing } — copy as-is
1072                result.push('$');
1073                i += 1;
1074            }
1075        } else {
1076            result.push(bytes[i] as char);
1077            i += 1;
1078        }
1079    }
1080
1081    *s = result;
1082}