2026-04-12 13:11:23 +00:00
|
|
|
//! Application context — shared state (`AppContext`) threaded through all HTTP handlers.
|
2026-03-22 19:07:07 +00:00
|
|
|
use crate::agents::{AgentPool, ReconciliationEvent};
|
|
|
|
|
use crate::io::watcher::WatcherEvent;
|
2026-03-22 19:08:41 +00:00
|
|
|
use crate::rebuild::{BotShutdownNotifier, ShutdownReason};
|
2026-04-24 17:39:42 +00:00
|
|
|
use crate::service::timer::TimerStore;
|
2026-03-22 19:07:07 +00:00
|
|
|
use crate::state::SessionState;
|
|
|
|
|
use crate::store::JsonFileStore;
|
|
|
|
|
use crate::workflow::WorkflowState;
|
|
|
|
|
use poem::http::StatusCode;
|
2026-04-11 22:00:05 +00:00
|
|
|
use std::collections::HashMap;
|
|
|
|
|
use std::path::PathBuf;
|
2026-03-22 19:07:07 +00:00
|
|
|
use std::sync::Arc;
|
|
|
|
|
use tokio::sync::{broadcast, mpsc, oneshot};
|
|
|
|
|
|
2026-04-11 22:00:05 +00:00
|
|
|
/// 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<std::process::Child>,
|
|
|
|
|
/// Populated once the child exits.
|
|
|
|
|
pub result: Option<TestJobResult>,
|
|
|
|
|
/// 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<std::sync::Mutex<HashMap<PathBuf, TestJob>>>;
|
|
|
|
|
|
2026-03-22 19:07:07 +00:00
|
|
|
/// 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<PermissionDecision>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Clone)]
|
|
|
|
|
pub struct AppContext {
|
|
|
|
|
pub state: Arc<SessionState>,
|
|
|
|
|
pub store: Arc<JsonFileStore>,
|
|
|
|
|
pub workflow: Arc<std::sync::Mutex<WorkflowState>>,
|
|
|
|
|
pub agents: Arc<AgentPool>,
|
|
|
|
|
/// Broadcast channel for filesystem watcher events. WebSocket handlers
|
|
|
|
|
/// subscribe to this to push lifecycle notifications to connected clients.
|
|
|
|
|
pub watcher_tx: broadcast::Sender<WatcherEvent>,
|
|
|
|
|
/// 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<ReconciliationEvent>,
|
|
|
|
|
/// 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<PermissionForward>,
|
|
|
|
|
/// Receiver for permission requests. The active WebSocket handler locks
|
|
|
|
|
/// this and polls for incoming permission forwards.
|
|
|
|
|
pub perm_rx: Arc<tokio::sync::Mutex<mpsc::UnboundedReceiver<PermissionForward>>>,
|
|
|
|
|
/// Child process of the QA app launched for manual testing.
|
|
|
|
|
/// Only one instance runs at a time.
|
|
|
|
|
pub qa_app_process: Arc<std::sync::Mutex<Option<std::process::Child>>>,
|
2026-03-22 19:08:41 +00:00
|
|
|
/// 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<Arc<BotShutdownNotifier>>,
|
|
|
|
|
/// 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.
|
2026-04-13 14:07:08 +00:00
|
|
|
pub matrix_shutdown_tx: Option<Arc<tokio::sync::watch::Sender<Option<ShutdownReason>>>>,
|
2026-04-09 21:28:48 +01:00
|
|
|
/// 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<TimerStore>,
|
2026-04-11 22:00:05 +00:00
|
|
|
/// 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,
|
2026-03-22 19:07:07 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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());
|
2026-04-03 16:12:52 +01:00
|
|
|
let store_path = project_root.join(".huskies_store.json");
|
2026-03-22 19:07:07 +00:00
|
|
|
let (watcher_tx, _) = broadcast::channel(64);
|
|
|
|
|
let (reconciliation_tx, _) = broadcast::channel(64);
|
|
|
|
|
let (perm_tx, perm_rx) = mpsc::unbounded_channel();
|
2026-04-09 21:28:48 +01:00
|
|
|
let timer_store = Arc::new(TimerStore::load(
|
|
|
|
|
project_root.join(".huskies").join("timers.json"),
|
|
|
|
|
));
|
2026-03-22 19:07:07 +00:00
|
|
|
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)),
|
2026-03-22 19:08:41 +00:00
|
|
|
bot_shutdown: None,
|
|
|
|
|
matrix_shutdown_tx: None,
|
2026-04-09 21:28:48 +01:00
|
|
|
timer_store,
|
2026-04-11 22:00:05 +00:00
|
|
|
test_jobs: Arc::new(std::sync::Mutex::new(HashMap::new())),
|
2026-03-22 19:07:07 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub type OpenApiResult<T> = poem::Result<T>;
|
|
|
|
|
|
|
|
|
|
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);
|
2026-04-13 14:07:08 +00:00
|
|
|
assert_eq!(
|
|
|
|
|
PermissionDecision::AlwaysAllow,
|
|
|
|
|
PermissionDecision::AlwaysAllow
|
|
|
|
|
);
|
2026-03-22 19:07:07 +00:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|