1use 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
34pub(super) type PermissionChecker = dyn bamboo_tools::permission::PermissionChecker;
36
37type ProviderHandles = (Arc<RwLock<Arc<dyn LLMProvider>>>, Arc<dyn LLMProvider>);
39
40pub 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 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 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
114pub 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
135pub 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
148pub 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 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 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
191pub fn init_mcp_manager(config: Arc<RwLock<Config>>) -> Arc<McpServerManager> {
193 let mcp_manager = Arc::new(McpServerManager::new_with_config(config.clone()));
194
195 {
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
208pub 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
221pub(crate) const SESSION_MAP_IDLE_TTL_SECS: i64 = 300;
227
228pub(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 !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 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
302pub 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 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
350pub 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#[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#[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#[allow(clippy::too_many_arguments)]
439pub async fn build_connect_manager(
440 agent: Arc<Agent>,
441 tools: Arc<dyn bamboo_agent_core::tools::ToolExecutor>,
442 session_repo: bamboo_engine::SessionRepository,
443 agent_runners: Arc<RwLock<HashMap<String, AgentRunner>>>,
444 session_event_senders: Arc<RwLock<HashMap<String, broadcast::Sender<AgentEvent>>>>,
445 account_feed_inbox: Option<bamboo_engine::execution::AccountFeedInbox>,
446 app_data_dir: Option<PathBuf>,
447 config: Arc<RwLock<Config>>,
448 provider_registry: Arc<bamboo_llm::ProviderRegistry>,
449 permission_checker: Arc<PermissionChecker>,
450) -> crate::connect::ConnectManager {
451 let config_snapshot = config.read().await.clone();
452 let ctx = crate::connect::ConnectContext {
453 agent,
454 tools,
455 session_repo,
456 agent_runners,
457 session_event_senders,
458 account_feed_inbox,
459 app_data_dir: app_data_dir.clone(),
460 config,
461 provider_registry,
462 permission_checker,
463 };
464 crate::connect::ConnectManager::start(ctx, &config_snapshot, app_data_dir).await
465}
466
467#[cfg(test)]
468mod eviction_tests {
469 use super::*;
470 use chrono::Duration as ChronoDuration;
471
472 const TTL: i64 = SESSION_MAP_IDLE_TTL_SECS;
473
474 fn terminal_runner(
477 tx: &broadcast::Sender<AgentEvent>,
478 completed_secs_ago: i64,
479 now: chrono::DateTime<Utc>,
480 ) -> AgentRunner {
481 let mut runner = AgentRunner::new();
482 runner.status = AgentStatus::Completed;
483 runner.completed_at = Some(now - ChronoDuration::seconds(completed_secs_ago));
484 runner.event_sender = tx.clone();
485 runner
486 }
487
488 fn insert_pair(
489 runners: &mut HashMap<String, AgentRunner>,
490 senders: &mut HashMap<String, broadcast::Sender<AgentEvent>>,
491 id: &str,
492 runner: AgentRunner,
493 tx: broadcast::Sender<AgentEvent>,
494 ) {
495 runners.insert(id.to_string(), runner);
496 senders.insert(id.to_string(), tx);
497 }
498
499 #[test]
500 fn evicts_idle_terminal_pair_past_ttl_with_no_receivers() {
501 let now = Utc::now();
502 let (tx, _) = broadcast::channel::<AgentEvent>(16); let mut runners = HashMap::new();
504 let mut senders = HashMap::new();
505 insert_pair(
506 &mut runners,
507 &mut senders,
508 "s1",
509 terminal_runner(&tx, TTL + 100, now),
510 tx.clone(),
511 );
512
513 let evicted = evict_idle_session_entries(&mut runners, &mut senders, TTL, now, None);
514
515 assert_eq!(evicted, 1);
516 assert!(runners.is_empty(), "terminal idle runner must be dropped");
517 assert!(
518 senders.is_empty(),
519 "paired session sender must be dropped together with the runner"
520 );
521 }
522
523 #[test]
524 fn retains_terminal_runner_with_live_receiver() {
525 let now = Utc::now();
526 let (tx, _) = broadcast::channel::<AgentEvent>(16);
527 let _live_rx = tx.subscribe();
529 assert_eq!(tx.receiver_count(), 1);
530
531 let mut runners = HashMap::new();
532 let mut senders = HashMap::new();
533 insert_pair(
534 &mut runners,
535 &mut senders,
536 "s1",
537 terminal_runner(&tx, TTL + 100, now),
538 tx.clone(),
539 );
540
541 let evicted = evict_idle_session_entries(&mut runners, &mut senders, TTL, now, None);
542
543 assert_eq!(evicted, 0, "must not evict while a receiver is live");
544 assert!(runners.contains_key("s1"));
545 assert!(senders.contains_key("s1"));
546 }
547
548 #[test]
549 fn retains_young_terminal_runner() {
550 let now = Utc::now();
551 let (tx, _) = broadcast::channel::<AgentEvent>(16);
552 let mut runners = HashMap::new();
553 let mut senders = HashMap::new();
554 insert_pair(
556 &mut runners,
557 &mut senders,
558 "s1",
559 terminal_runner(&tx, 60, now),
560 tx.clone(),
561 );
562
563 let evicted = evict_idle_session_entries(&mut runners, &mut senders, TTL, now, None);
564
565 assert_eq!(
566 evicted, 0,
567 "must preserve the post-completion replay window"
568 );
569 assert!(runners.contains_key("s1"));
570 assert!(senders.contains_key("s1"));
571 }
572
573 #[test]
574 fn retains_running_runner_regardless_of_age() {
575 let now = Utc::now();
576 let (tx, _) = broadcast::channel::<AgentEvent>(16);
577 let mut runner = AgentRunner::new();
578 runner.status = AgentStatus::Running;
579 runner.started_at = now - ChronoDuration::seconds(TTL + 100_000);
580 runner.completed_at = None;
581 runner.event_sender = tx.clone();
582
583 let mut runners = HashMap::new();
584 let mut senders = HashMap::new();
585 insert_pair(&mut runners, &mut senders, "s1", runner, tx.clone());
586
587 let evicted = evict_idle_session_entries(&mut runners, &mut senders, TTL, now, None);
588
589 assert_eq!(evicted, 0, "a Running runner is never evicted");
590 assert!(runners.contains_key("s1"));
591 assert!(senders.contains_key("s1"));
592 }
593
594 #[test]
595 fn keeps_sender_if_it_has_receivers_even_when_runner_evicted() {
596 let now = Utc::now();
601 let (runner_tx, _) = broadcast::channel::<AgentEvent>(16);
602 let (sender_tx, _) = broadcast::channel::<AgentEvent>(16);
604 let _live = sender_tx.subscribe();
605
606 let mut runners = HashMap::new();
607 let mut senders = HashMap::new();
608 runners.insert(
609 "s1".to_string(),
610 terminal_runner(&runner_tx, TTL + 100, now),
611 );
612 senders.insert("s1".to_string(), sender_tx.clone());
613
614 let evicted = evict_idle_session_entries(&mut runners, &mut senders, TTL, now, None);
615
616 assert_eq!(evicted, 1, "runner (no receivers) is evicted");
617 assert!(runners.is_empty());
618 assert!(
619 senders.contains_key("s1"),
620 "sender with a live receiver must be retained even when the runner is dropped"
621 );
622 }
623}