//! Application context — shared state (`AppContext`) threaded through all HTTP handlers. use crate::agents::{AgentPool, ReconciliationEvent}; use crate::chat::timer::TimerStore; use crate::io::watcher::WatcherEvent; use crate::rebuild::{BotShutdownNotifier, ShutdownReason}; use crate::state::SessionState; use crate::store::JsonFileStore; use crate::workflow::WorkflowState; use poem::http::StatusCode; use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; use tokio::sync::{broadcast, mpsc, oneshot}; /// A running or completed test job spawned by the `run_tests` MCP tool. pub struct TestJob { /// The child process handle. `None` once the process has exited and results /// have been collected. pub child: Option, /// Populated once the child exits. pub result: Option, /// When the job was started. pub started_at: std::time::Instant, } /// The result of a completed test job. #[derive(Clone)] pub struct TestJobResult { pub passed: bool, pub exit_code: i32, pub tests_passed: u64, pub tests_failed: u64, pub output: String, } /// Shared registry of in-flight and recently completed test jobs, keyed by /// worktree path. pub type TestJobRegistry = Arc>>; /// The user's decision when responding to a permission dialog. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PermissionDecision { /// One-time denial. Deny, /// One-time approval. Approve, /// Approve and persist the rule to `.claude/settings.json` so Claude Code's /// built-in permission system handles future checks without prompting. AlwaysAllow, } /// A permission request forwarded from the MCP `prompt_permission` tool to the /// active WebSocket session. The MCP handler blocks on `response_tx` until the /// user approves or denies via the frontend dialog. pub struct PermissionForward { pub request_id: String, pub tool_name: String, pub tool_input: serde_json::Value, pub response_tx: oneshot::Sender, } #[derive(Clone)] pub struct AppContext { pub state: Arc, pub store: Arc, pub workflow: Arc>, pub agents: Arc, /// Broadcast channel for filesystem watcher events. WebSocket handlers /// subscribe to this to push lifecycle notifications to connected clients. pub watcher_tx: broadcast::Sender, /// Broadcast channel for startup reconciliation progress events. /// WebSocket handlers subscribe to this to push real-time reconciliation /// updates to connected clients. pub reconciliation_tx: broadcast::Sender, /// Sender for permission requests originating from the MCP /// `prompt_permission` tool. The MCP handler sends a [`PermissionForward`] /// and awaits the oneshot response. pub perm_tx: mpsc::UnboundedSender, /// Receiver for permission requests. The active WebSocket handler locks /// this and polls for incoming permission forwards. pub perm_rx: Arc>>, /// Child process of the QA app launched for manual testing. /// Only one instance runs at a time. pub qa_app_process: Arc>>, /// Best-effort shutdown notifier for active bot channels (Slack / WhatsApp). /// /// When set, the MCP `rebuild_and_restart` tool uses this to announce the /// shutdown to configured channels before re-execing the server binary. /// `None` when no webhook-based bot transport is configured. pub bot_shutdown: Option>, /// Watch sender used to signal the Matrix bot task that the server is /// shutting down (rebuild path). The bot task listens for this signal and /// sends a shutdown announcement to all configured rooms. /// /// Wrapped in `Arc` so `AppContext` can implement `Clone`. /// `None` when no Matrix bot is configured. pub matrix_shutdown_tx: Option>>>, /// Shared rate-limit retry timer store. /// /// Used by MCP tools (`move_story`, `stop_agent`) to cancel pending timers /// when the user manually intervenes (bug 501). Shared with the tick loop /// spawned by the bot so that cancellations take effect in-memory rather /// than only on disk. pub timer_store: Arc, /// Registry of running/completed test jobs spawned by the `run_tests` MCP /// tool. Keyed by worktree path so each worktree has at most one active job. pub test_jobs: TestJobRegistry, } #[cfg(test)] impl AppContext { pub fn new_test(project_root: std::path::PathBuf) -> Self { let state = SessionState::default(); *state.project_root.lock().unwrap() = Some(project_root.clone()); let store_path = project_root.join(".huskies_store.json"); let (watcher_tx, _) = broadcast::channel(64); let (reconciliation_tx, _) = broadcast::channel(64); let (perm_tx, perm_rx) = mpsc::unbounded_channel(); let timer_store = Arc::new(TimerStore::load( project_root.join(".huskies").join("timers.json"), )); Self { state: Arc::new(state), store: Arc::new(JsonFileStore::new(store_path).unwrap()), workflow: Arc::new(std::sync::Mutex::new(WorkflowState::default())), agents: Arc::new(AgentPool::new(3001, watcher_tx.clone())), watcher_tx, reconciliation_tx, perm_tx, perm_rx: Arc::new(tokio::sync::Mutex::new(perm_rx)), qa_app_process: Arc::new(std::sync::Mutex::new(None)), bot_shutdown: None, matrix_shutdown_tx: None, timer_store, test_jobs: Arc::new(std::sync::Mutex::new(HashMap::new())), } } } pub type OpenApiResult = poem::Result; pub fn bad_request(message: String) -> poem::Error { poem::Error::from_string(message, StatusCode::BAD_REQUEST) } pub fn not_found(message: String) -> poem::Error { poem::Error::from_string(message, StatusCode::NOT_FOUND) } #[cfg(test)] mod tests { use super::*; #[test] fn bad_request_returns_400_status() { let err = bad_request("something went wrong".to_string()); assert_eq!(err.status(), StatusCode::BAD_REQUEST); } #[test] fn bad_request_accepts_empty_message() { let err = bad_request(String::new()); assert_eq!(err.status(), StatusCode::BAD_REQUEST); } #[test] fn permission_decision_equality() { assert_eq!(PermissionDecision::Deny, PermissionDecision::Deny); assert_eq!(PermissionDecision::Approve, PermissionDecision::Approve); assert_eq!( PermissionDecision::AlwaysAllow, PermissionDecision::AlwaysAllow ); assert_ne!(PermissionDecision::Deny, PermissionDecision::Approve); assert_ne!(PermissionDecision::Approve, PermissionDecision::AlwaysAllow); } #[test] fn not_found_returns_404_status() { let err = not_found("item not found".to_string()); assert_eq!(err.status(), StatusCode::NOT_FOUND); } }