#![allow(dead_code)] use std::net::SocketAddr; use std::sync::Arc; use std::time::{Duration, Instant}; use async_trait::async_trait; use secrecy::SecretString; use tokio::sync::mpsc; use tokio::sync::oneshot; use ironclaw::agent::routine_engine::RoutineEngine; use ironclaw::agent::{Agent, AgentDeps, SessionManager as AgentSessionManager}; use ironclaw::app::{AppBuilder, AppBuilderFlags}; use ironclaw::channels::IncomingMessage; use ironclaw::channels::web::log_layer::LogBroadcaster; use ironclaw::channels::web::server::{GatewayState, RateLimiter, start_server}; use ironclaw::channels::web::sse::SseManager; use ironclaw::channels::web::ws::WsConnectionTracker; use ironclaw::config::{Config, RegistryProviderConfig, RoutineConfig}; use ironclaw::db::Database; use ironclaw::db::libsql::LibSqlBackend; use ironclaw::llm::registry::ProviderProtocol; use ironclaw::llm::{ SessionConfig as LlmSessionConfig, SessionManager as LlmSessionManager, create_llm_provider, }; use ironclaw::secrets::SecretsStore; use ironclaw::tools::{Tool, ToolError, ToolOutput}; use crate::support::test_channel::{TestChannel, TestChannelHandle}; struct MockGithubWebhookTool; #[async_trait] impl Tool for MockGithubWebhookTool { fn name(&self) -> &str { "github" } fn description(&self) -> &str { "Mock GitHub webhook parser for integration harness" } fn parameters_schema(&self) -> serde_json::Value { serde_json::json!({"type":"object"}) } async fn execute( &self, params: serde_json::Value, _ctx: &ironclaw::context::JobContext, ) -> Result { let event = params .pointer("/webhook/headers/x-github-event") .and_then(|v| v.as_str()) .ok_or_else(|| ToolError::InvalidParameters("missing x-github-event".to_string()))?; let action = params .pointer("/webhook/body_json/action") .and_then(|v| v.as_str()) .unwrap_or("unknown"); let mut payload = params .pointer("/webhook/body_json") .cloned() .unwrap_or_else(|| serde_json::json!({})); if payload.get("repository").and_then(|v| v.as_str()).is_none() && let Some(full_name) = payload .pointer("/repository/full_name") .and_then(|v| v.as_str()) { payload["repository"] = serde_json::json!(full_name); } let event_type = format!( "{}.{}", if event == "issues" { "issue" } else { event }, action ); Ok(ToolOutput::success( serde_json::json!({ "emit_events": [{ "source": "github", "event_type": event_type, "payload": payload }] }), Duration::from_millis(1), )) } fn webhook_capability(&self) -> Option { Some(ironclaw::tools::wasm::WebhookCapability { secret_name: Some("github_webhook_secret".to_string()), secret_header: Some("x-webhook-secret".to_string()), ..Default::default() }) } } pub struct GatewayWorkflowHarness { pub addr: SocketAddr, pub webhook_addr: SocketAddr, pub auth_token: String, pub client: reqwest::Client, pub user_id: String, pub test_channel: Arc, pub db: Arc, gateway_state: Arc, agent_handle: Option>, bridge_handle: Option>, webhook_shutdown_tx: Option>, webhook_handle: Option>, _temp_dir: tempfile::TempDir, } impl GatewayWorkflowHarness { pub async fn start_openai_compatible(base_url: &str, model: &str) -> Self { let temp_dir = tempfile::tempdir().expect("failed to create temp dir"); let db_path = temp_dir.path().join("gateway_workflow_harness.db"); let backend = LibSqlBackend::new_local(&db_path) .await .expect("failed to create test db"); backend .run_migrations() .await .expect("failed to run migrations"); let db: Arc = Arc::new(backend); let skills_dir = temp_dir.path().join("skills"); let installed_skills_dir = temp_dir.path().join("installed_skills"); let _ = std::fs::create_dir_all(&skills_dir); let _ = std::fs::create_dir_all(&installed_skills_dir); let mut config = Config::for_testing(db_path, skills_dir, installed_skills_dir); config.agent.auto_approve_tools = true; config.routines.enabled = true; config.routines.max_concurrent_routines = 4; config.llm.backend = "openai_compatible".to_string(); config.llm.provider = Some(RegistryProviderConfig { protocol: ProviderProtocol::OpenAiCompletions, provider_id: "openai_compatible".to_string(), api_key: Some(SecretString::from("dummy".to_string())), base_url: base_url.to_string(), model: model.to_string(), extra_headers: Vec::new(), oauth_token: None, is_codex_chatgpt: false, refresh_token: None, auth_path: None, cache_retention: Default::default(), unsupported_params: Vec::new(), }); let llm_session = Arc::new(LlmSessionManager::new(LlmSessionConfig::default())); let llm = create_llm_provider(&config.llm, Arc::clone(&llm_session)) .await .expect("failed to create openai-compatible provider"); let log_broadcaster = Arc::new(LogBroadcaster::new()); let mut app_builder = AppBuilder::new( config, AppBuilderFlags::default(), None, Arc::clone(&llm_session), log_broadcaster, ); app_builder.with_database(Arc::clone(&db)); app_builder.with_llm(llm); let components = app_builder .build_all() .await .expect("failed to build app components"); components .tools .register(Arc::new(MockGithubWebhookTool)) .await; components.tools.register_job_tools( Arc::clone(&components.context_manager), None, None, components.db.clone(), None, None, None, None, ); // Agent::run() creates its own RoutineEngine and populates this slot. let routine_slot: Arc>>> = Arc::new(tokio::sync::RwLock::new(None)); let test_channel = Arc::new(TestChannel::new()); let handle = TestChannelHandle::with_name(Arc::clone(&test_channel), "gateway"); let channel_manager = ironclaw::channels::ChannelManager::new(); channel_manager.add(Box::new(handle)).await; let channels = Arc::new(channel_manager); let user_id = "gateway-test-user".to_string(); let (gw_tx, mut gw_rx) = mpsc::channel::(256); let forward_channel = Arc::clone(&test_channel); let bridge_handle = tokio::spawn(async move { while let Some(msg) = gw_rx.recv().await { forward_channel.send_incoming(msg).await; } }); let scheduler_slot: ironclaw::tools::builtin::SchedulerSlot = Arc::new(tokio::sync::RwLock::new(None)); let agent_session_manager = Arc::new(AgentSessionManager::new()); let gateway_state = Arc::new(GatewayState { msg_tx: tokio::sync::RwLock::new(Some(gw_tx)), sse: SseManager::new(), workspace: components.workspace.clone(), session_manager: Some(Arc::clone(&agent_session_manager)), log_broadcaster: None, log_level_handle: None, extension_manager: components.extension_manager.clone(), tool_registry: Some(Arc::clone(&components.tools)), store: components.db.clone(), job_manager: None, prompt_queue: None, scheduler: Some(scheduler_slot.clone()), user_id: user_id.clone(), shutdown_tx: tokio::sync::RwLock::new(None), ws_tracker: Some(Arc::new(WsConnectionTracker::new())), llm_provider: Some(Arc::clone(&components.llm)), skill_registry: components.skill_registry.clone(), skill_catalog: components.skill_catalog.clone(), chat_rate_limiter: RateLimiter::new(120, 60), oauth_rate_limiter: RateLimiter::new(10, 60), registry_entries: Vec::new(), cost_guard: Some(Arc::clone(&components.cost_guard)), routine_engine: Arc::clone(&routine_slot), startup_time: Instant::now(), active_config: ironclaw::channels::web::server::ActiveConfigSnapshot::default(), }); let mut agent = Agent::new( components.config.agent.clone(), AgentDeps { owner_id: components.config.owner_id.clone(), store: components.db, llm: components.llm, cheap_llm: components.cheap_llm, safety: components.safety, tools: components.tools, workspace: components.workspace, extension_manager: components.extension_manager, skill_registry: components.skill_registry, skill_catalog: components.skill_catalog, skills_config: components.config.skills.clone(), hooks: components.hooks, cost_guard: components.cost_guard, sse_tx: Some(gateway_state.sse.sender()), http_interceptor: None, transcription: None, document_extraction: None, builder: None, }, channels, None, None, Some(RoutineConfig { enabled: true, cron_check_interval_secs: 60, max_concurrent_routines: 4, default_cooldown_secs: 300, max_lightweight_tokens: 4096, lightweight_tools_enabled: true, lightweight_max_iterations: 3, }), Some(Arc::clone(&components.context_manager)), Some(Arc::clone(&agent_session_manager)), ); agent.set_routine_engine_slot(Arc::clone(&routine_slot)); *scheduler_slot.write().await = Some(agent.scheduler()); let agent_handle = tokio::spawn(async move { let _ = agent.run().await; }); if let Some(rx) = test_channel.take_ready_rx().await { let _ = tokio::time::timeout(Duration::from_secs(5), rx).await; } let auth_token = "gateway-test-token".to_string(); let addr = start_server( "127.0.0.1:0".parse().expect("valid localhost addr"), Arc::clone(&gateway_state), auth_token.clone(), ) .await .expect("failed to start gateway server"); let webhook_secrets = Arc::new(ironclaw::secrets::InMemorySecretsStore::new(Arc::new( ironclaw::secrets::SecretsCrypto::new(SecretString::from( "test-key-at-least-32-chars-long!!".to_string(), )) .expect("crypto"), ))); webhook_secrets .create( &user_id, ironclaw::secrets::CreateSecretParams::new( "github_webhook_secret", "test-webhook-secret", ), ) .await .expect("store webhook secret"); let webhook_state = ironclaw::webhooks::ToolWebhookState { tools: Arc::clone(gateway_state.tool_registry.as_ref().expect("tool registry")), routine_engine: Arc::clone(&routine_slot), user_id: user_id.clone(), secrets_store: Some( webhook_secrets as Arc, ), }; let webhook_app = ironclaw::webhooks::routes(webhook_state); let webhook_listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .expect("failed to bind webhook listener"); let webhook_addr = webhook_listener.local_addr().expect("webhook local addr"); let (webhook_shutdown_tx, webhook_shutdown_rx) = oneshot::channel(); let webhook_handle = tokio::spawn(async move { let _ = axum::serve(webhook_listener, webhook_app) .with_graceful_shutdown(async { let _ = webhook_shutdown_rx.await; }) .await; }); let client = reqwest::Client::builder() .timeout(Duration::from_secs(10)) .build() .expect("failed to build reqwest client"); Self { addr, webhook_addr, auth_token, client, user_id, test_channel, db, gateway_state, agent_handle: Some(agent_handle), bridge_handle: Some(bridge_handle), webhook_shutdown_tx: Some(webhook_shutdown_tx), webhook_handle: Some(webhook_handle), _temp_dir: temp_dir, } } pub fn base_url(&self) -> String { format!("http://{}", self.addr) } pub fn webhook_base_url(&self) -> String { format!("http://{}", self.webhook_addr) } pub async fn create_thread(&self) -> String { let resp = self .client .post(format!("{}/api/chat/thread/new", self.base_url())) .bearer_auth(&self.auth_token) .send() .await .expect("create thread request failed") .error_for_status() .expect("create thread non-2xx") .json::() .await .expect("invalid thread response"); resp.get("id") .and_then(|v| v.as_str()) .expect("thread id missing") .to_string() } pub async fn send_chat(&self, thread_id: &str, content: &str) { let _ = self .client .post(format!("{}/api/chat/send", self.base_url())) .bearer_auth(&self.auth_token) .json(&serde_json::json!({"thread_id": thread_id, "content": content})) .send() .await .expect("chat send failed") .error_for_status() .expect("chat send non-2xx"); } pub async fn history(&self, thread_id: &str) -> serde_json::Value { self.client .get(format!( "{}/api/chat/history?thread_id={thread_id}", self.base_url() )) .bearer_auth(&self.auth_token) .send() .await .expect("history request failed") .error_for_status() .expect("history non-2xx") .json::() .await .expect("invalid history response") } pub async fn wait_for_turns( &self, thread_id: &str, min_turns: usize, timeout: Duration, ) -> serde_json::Value { let deadline = Instant::now() + timeout; loop { let history = self.history(thread_id).await; let turns = history .get("turns") .and_then(|v| v.as_array()) .map(|v| v.len()) .unwrap_or_default(); if turns >= min_turns { return history; } assert!(Instant::now() < deadline, "timed out waiting for turns"); tokio::time::sleep(Duration::from_millis(100)).await; } } pub async fn list_routines(&self) -> serde_json::Value { self.client .get(format!("{}/api/routines", self.base_url())) .bearer_auth(&self.auth_token) .send() .await .expect("routines request failed") .error_for_status() .expect("routines non-2xx") .json::() .await .expect("invalid routines response") } pub async fn routine_by_name(&self, name: &str) -> Option { let routines = self.list_routines().await; routines .get("routines") .and_then(|v| v.as_array()) .and_then(|arr| { arr.iter() .find(|r| r.get("name").and_then(|v| v.as_str()) == Some(name)) .cloned() }) } pub async fn routine_runs(&self, routine_id: &str) -> serde_json::Value { self.client .get(format!( "{}/api/routines/{routine_id}/runs", self.base_url() )) .bearer_auth(&self.auth_token) .send() .await .expect("routine runs request failed") .error_for_status() .expect("routine runs non-2xx") .json::() .await .expect("invalid routine runs response") } pub async fn github_webhook( &self, event: &str, payload: serde_json::Value, ) -> serde_json::Value { self.client .post(format!("{}/webhook/tools/github", self.webhook_base_url())) .header("x-github-event", event) .header("x-webhook-secret", "test-webhook-secret") .json(&payload) .send() .await .expect("webhook request failed") .error_for_status() .expect("webhook non-2xx") .json::() .await .expect("invalid webhook response") } pub async fn shutdown(mut self) { self.test_channel.signal_shutdown(); if let Some(tx) = self.gateway_state.shutdown_tx.write().await.take() { let _ = tx.send(()); } if let Some(tx) = self.webhook_shutdown_tx.take() { let _ = tx.send(()); } if let Some(handle) = self.bridge_handle.take() { handle.abort(); } if let Some(handle) = self.webhook_handle.take() { let _ = handle.await; } if let Some(handle) = self.agent_handle.take() { handle.abort(); } } } impl Drop for GatewayWorkflowHarness { fn drop(&mut self) { self.test_channel.signal_shutdown(); if let Some(handle) = self.bridge_handle.take() { handle.abort(); } if let Some(handle) = self.webhook_handle.take() { handle.abort(); } if let Some(handle) = self.agent_handle.take() { handle.abort(); } } }