//! HTTP server — module declarations for all REST, MCP, WebSocket, and SSE endpoints. /// Agent management HTTP endpoints. pub mod agents; /// Server-sent event stream for real-time agent output. pub mod agents_sse; /// Anthropic API key management endpoints. pub mod anthropic; /// Static asset serving (embedded frontend files). pub mod assets; /// Bot slash-command HTTP endpoint. pub mod bot_command; /// Bot configuration read/write endpoints. pub mod bot_config; /// Chat session HTTP endpoints. pub mod chat; /// Shared application context threaded through handlers. pub mod context; /// Server-sent event stream for pipeline/watcher events. pub mod events; /// Node identity endpoint (public key, node ID). pub mod identity; /// Filesystem I/O HTTP endpoints (read, write, list, search). pub mod io; /// Model Context Protocol (MCP) HTTP endpoint and tool modules. pub mod mcp; /// LLM model selection and listing endpoints. pub mod model; /// OAuth 2.0 PKCE flow endpoints for Anthropic authentication. pub mod oauth; /// Project settings HTTP endpoints. pub mod settings; #[cfg(test)] pub(crate) mod test_helpers; /// Workflow helpers for story/bug file operations. pub mod workflow; /// Gateway-mode HTTP endpoints for multi-project proxy. pub mod gateway; /// Project open/close/list HTTP endpoints. pub mod project; /// Setup wizard HTTP endpoints. pub mod wizard; /// WebSocket handler for real-time frontend communication. pub mod ws; use agents::AgentsApi; use anthropic::AnthropicApi; use bot_command::BotCommandApi; use bot_config::BotConfigApi; use chat::ChatApi; use context::AppContext; use io::IoApi; use model::ModelApi; use poem::EndpointExt; use poem::{Route, get, post}; use poem_openapi::OpenApiService; use project::ProjectApi; use settings::SettingsApi; use std::path::{Path, PathBuf}; use std::sync::Arc; use crate::chat::transport::slack::SlackWebhookContext; use crate::chat::transport::whatsapp::WhatsAppWebhookContext; const DEFAULT_PORT: u16 = 3001; /// Parse an optional port string, falling back to the default (3001). pub fn parse_port(value: Option) -> u16 { value .and_then(|v| v.parse::().ok()) .unwrap_or(DEFAULT_PORT) } /// Read the server port from the `HUSKIES_PORT` env var, or use the default. pub fn resolve_port() -> u16 { parse_port(std::env::var("HUSKIES_PORT").ok()) } /// Write a `.huskies_port` file so other processes can discover the port. pub fn write_port_file(dir: &Path, port: u16) -> Option { let path = dir.join(".huskies_port"); std::fs::write(&path, port.to_string()).ok()?; Some(path) } /// Delete the `.huskies_port` file on shutdown. pub fn remove_port_file(path: &Path) { let _ = std::fs::remove_file(path); } /// Assemble the full Poem route tree (API, WebSocket, MCP, OAuth, assets). pub fn build_routes( ctx: AppContext, whatsapp_ctx: Option>, slack_ctx: Option>, port: u16, event_buffer: Option, ) -> impl poem::Endpoint { let ctx_arc = std::sync::Arc::new(ctx); let (api_service, docs_service) = build_openapi_service(ctx_arc.clone()); let oauth_state = Arc::new(oauth::OAuthState::new(port)); let mut route = Route::new() .nest("/api", api_service) .nest("/docs", docs_service.swagger_ui()) .at("/ws", get(ws::ws_handler)) .at("/crdt-sync", get(crate::crdt_sync::crdt_sync_handler)) .at("/rpc", post(rpc_http_handler)) .at( "/agents/:story_id/:agent_name/stream", get(agents_sse::agent_stream), ) .at("/identity", get(identity::identity_handler)) .at( "/oauth/authorize", get(oauth::oauth_authorize).data(oauth_state.clone()), ) .at( "/callback", get(oauth::oauth_callback).data(oauth_state.clone()), ) .at("/oauth/status", get(oauth::oauth_status)) .at("/debug/crdt", get(debug_crdt_handler)) .at("/assets/*path", get(assets::embedded_asset)) .at("/", get(assets::embedded_index)) .at("/*path", get(assets::embedded_file)); if let Some(buf) = event_buffer { route = route.at("/api/events", get(events::events_handler).data(buf)); } if let Some(wa_ctx) = whatsapp_ctx { route = route.at( "/webhook/whatsapp", get(crate::chat::transport::whatsapp::webhook_verify) .post(crate::chat::transport::whatsapp::webhook_receive) .data(wa_ctx), ); } if let Some(sl_ctx) = slack_ctx { route = route .at( "/webhook/slack", post(crate::chat::transport::slack::webhook_receive).data(sl_ctx.clone()), ) .at( "/webhook/slack/command", post(crate::chat::transport::slack::slash_command_receive).data(sl_ctx), ); } route.data(ctx_arc) } /// HTTP bridge for the read-RPC protocol. /// /// Accepts a JSON [`RpcFrame::RpcRequest`] body and returns the corresponding /// [`RpcFrame::RpcResponse`]. This allows HTTP clients (e.g. the frontend) to /// call read-RPC methods without maintaining a `/crdt-sync` WebSocket connection. #[poem::handler] pub async fn rpc_http_handler(body: poem::web::Json) -> poem::Response { let text = serde_json::to_string(&body.0).unwrap_or_default(); match crate::crdt_sync::try_handle_rpc_text(&text) { Some(response) => { let json = serde_json::to_string(&response).unwrap_or_default(); poem::Response::builder() .status(poem::http::StatusCode::OK) .header(poem::http::header::CONTENT_TYPE, "application/json") .body(json) } None => poem::Response::builder() .status(poem::http::StatusCode::BAD_REQUEST) .body("Invalid RPC request"), } } /// Debug HTTP endpoint: `GET /debug/crdt[?story_id=]` /// /// Returns the raw in-memory CRDT state as JSON. Accepts an optional /// `story_id` query parameter to restrict the dump to a single item. /// /// **This is a debug endpoint.** Use `GET /api/pipeline` or the /// `get_pipeline_status` MCP tool for normal pipeline introspection. #[poem::handler] pub fn debug_crdt_handler(req: &poem::Request) -> poem::Response { let story_id_filter = req.uri().query().and_then(|q| { q.split('&').find_map(|pair| { let (key, val) = pair.split_once('=')?; if key == "story_id" { Some(val.to_string()) } else { None } }) }); let dump = crate::crdt_state::dump_crdt_state(story_id_filter.as_deref()); let items: Vec = dump .items .into_iter() .map(|item| { serde_json::json!({ "story_id": item.story_id, "stage": item.stage, "name": item.name, "agent": item.agent, "retry_count": item.retry_count, "blocked": item.blocked, "depends_on": item.depends_on, "claimed_by": item.claimed_by, "claimed_at": item.claimed_at, "content_index": item.content_index, "is_deleted": item.is_deleted, }) }) .collect(); let body = serde_json::json!({ "metadata": { "in_memory_state_loaded": dump.in_memory_state_loaded, "total_items": dump.total_items, "total_ops_in_list": dump.total_ops_in_list, "max_seq_in_list": dump.max_seq_in_list, "persisted_ops_count": dump.persisted_ops_count, "pending_persist_ops_count": null, }, "items": items, }); poem::Response::builder() .status(poem::http::StatusCode::OK) .header(poem::http::header::CONTENT_TYPE, "application/json") .body(serde_json::to_string_pretty(&body).unwrap_or_default()) } type ApiTuple = ( ProjectApi, ModelApi, AnthropicApi, IoApi, ChatApi, AgentsApi, SettingsApi, BotCommandApi, wizard::WizardApi, BotConfigApi, ); type ApiService = OpenApiService; /// All HTTP methods are documented by OpenAPI at /docs pub fn build_openapi_service(ctx: Arc) -> (ApiService, ApiService) { let api = ( ProjectApi { ctx: ctx.clone() }, ModelApi { ctx: ctx.clone() }, AnthropicApi::new(ctx.clone()), IoApi { ctx: ctx.clone() }, ChatApi { ctx: ctx.clone() }, AgentsApi { ctx: ctx.clone() }, SettingsApi { ctx: ctx.clone() }, BotCommandApi { ctx: ctx.clone() }, wizard::WizardApi { ctx: ctx.clone() }, BotConfigApi { ctx: ctx.clone() }, ); let api_service = OpenApiService::new(api, "Huskies API", "1.0").server("http://127.0.0.1:3001/api"); let docs_api = ( ProjectApi { ctx: ctx.clone() }, ModelApi { ctx: ctx.clone() }, AnthropicApi::new(ctx.clone()), IoApi { ctx: ctx.clone() }, ChatApi { ctx: ctx.clone() }, AgentsApi { ctx: ctx.clone() }, SettingsApi { ctx: ctx.clone() }, BotCommandApi { ctx: ctx.clone() }, wizard::WizardApi { ctx: ctx.clone() }, BotConfigApi { ctx }, ); let docs_service = OpenApiService::new(docs_api, "Huskies API", "1.0").server("http://127.0.0.1:3001/api"); (api_service, docs_service) } #[cfg(test)] mod tests { use super::*; #[test] fn parse_port_defaults_to_3001() { assert_eq!(parse_port(None), 3001); } #[test] fn parse_port_reads_valid_value() { assert_eq!(parse_port(Some("4200".to_string())), 4200); } #[test] fn parse_port_ignores_invalid_value() { assert_eq!(parse_port(Some("not_a_number".to_string())), 3001); } #[test] fn write_and_remove_port_file() { let tmp = tempfile::tempdir().unwrap(); let path = write_port_file(tmp.path(), 4567).expect("should write port file"); assert_eq!(std::fs::read_to_string(&path).unwrap(), "4567"); remove_port_file(&path); assert!(!path.exists()); } #[test] fn write_port_file_returns_none_on_nonexistent_dir() { let bad = std::path::Path::new("/this_dir_does_not_exist_storykit_test_xyz"); assert!(write_port_file(bad, 1234).is_none()); } #[test] fn remove_port_file_does_not_panic_for_missing_file() { let path = std::path::Path::new("/tmp/nonexistent_storykit_port_test_xyz_999"); remove_port_file(path); } #[test] fn resolve_port_returns_a_valid_port() { // Exercises the resolve_port code path (reads HUSKIES_PORT env var or defaults). let port = resolve_port(); assert!(port > 0); } #[test] fn build_openapi_service_constructs_without_panic() { let tmp = tempfile::tempdir().unwrap(); let ctx = Arc::new(context::AppContext::new_test(tmp.path().to_path_buf())); let (_api_service, _docs_service) = build_openapi_service(ctx); } #[test] fn build_routes_constructs_without_panic() { let tmp = tempfile::tempdir().unwrap(); let ctx = context::AppContext::new_test(tmp.path().to_path_buf()); let _endpoint = build_routes(ctx, None, None, 3001, None); } #[test] fn build_routes_accepts_custom_port() { // Verify build_routes compiles and runs with a non-default port, // ensuring the port parameter flows through to OAuthState. let tmp = tempfile::tempdir().unwrap(); let ctx = context::AppContext::new_test(tmp.path().to_path_buf()); let _endpoint = build_routes(ctx, None, None, 9999, None); } #[test] fn build_routes_with_event_buffer_constructs_without_panic() { let tmp = tempfile::tempdir().unwrap(); let ctx = context::AppContext::new_test(tmp.path().to_path_buf()); let buf = events::EventBuffer::new(); let _endpoint = build_routes(ctx, None, None, 3001, Some(buf)); } }