225 lines
9.9 KiB
Rust
225 lines
9.9 KiB
Rust
//! Application context — shared state (`AppContext`) threaded through all HTTP handlers.
|
|
use crate::agents::ReconciliationEvent;
|
|
use crate::io::watcher::WatcherEvent;
|
|
use crate::rebuild::{BotShutdownNotifier, ShutdownReason};
|
|
use crate::service::event_triggers::store::EventTriggerStore;
|
|
use crate::service::timer::{ScheduledTimerStore, TimerStore};
|
|
use crate::services::Services;
|
|
use crate::state::SessionState;
|
|
use crate::store::JsonFileStore;
|
|
use crate::workflow::WorkflowState;
|
|
use std::sync::Arc;
|
|
use tokio::sync::{broadcast, mpsc, oneshot};
|
|
|
|
/// 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 remember `(tool, target-pattern)` for the rest of the
|
|
/// requesting agent's session (story 1218) — subsequent matching
|
|
/// requests auto-approve without forwarding to chat. Scoped in-memory to
|
|
/// the session that made the request (see
|
|
/// `service::permission_router::RememberedPermissions`); never persisted
|
|
/// to disk and never shared with another agent or story.
|
|
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>,
|
|
}
|
|
|
|
/// A single selectable choice within a [`QuestionSpec`].
|
|
#[derive(Debug, Clone)]
|
|
pub struct QuestionOption {
|
|
pub label: String,
|
|
pub description: String,
|
|
}
|
|
|
|
/// A multiple-choice question forwarded from the MCP `ask_question` tool to a
|
|
/// chat transport for rendering as numbered text (story 1228).
|
|
#[derive(Debug, Clone)]
|
|
pub struct QuestionSpec {
|
|
pub header: String,
|
|
pub question: String,
|
|
pub options: Vec<QuestionOption>,
|
|
pub multi_select: bool,
|
|
}
|
|
|
|
/// The user's reply to a forwarded [`QuestionSpec`].
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum QuestionAnswer {
|
|
/// 0-based indices into `QuestionSpec::options` the user selected.
|
|
Selected(Vec<usize>),
|
|
/// Freeform text the user typed instead of selecting a listed option
|
|
/// (the always-available "Other" path, AC5).
|
|
FreeText(String),
|
|
}
|
|
|
|
/// A question request forwarded from the MCP `ask_question` tool to the
|
|
/// active chat transport. The MCP handler blocks on `response_tx` until a
|
|
/// chat reply resolves it (or it times out).
|
|
///
|
|
/// Kept structurally separate from `PermissionForward` / permission-router
|
|
/// plumbing (see `service::question_router`) so a reply answering one is
|
|
/// never misinterpreted as answering the other (story 1228, AC4).
|
|
pub struct QuestionForward {
|
|
pub request_id: String,
|
|
pub question: QuestionSpec,
|
|
pub response_tx: oneshot::Sender<Result<QuestionAnswer, String>>,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
/// Shared application state threaded through all HTTP handlers via Poem's `Data` extractor.
|
|
pub struct AppContext {
|
|
pub state: Arc<SessionState>,
|
|
pub store: Arc<JsonFileStore>,
|
|
pub workflow: Arc<std::sync::Mutex<WorkflowState>>,
|
|
/// Shared services bundle (agent pool, bot identity, permissions, etc.).
|
|
pub services: Arc<Services>,
|
|
/// 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>,
|
|
/// Sender for questions originating from the MCP `ask_question` tool.
|
|
/// The MCP handler sends a [`QuestionForward`] and awaits the oneshot
|
|
/// response (story 1228).
|
|
pub question_tx: mpsc::UnboundedSender<QuestionForward>,
|
|
/// 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>>>,
|
|
/// Best-effort shutdown notifier for active bot channels (Slack / WhatsApp).
|
|
///
|
|
/// When set, restart-inducing paths use this to announce the shutdown to
|
|
/// configured channels before the process exits.
|
|
/// `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.
|
|
pub matrix_shutdown_tx: Option<Arc<tokio::sync::watch::Sender<Option<ShutdownReason>>>>,
|
|
/// 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>,
|
|
/// Generic scheduled-timer store for `schedule_timer` / `list_timers` /
|
|
/// `cancel_timer` MCP tools. Persists to `.huskies/scheduled_timers.json`.
|
|
pub scheduled_timer_store: Arc<ScheduledTimerStore>,
|
|
/// Persistent store for event-based pipeline triggers.
|
|
///
|
|
/// Shared with the background subscriber so that triggers registered via
|
|
/// MCP are immediately visible to the subscriber without a disk round-trip.
|
|
pub event_trigger_store: Arc<EventTriggerStore>,
|
|
}
|
|
|
|
#[cfg(test)]
|
|
impl AppContext {
|
|
/// Build a minimal `AppContext` for unit tests with an in-memory store.
|
|
pub fn new_test(project_root: std::path::PathBuf) -> Self {
|
|
use crate::agents::AgentPool;
|
|
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 permission_registry = crate::service::permission_router::ResponderRegistry::new();
|
|
let (question_tx, question_rx) = mpsc::unbounded_channel();
|
|
let question_registry = crate::service::question_router::QuestionResponderRegistry::new();
|
|
// Plain `#[test]` fns (no tokio runtime) construct `AppContext` too;
|
|
// skip spawning when there's no reactor to spawn onto since those
|
|
// tests never exercise the permission plumbing.
|
|
if tokio::runtime::Handle::try_current().is_ok() {
|
|
crate::service::permission_router::spawn_permission_router(
|
|
perm_rx,
|
|
Arc::clone(&permission_registry),
|
|
);
|
|
crate::service::question_router::spawn_question_router(
|
|
question_rx,
|
|
Arc::clone(&question_registry),
|
|
);
|
|
}
|
|
let timer_store = Arc::new(TimerStore::load(
|
|
project_root.join(".huskies").join("timers.json"),
|
|
));
|
|
let event_trigger_store = Arc::new(EventTriggerStore::load(
|
|
project_root.join(".huskies").join("event_triggers.json"),
|
|
));
|
|
let scheduled_timer_store = Arc::new(ScheduledTimerStore::load(
|
|
project_root.join(".huskies").join("scheduled_timers.json"),
|
|
));
|
|
let agents = Arc::new(AgentPool::new(3001, watcher_tx.clone()));
|
|
let services = Arc::new(Services {
|
|
project_root: project_root.clone(),
|
|
agents: Arc::clone(&agents),
|
|
bot_name: "Assistant".to_string(),
|
|
bot_user_id: String::new(),
|
|
ambient_rooms: Arc::new(std::sync::Mutex::new(std::collections::HashSet::new())),
|
|
permission_registry,
|
|
pending_perm_replies: crate::service::permission_router::PendingPermReplies::new(),
|
|
permission_timeout_secs: 120,
|
|
remembered_permissions: crate::service::permission_router::RememberedPermissions::new(),
|
|
question_registry,
|
|
pending_question_replies: crate::service::question_router::PendingQuestionReplies::new(
|
|
),
|
|
question_timeout_secs: 120,
|
|
status: agents.status_broadcaster(),
|
|
chat_dispatcher: Arc::new(crate::chat::dispatcher::ChatDispatcher::new(1_500)),
|
|
});
|
|
Self {
|
|
state: Arc::new(state),
|
|
store: Arc::new(JsonFileStore::new(store_path).unwrap()),
|
|
workflow: Arc::new(std::sync::Mutex::new(WorkflowState::default())),
|
|
services,
|
|
watcher_tx,
|
|
reconciliation_tx,
|
|
perm_tx,
|
|
question_tx,
|
|
qa_app_process: Arc::new(std::sync::Mutex::new(None)),
|
|
bot_shutdown: None,
|
|
matrix_shutdown_tx: None,
|
|
timer_store,
|
|
scheduled_timer_store,
|
|
event_trigger_store,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[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);
|
|
}
|
|
}
|