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