Skip to main content

bamboo_server/app_state/
init.rs

1//! Infrastructure initialization helpers.
2//!
3//! These functions create domain-level services (storage, skill manager, provider handles,
4//! MCP servers, metrics, permissions, schedules) that are shared between `AppState`
5//! and `AgentRuntime`.
6
7use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9use std::sync::Arc;
10use std::time::Duration;
11
12use chrono::Utc;
13use tokio::sync::{broadcast, RwLock};
14
15use bamboo_agent_core::storage::Storage;
16use bamboo_agent_core::AgentEvent;
17use bamboo_engine::Agent;
18use bamboo_llm::Config;
19use bamboo_llm::LLMProvider;
20use bamboo_mcp::manager::McpServerManager;
21use bamboo_skills::{SkillManager, SkillStoreConfig};
22use bamboo_storage::LockedSessionStore;
23use bamboo_storage::SessionStoreV2;
24
25use crate::error::AppError;
26use crate::schedule_app::manager::{build_schedule_context, ScheduleContext};
27use crate::schedule_app::ScheduleManager;
28use crate::schedule_app::ScheduleStore;
29use bamboo_engine::execution::spawn::{SpawnContext, SpawnScheduler};
30use bamboo_metrics::metrics_service::MetricsService;
31
32use super::{AgentRunner, AgentStatus};
33
34/// Type alias for the permission checker trait object.
35pub(super) type PermissionChecker = dyn bamboo_tools::permission::PermissionChecker;
36
37/// Type alias for the provider lock + handle pair returned by [`build_provider_handles`].
38type ProviderHandles = (Arc<RwLock<Arc<dyn LLMProvider>>>, Arc<dyn LLMProvider>);
39
40/// Initialize storage components (session store + storage trait object).
41///
42/// Spawns a background task to rebuild the search index on startup.
43pub async fn init_storage(
44    data_dir: &Path,
45) -> Result<(Arc<SessionStoreV2>, Arc<dyn Storage>), AppError> {
46    tracing::info!("Initializing session store V2 at: {:?}", data_dir);
47    let session_store = Arc::new(SessionStoreV2::new(data_dir.to_path_buf()).await.map_err(
48        |error| {
49            tracing::error!(
50                "Failed to initialize SessionStoreV2 at {:?}: {}",
51                data_dir,
52                error
53            );
54            AppError::StorageError(error)
55        },
56    )?);
57
58    // One-shot, idempotent migration: split each legacy session's runtime
59    // control-plane into a `runtime.json` sidecar so the O(1) runtime-save path
60    // (used heavily during sub-agent spawn) is active immediately. Run inline
61    // before the server starts serving so it cannot race concurrent saves; it is
62    // a no-op on already-migrated stores (marker file).
63    match session_store.migrate_runtime_sidecars().await {
64        Ok(0) => {}
65        Ok(count) => tracing::info!("Runtime sidecar migration created {count} sidecar(s)"),
66        Err(error) => {
67            // Non-fatal: loading tolerates a missing sidecar. Log and continue.
68            tracing::warn!("Runtime sidecar migration failed (continuing): {error}");
69        }
70    }
71
72    let session_store_for_rebuild = session_store.clone();
73    tokio::spawn(async move {
74        let purged_rows = match session_store_for_rebuild
75            .search_index()
76            .prune_stale_sessions()
77            .await
78        {
79            Ok(count) => count,
80            Err(error) => {
81                tracing::warn!("Background session search index prune failed: {}", error);
82                0
83            }
84        };
85
86        if let Err(error) = session_store_for_rebuild.rebuild_search_index().await {
87            tracing::warn!("Background session search index rebuild failed: {}", error);
88        } else {
89            tracing::info!("Background session search index rebuild completed");
90        }
91
92        match session_store_for_rebuild
93            .search_index()
94            .maybe_vacuum_if_needed(purged_rows)
95            .await
96        {
97            Ok(true) => tracing::info!("Background session search index vacuum completed"),
98            Ok(false) => {}
99            Err(error) => {
100                tracing::warn!("Background session search index vacuum failed: {}", error)
101            }
102        }
103    });
104
105    let storage: Arc<dyn Storage> = session_store.clone();
106    tracing::info!(
107        "Session store V2 initialized (index: {:?}, sessions: {:?})",
108        session_store.index_path(),
109        session_store.sessions_root_dir()
110    );
111    Ok((session_store, storage))
112}
113
114/// Initialize the skill manager with workspace directory and active mode from environment.
115pub async fn init_skill_manager(data_dir: &Path) -> Arc<SkillManager> {
116    let project_dir = std::env::var_os("BAMBOO_WORKSPACE_DIR")
117        .map(PathBuf::from)
118        .or_else(|| std::env::current_dir().ok());
119    let active_mode = std::env::var("BAMBOO_SKILL_MODE")
120        .ok()
121        .map(|value| value.trim().to_string())
122        .filter(|value| !value.is_empty());
123
124    let skill_manager = Arc::new(SkillManager::with_config(SkillStoreConfig {
125        skills_dir: data_dir.join("skills"),
126        project_dir,
127        active_mode,
128    }));
129    if let Err(error) = skill_manager.initialize().await {
130        tracing::warn!("Failed to initialize skill manager: {}", error);
131    }
132    skill_manager
133}
134
135/// Build reloadable provider handles.
136///
137/// Returns `(provider_lock, provider_handle)` where `provider_lock` is the
138/// hot-reloadable `RwLock` and `provider_handle` is a stable `ReloadableProvider`
139/// that always delegates to the latest value in the lock.
140pub fn build_provider_handles(provider: Arc<dyn LLMProvider>) -> ProviderHandles {
141    let provider_lock: Arc<RwLock<Arc<dyn LLMProvider>>> = Arc::new(RwLock::new(provider));
142    let provider_handle: Arc<dyn LLMProvider> = Arc::new(
143        crate::reloadable_provider::ReloadableProvider::new(provider_lock.clone()),
144    );
145    (provider_lock, provider_handle)
146}
147
148/// Load permission configuration from disk.
149///
150/// Falls back to disabled permissions if no config exists or loading fails.
151pub async fn load_permission_checker(bamboo_home_dir: &Path) -> Arc<PermissionChecker> {
152    use bamboo_tools::permission::{PermissionConfig, RiskLevel};
153
154    let storage = bamboo_tools::permission::storage::PermissionStorage::new(bamboo_home_dir);
155    let permission_config = match storage.load().await {
156        Ok(Some(config)) => config,
157        Ok(None) => {
158            // First run, no saved config: default posture is "ask on high-risk".
159            // Checks are enabled (PermissionConfig::new defaults to enabled +
160            // Default mode); only high-risk operations (execute command, delete,
161            // git write, terminal session) require confirmation. Lower-risk ops
162            // (file writes, HTTP) auto-allow.
163            let cfg = PermissionConfig::new();
164            cfg.set_confirm_threshold(RiskLevel::High);
165            cfg
166        }
167        Err(error) => {
168            tracing::warn!(
169                "Failed to load permission config; defaulting to ask-on-high-risk: {error}"
170            );
171            let cfg = PermissionConfig::new();
172            cfg.set_confirm_threshold(RiskLevel::High);
173            cfg
174        }
175    };
176    permission_config.cleanup_expired_grants();
177
178    // Wrap the config checker in a mode-aware checker so the active PermissionMode
179    // (Default / Plan / AcceptEdits / DontAsk / BypassPermissions) takes effect at
180    // the checker level. Both share the same Arc<PermissionConfig>, so session
181    // grants recorded on approval (see the respond handler) are visible here.
182    let config = Arc::new(permission_config);
183    let inner: Arc<dyn bamboo_tools::permission::PermissionChecker> = Arc::new(
184        bamboo_tools::permission::ConfigPermissionChecker::new(config.clone()),
185    );
186    Arc::new(bamboo_tools::permission::ModeAwarePermissionChecker::new(
187        inner, config,
188    ))
189}
190
191/// Initialize MCP server manager with background server initialization.
192pub fn init_mcp_manager(config: Arc<RwLock<Config>>) -> Arc<McpServerManager> {
193    let mcp_manager = Arc::new(McpServerManager::new_with_config(config.clone()));
194
195    // Initialize MCP servers in the background so the HTTP API is responsive quickly.
196    {
197        let mcp_manager = mcp_manager.clone();
198        let config = config.clone();
199        tokio::spawn(async move {
200            let mcp_config = config.read().await.mcp.clone();
201            mcp_manager.initialize_from_config(&mcp_config).await;
202        });
203    }
204
205    mcp_manager
206}
207
208/// Initialize metrics service with SQLite backend.
209pub async fn init_metrics_service(data_dir: &Path) -> Result<Arc<MetricsService>, AppError> {
210    let service = MetricsService::new(data_dir.join("metrics.db"))
211        .await
212        .map_err(|error| {
213            tracing::error!("Failed to initialize metrics storage: {}", error);
214            AppError::InternalError(anyhow::anyhow!(
215                "Failed to initialize metrics storage: {error}"
216            ))
217        })?;
218    Ok(Arc::new(service))
219}
220
221/// Idle-eviction TTL for terminal runners / session senders, in seconds.
222///
223/// A completed runner is retained for at least this long after `completed_at`
224/// so a client that subscribes shortly after the run finishes still gets the
225/// cached `last_critical_events` / `last_budget_event` replay.
226pub(crate) const SESSION_MAP_IDLE_TTL_SECS: i64 = 300;
227
228/// Single idle-eviction pass over the per-session runner + event-sender maps.
229///
230/// Drops a `(agent_runners, session_event_senders)` pair together when the
231/// runner is terminal, older than `ttl_secs`, and its broadcast channel has **no
232/// live receivers**. Returns the number of runners evicted.
233///
234/// # Why `receiver_count() == 0` is required
235///
236/// A runner's `event_sender` is a clone of the session's long-lived sender (see
237/// `reserve_runner` / `try_reserve_runner`), so `receiver_count()` reflects
238/// every SSE/WS subscriber on the session stream. Evicting while a receiver is
239/// live would (a) yank the replay cache out from under a client that just
240/// subscribed after `Complete`, and (b) silently drop a terminal parent whose
241/// still-running children forward events into this stream (that parent keeps a
242/// live subscription). So we only reclaim genuinely-idle terminal sessions —
243/// exactly the ephemeral sub-agent / guardian / scheduled runs that never had a
244/// UI subscription and whose count therefore reaches zero.
245///
246/// Removing the map's `Sender` (the last clone once the terminal runner's own
247/// clone is dropped by the `retain`) closes the channel, which also lets any
248/// per-session notification relay task exit on `RecvError::Closed`.
249///
250/// This runs synchronously under both write guards held by the caller (no
251/// `.await` between the check and the removals), so a concurrent `reserve_runner`
252/// / `get_or_create_event_sender` (which need those same locks) cannot interleave.
253pub(crate) fn evict_idle_session_entries(
254    runners: &mut HashMap<String, AgentRunner>,
255    senders: &mut HashMap<String, broadcast::Sender<AgentEvent>>,
256    ttl_secs: i64,
257    now: chrono::DateTime<Utc>,
258    log_prefix: Option<&'static str>,
259) -> usize {
260    let mut evicted: Vec<String> = Vec::new();
261
262    runners.retain(|session_id, runner| {
263        let keep = match &runner.status {
264            AgentStatus::Running => true,
265            _ => {
266                let age =
267                    now.signed_duration_since(runner.completed_at.unwrap_or(runner.started_at));
268                let expired = age.num_seconds() >= ttl_secs;
269                let has_receivers = runner.event_sender.receiver_count() > 0;
270                // Keep unless it is BOTH past the TTL AND has no live receiver.
271                !expired || has_receivers
272            }
273        };
274        if !keep {
275            evicted.push(session_id.clone());
276            if let Some(prefix) = log_prefix {
277                tracing::debug!("[{}:{}] Evicting idle terminal runner", prefix, session_id);
278            } else {
279                tracing::debug!("[{}] Evicting idle terminal runner", session_id);
280            }
281        }
282        keep
283    });
284
285    // Drop the paired long-lived sender for each evicted runner, but only if it
286    // too has no live receiver (re-checked here; it is the same channel as the
287    // runner's `event_sender`, so this is normally a formality — a session sender
288    // can, however, outlive its runner, and we must never close a channel a
289    // client is actively reading).
290    for session_id in &evicted {
291        if senders
292            .get(session_id)
293            .is_some_and(|sender| sender.receiver_count() == 0)
294        {
295            senders.remove(session_id);
296        }
297    }
298
299    evicted.len()
300}
301
302/// Spawn a background task that idle-evicts completed agent runners **and their
303/// paired session event senders** after [`SESSION_MAP_IDLE_TTL_SECS`].
304///
305/// Fire-and-forget (runs for the process lifetime), matching the other
306/// background maintenance tickers. See [`evict_idle_session_entries`] for the
307/// eviction predicate and its race-freedom argument.
308pub fn spawn_session_map_cleanup_task(
309    runners: Arc<RwLock<HashMap<String, AgentRunner>>>,
310    senders: Arc<RwLock<HashMap<String, broadcast::Sender<AgentEvent>>>>,
311    log_prefix: Option<&'static str>,
312) {
313    tokio::spawn(async move {
314        loop {
315            tokio::time::sleep(Duration::from_secs(60)).await;
316
317            // Lock order: runners ⊃ senders. This matches `reserve_runner_core`
318            // (which nests senders inside the runners write lock to re-assert the
319            // sender) and is never inverted anywhere, so nesting is deadlock-free.
320            //
321            // Both write locks are intentionally held across the whole O(n) pass
322            // rather than snapshot-then-remove: the atomicity is load-bearing.
323            // `reserve_runner_core` inserts a `Running` runner AND re-asserts its
324            // sender under the runners lock; holding both here means a concurrent
325            // reservation cannot interleave between our runner-eviction and our
326            // sender-removal, so we can never drop a sender that a just-reserved
327            // run re-asserted. The pass is cheap (a receiver_count atomic load +
328            // timestamp math per entry) at the 60s cadence, so the widened window
329            // is acceptable.
330            let mut runners_guard = runners.write().await;
331            let mut senders_guard = senders.write().await;
332            let now = Utc::now();
333            let evicted = evict_idle_session_entries(
334                &mut runners_guard,
335                &mut senders_guard,
336                SESSION_MAP_IDLE_TTL_SECS,
337                now,
338                log_prefix,
339            );
340            drop(senders_guard);
341            drop(runners_guard);
342
343            if evicted > 0 {
344                tracing::debug!("Idle-evicted {evicted} completed session runner(s)/sender(s)");
345            }
346        }
347    });
348}
349
350/// Initialize schedule store for timed tasks.
351pub async fn init_schedule_store(data_dir: &PathBuf) -> Result<Arc<ScheduleStore>, AppError> {
352    let store = ScheduleStore::new(data_dir.clone())
353        .await
354        .map_err(|error| {
355            tracing::error!(
356                "Failed to initialize ScheduleStore at {:?}: {}",
357                data_dir,
358                error
359            );
360            AppError::StorageError(error)
361        })?;
362    Ok(Arc::new(store))
363}
364
365/// Build sub-session spawn scheduler.
366#[allow(clippy::too_many_arguments)]
367pub fn build_spawn_scheduler(
368    agent: Arc<Agent>,
369    child_tools: Arc<dyn bamboo_agent_core::tools::ToolExecutor>,
370    sessions: bamboo_engine::SessionCache,
371    agent_runners: Arc<RwLock<HashMap<String, AgentRunner>>>,
372    session_event_senders: Arc<RwLock<HashMap<String, broadcast::Sender<AgentEvent>>>>,
373    external_child_runner: Arc<dyn bamboo_engine::runtime::execution::ExternalChildRunner>,
374    provider_router: Option<Arc<bamboo_llm::ProviderModelRouter>>,
375    completion_handler: Option<Arc<dyn bamboo_engine::execution::ChildCompletionHandler>>,
376    app_data_dir: Option<std::path::PathBuf>,
377    account_feed_inbox: Option<bamboo_engine::execution::AccountFeedInbox>,
378) -> Arc<SpawnScheduler> {
379    Arc::new(SpawnScheduler::new(SpawnContext {
380        agent,
381        tools: child_tools,
382        sessions_cache: sessions,
383        agent_runners,
384        session_event_senders,
385        external_child_runner,
386        provider_router,
387        app_data_dir,
388        completion_handler,
389        account_feed_inbox,
390    }))
391}
392
393/// Build schedule manager with minimal tool surface for background automation.
394#[allow(clippy::too_many_arguments)]
395pub fn build_schedule_manager(
396    schedule_store: Arc<ScheduleStore>,
397    agent: Arc<Agent>,
398    tools_for_schedules: Arc<dyn bamboo_agent_core::tools::ToolExecutor>,
399    sessions: bamboo_engine::SessionCache,
400    agent_runners: Arc<RwLock<HashMap<String, AgentRunner>>>,
401    session_event_senders: Arc<RwLock<HashMap<String, broadcast::Sender<AgentEvent>>>>,
402    persistence: Arc<LockedSessionStore>,
403    config: Arc<RwLock<Config>>,
404    provider_registry: Arc<bamboo_llm::ProviderRegistry>,
405    app_data_dir: Option<std::path::PathBuf>,
406    account_feed_inbox: Option<bamboo_engine::execution::AccountFeedInbox>,
407    notification_relay: crate::app_state::session_events::NotificationRelayDeps,
408) -> Arc<ScheduleManager> {
409    let base_ctx = ScheduleContext {
410        schedule_store,
411        agent,
412        tools: tools_for_schedules,
413        sessions_cache: sessions,
414        agent_runners,
415        session_event_senders,
416        account_feed_inbox,
417        persistence,
418        app_data_dir,
419        trigger_engine: crate::schedule_app::default_trigger_engine(),
420        notification_relay,
421        resolve_run_config: Arc::new(|_| unimplemented!("replaced by build_schedule_context")),
422    };
423    Arc::new(ScheduleManager::new(build_schedule_context(
424        base_ctx,
425        config,
426        provider_registry,
427    )))
428}
429
430#[cfg(test)]
431mod eviction_tests {
432    use super::*;
433    use chrono::Duration as ChronoDuration;
434
435    const TTL: i64 = SESSION_MAP_IDLE_TTL_SECS;
436
437    /// A terminal (Completed) runner whose `event_sender` shares `tx`'s channel,
438    /// exactly as production sets it in `reserve_runner`.
439    fn terminal_runner(
440        tx: &broadcast::Sender<AgentEvent>,
441        completed_secs_ago: i64,
442        now: chrono::DateTime<Utc>,
443    ) -> AgentRunner {
444        let mut runner = AgentRunner::new();
445        runner.status = AgentStatus::Completed;
446        runner.completed_at = Some(now - ChronoDuration::seconds(completed_secs_ago));
447        runner.event_sender = tx.clone();
448        runner
449    }
450
451    fn insert_pair(
452        runners: &mut HashMap<String, AgentRunner>,
453        senders: &mut HashMap<String, broadcast::Sender<AgentEvent>>,
454        id: &str,
455        runner: AgentRunner,
456        tx: broadcast::Sender<AgentEvent>,
457    ) {
458        runners.insert(id.to_string(), runner);
459        senders.insert(id.to_string(), tx);
460    }
461
462    #[test]
463    fn evicts_idle_terminal_pair_past_ttl_with_no_receivers() {
464        let now = Utc::now();
465        let (tx, _) = broadcast::channel::<AgentEvent>(16); // receiver dropped -> count 0
466        let mut runners = HashMap::new();
467        let mut senders = HashMap::new();
468        insert_pair(
469            &mut runners,
470            &mut senders,
471            "s1",
472            terminal_runner(&tx, TTL + 100, now),
473            tx.clone(),
474        );
475
476        let evicted = evict_idle_session_entries(&mut runners, &mut senders, TTL, now, None);
477
478        assert_eq!(evicted, 1);
479        assert!(runners.is_empty(), "terminal idle runner must be dropped");
480        assert!(
481            senders.is_empty(),
482            "paired session sender must be dropped together with the runner"
483        );
484    }
485
486    #[test]
487    fn retains_terminal_runner_with_live_receiver() {
488        let now = Utc::now();
489        let (tx, _) = broadcast::channel::<AgentEvent>(16);
490        // A client subscribed just after completion — its receiver is live.
491        let _live_rx = tx.subscribe();
492        assert_eq!(tx.receiver_count(), 1);
493
494        let mut runners = HashMap::new();
495        let mut senders = HashMap::new();
496        insert_pair(
497            &mut runners,
498            &mut senders,
499            "s1",
500            terminal_runner(&tx, TTL + 100, now),
501            tx.clone(),
502        );
503
504        let evicted = evict_idle_session_entries(&mut runners, &mut senders, TTL, now, None);
505
506        assert_eq!(evicted, 0, "must not evict while a receiver is live");
507        assert!(runners.contains_key("s1"));
508        assert!(senders.contains_key("s1"));
509    }
510
511    #[test]
512    fn retains_young_terminal_runner() {
513        let now = Utc::now();
514        let (tx, _) = broadcast::channel::<AgentEvent>(16);
515        let mut runners = HashMap::new();
516        let mut senders = HashMap::new();
517        // Completed only 60s ago — inside the replay window.
518        insert_pair(
519            &mut runners,
520            &mut senders,
521            "s1",
522            terminal_runner(&tx, 60, now),
523            tx.clone(),
524        );
525
526        let evicted = evict_idle_session_entries(&mut runners, &mut senders, TTL, now, None);
527
528        assert_eq!(
529            evicted, 0,
530            "must preserve the post-completion replay window"
531        );
532        assert!(runners.contains_key("s1"));
533        assert!(senders.contains_key("s1"));
534    }
535
536    #[test]
537    fn retains_running_runner_regardless_of_age() {
538        let now = Utc::now();
539        let (tx, _) = broadcast::channel::<AgentEvent>(16);
540        let mut runner = AgentRunner::new();
541        runner.status = AgentStatus::Running;
542        runner.started_at = now - ChronoDuration::seconds(TTL + 100_000);
543        runner.completed_at = None;
544        runner.event_sender = tx.clone();
545
546        let mut runners = HashMap::new();
547        let mut senders = HashMap::new();
548        insert_pair(&mut runners, &mut senders, "s1", runner, tx.clone());
549
550        let evicted = evict_idle_session_entries(&mut runners, &mut senders, TTL, now, None);
551
552        assert_eq!(evicted, 0, "a Running runner is never evicted");
553        assert!(runners.contains_key("s1"));
554        assert!(senders.contains_key("s1"));
555    }
556
557    #[test]
558    fn keeps_sender_if_it_has_receivers_even_when_runner_evicted() {
559        // Defensive edge: the runner qualifies for eviction, but the session
560        // sender independently has a live receiver (e.g. a subscriber that
561        // reused the channel). The sender must survive so we never close a
562        // channel a client is reading.
563        let now = Utc::now();
564        let (runner_tx, _) = broadcast::channel::<AgentEvent>(16);
565        // Distinct channel for the map entry, with a live receiver.
566        let (sender_tx, _) = broadcast::channel::<AgentEvent>(16);
567        let _live = sender_tx.subscribe();
568
569        let mut runners = HashMap::new();
570        let mut senders = HashMap::new();
571        runners.insert(
572            "s1".to_string(),
573            terminal_runner(&runner_tx, TTL + 100, now),
574        );
575        senders.insert("s1".to_string(), sender_tx.clone());
576
577        let evicted = evict_idle_session_entries(&mut runners, &mut senders, TTL, now, None);
578
579        assert_eq!(evicted, 1, "runner (no receivers) is evicted");
580        assert!(runners.is_empty());
581        assert!(
582            senders.contains_key("s1"),
583            "sender with a live receiver must be retained even when the runner is dropped"
584        );
585    }
586}