473 lines
18 KiB
Rust
473 lines
18 KiB
Rust
pub mod auth;
|
|
pub mod http;
|
|
mod router;
|
|
pub mod uploads;
|
|
pub mod ws;
|
|
|
|
use axum::{Router, middleware, routing};
|
|
use std::net::SocketAddr;
|
|
use std::sync::Arc;
|
|
use tokio::net::TcpListener;
|
|
|
|
use crate::bus::{MessageBus, OutboundDispatcher};
|
|
use crate::channels::{ChannelManager, CliChatChannel};
|
|
use crate::config::{Config, ensure_workspace_dir, expand_path};
|
|
use crate::delivery::{ConversationWriteLocks, DeliveryCoordinator, TurnDeliveryService};
|
|
use crate::logging;
|
|
use crate::mcp;
|
|
use crate::memory::MemoryManager;
|
|
use crate::scheduler::Scheduler;
|
|
use crate::session::{SessionManager, SessionManagerServices};
|
|
use crate::task_supervisor::TaskSupervisor;
|
|
|
|
pub struct GatewayState {
|
|
pub config: Config,
|
|
pub config_path: std::path::PathBuf,
|
|
pub workspace_dir: std::path::PathBuf,
|
|
pub session_manager: Arc<SessionManager>,
|
|
pub channel_manager: ChannelManager,
|
|
pub storage: Arc<crate::storage::Storage>,
|
|
pub task_supervisor: TaskSupervisor,
|
|
pub delivery_coordinator: DeliveryCoordinator,
|
|
pub connection_shutdown: tokio_util::sync::CancellationToken,
|
|
pub auth: auth::AuthManager,
|
|
pub uploads: uploads::UploadRegistry,
|
|
}
|
|
|
|
impl GatewayState {
|
|
pub async fn new() -> Result<Self, Box<dyn std::error::Error>> {
|
|
let config_path = crate::config::resolve_default_config_path();
|
|
let config = Config::load_from(&config_path)?;
|
|
Self::from_config(config, config_path).await
|
|
}
|
|
|
|
async fn from_config(
|
|
config: Config,
|
|
config_path: std::path::PathBuf,
|
|
) -> Result<Self, Box<dyn std::error::Error>> {
|
|
let task_supervisor = TaskSupervisor::new();
|
|
let delivery_coordinator = DeliveryCoordinator::new(ConversationWriteLocks::default());
|
|
let connection_shutdown = tokio_util::sync::CancellationToken::new();
|
|
let auth = auth::AuthManager::load(
|
|
config.gateway.require_pairing,
|
|
crate::config::get_user_config_dir().join("web_auth.json"),
|
|
)
|
|
.await?;
|
|
let uploads = uploads::UploadRegistry::new(config.gateway.file_transfer.clone());
|
|
|
|
// Initialize workspace directory: expand path and ensure it exists
|
|
let workspace_path = expand_path(&config.workspace_dir);
|
|
let workspace_path = ensure_workspace_dir(&workspace_path)?;
|
|
|
|
// Switch current working directory to workspace
|
|
std::env::set_current_dir(&workspace_path).map_err(|e| {
|
|
format!(
|
|
"Failed to switch to workspace directory {}: {}",
|
|
workspace_path.display(),
|
|
e
|
|
)
|
|
})?;
|
|
|
|
tracing::info!("Using workspace directory: {}", workspace_path.display());
|
|
|
|
// Release default AGENTS.md and USER.md to ~/.picobot/ if not exist
|
|
ensure_default_config_files();
|
|
|
|
// Get provider config for SessionManager
|
|
let mut provider_config = config.get_provider_config("default")?;
|
|
// Override workspace_dir with the ensured path
|
|
provider_config.workspace_dir = workspace_path.clone();
|
|
|
|
// Initialize Storage
|
|
let db_path = if let Some(ref path) = config.gateway.session_db_path {
|
|
std::path::PathBuf::from(path)
|
|
} else {
|
|
workspace_path.join("picobot.db")
|
|
};
|
|
let storage = Arc::new(
|
|
crate::storage::Storage::new(&db_path)
|
|
.await
|
|
.map_err(|e| format!("failed to initialize session storage: {}", e))?,
|
|
);
|
|
tracing::info!("Session storage: {}", db_path.display());
|
|
|
|
// Resolve consolidation provider/model with fallback to main agent config
|
|
let consolidation_provider = config
|
|
.memory
|
|
.resolve_consolidation_provider(&provider_config.name);
|
|
let consolidation_model = config
|
|
.memory
|
|
.resolve_consolidation_model(&provider_config.model_id);
|
|
let memory_manager = Arc::new(MemoryManager::new(
|
|
storage.clone(),
|
|
consolidation_provider,
|
|
consolidation_model,
|
|
));
|
|
tracing::info!(
|
|
consolidation_provider = %memory_manager.consolidation_provider,
|
|
consolidation_model = %memory_manager.consolidation_model,
|
|
"Memory system initialized"
|
|
);
|
|
|
|
// Create MessageBus first (shared by SessionManager and ChannelManager)
|
|
let bus = MessageBus::new(100);
|
|
|
|
// Channels are resolved by TurnDeliveryService, while Session workers
|
|
// depend only on that protocol-neutral delivery facade.
|
|
let cli_chat_channel = Arc::new(CliChatChannel::with_upload_registry(uploads.clone()));
|
|
let channel_manager = ChannelManager::with_bus(cli_chat_channel, bus.clone());
|
|
channel_manager
|
|
.init(&config, workspace_path.clone())
|
|
.await
|
|
.map_err(|e| format!("Failed to init channels: {}", e))?;
|
|
let turn_delivery = TurnDeliveryService::new(
|
|
delivery_coordinator.clone(),
|
|
channel_manager.clone(),
|
|
task_supervisor.clone(),
|
|
);
|
|
|
|
let browser_config = if config.browser.enabled {
|
|
Some(config.browser.clone())
|
|
} else {
|
|
None
|
|
};
|
|
|
|
// Create SessionManager with bus injection
|
|
let session_manager = SessionManager::new(
|
|
provider_config.clone(),
|
|
storage.clone(),
|
|
SessionManagerServices::new(
|
|
bus.clone(),
|
|
memory_manager,
|
|
task_supervisor.clone(),
|
|
turn_delivery,
|
|
),
|
|
browser_config,
|
|
config.gateway.max_concurrent_background_tasks,
|
|
)?;
|
|
let session_manager = Arc::new(session_manager);
|
|
|
|
// Register send_message tool with available channel names
|
|
let available_channels = channel_manager.list_channel_names().await;
|
|
let valid_channels = available_channels.clone();
|
|
session_manager.register_outbound_tool(available_channels);
|
|
|
|
// Register chat_manager tool
|
|
session_manager
|
|
.tools()
|
|
.register(crate::tools::ChatManagerTool::new(
|
|
storage.clone(),
|
|
valid_channels.clone(),
|
|
));
|
|
|
|
// Initialize MCP servers — connect and register discovered tools
|
|
if !config.mcp.servers.is_empty() {
|
|
let mcp_tools = mcp::connect_all(&config.mcp).await;
|
|
for tool_info in mcp_tools {
|
|
let wrapper = mcp::McpToolWrapper::new(
|
|
&tool_info.server_name,
|
|
tool_info.tool_name,
|
|
tool_info.description,
|
|
tool_info.schema,
|
|
tool_info.connection,
|
|
);
|
|
session_manager.tools().register(wrapper);
|
|
}
|
|
}
|
|
|
|
// Initialize scheduler if enabled in config
|
|
let scheduler_config = config.gateway.scheduler.clone().unwrap_or_default();
|
|
if scheduler_config.enabled {
|
|
session_manager
|
|
.tools()
|
|
.register(crate::tools::RoutineMaintenanceTool::new(
|
|
storage.clone(),
|
|
config.memory.timeline_retention_days,
|
|
));
|
|
storage
|
|
.ensure_default_maintenance_job()
|
|
.await
|
|
.map_err(|e| format!("failed to seed default maintenance job: {e}"))?;
|
|
// Register cron tools
|
|
session_manager
|
|
.tools()
|
|
.register(crate::tools::cron::CronAddTool::new(
|
|
storage.clone(),
|
|
valid_channels,
|
|
));
|
|
session_manager
|
|
.tools()
|
|
.register(crate::tools::cron::CronListTool::new(storage.clone()));
|
|
session_manager
|
|
.tools()
|
|
.register(crate::tools::cron::CronRemoveTool::new(storage.clone()));
|
|
session_manager
|
|
.tools()
|
|
.register(crate::tools::cron::CronEnableTool::new(storage.clone()));
|
|
session_manager
|
|
.tools()
|
|
.register(crate::tools::cron::CronDisableTool::new(storage.clone()));
|
|
session_manager
|
|
.tools()
|
|
.register(crate::tools::cron::CronUpdateTool::new(storage.clone()));
|
|
tracing::info!("Cron tools registered");
|
|
}
|
|
|
|
Ok(Self {
|
|
config,
|
|
config_path,
|
|
workspace_dir: workspace_path,
|
|
session_manager: session_manager.clone(),
|
|
channel_manager,
|
|
storage,
|
|
task_supervisor,
|
|
delivery_coordinator,
|
|
connection_shutdown,
|
|
auth,
|
|
uploads,
|
|
})
|
|
}
|
|
|
|
/// Get a reference to the MessageBus
|
|
pub fn bus(&self) -> Arc<crate::bus::MessageBus> {
|
|
self.channel_manager.bus()
|
|
}
|
|
|
|
/// Get CLI chat channel for WebSocket handling
|
|
pub fn cli_chat_channel(&self) -> Arc<CliChatChannel> {
|
|
self.channel_manager.cli_chat_channel()
|
|
}
|
|
|
|
/// Start the message processing loops
|
|
pub async fn start_message_processing(&self) {
|
|
let bus = self.bus();
|
|
let bus_for_outbound = bus.clone();
|
|
let session_manager = self.session_manager.clone();
|
|
|
|
if self.uploads.enabled() {
|
|
let uploads = self.uploads.clone();
|
|
self.task_supervisor
|
|
.spawn("pending-upload-cleanup", async move {
|
|
let mut interval = tokio::time::interval(std::time::Duration::from_secs(300));
|
|
interval.tick().await;
|
|
loop {
|
|
interval.tick().await;
|
|
let removed = uploads.cleanup_expired().await;
|
|
if removed > 0 {
|
|
tracing::debug!(removed, "Expired pending uploads removed");
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// Relay structured plan changes to WebSocket clients. This remains
|
|
// separate from chat messages, so task UI updates never pollute history.
|
|
let mut plan_events = self.session_manager.work_manager().subscribe();
|
|
let cli_chat = self.cli_chat_channel();
|
|
self.task_supervisor.spawn("task-plan-events", async move {
|
|
loop {
|
|
match plan_events.recv().await {
|
|
Ok(event) => cli_chat.publish_plan_changed(event).await,
|
|
Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => {
|
|
tracing::warn!(skipped, "Task plan event relay lagged");
|
|
}
|
|
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
|
}
|
|
}
|
|
});
|
|
|
|
router::spawn_message_routers(bus.clone(), session_manager, self.task_supervisor.clone());
|
|
|
|
// Spawn outbound dispatcher
|
|
let dispatcher = OutboundDispatcher::new(
|
|
bus_for_outbound,
|
|
self.channel_manager.clone(),
|
|
self.task_supervisor.clone(),
|
|
self.delivery_coordinator.write_locks(),
|
|
);
|
|
|
|
self.task_supervisor
|
|
.spawn("outbound-dispatcher", async move {
|
|
tracing::info!("Outbound dispatcher started");
|
|
dispatcher.run().await;
|
|
});
|
|
|
|
// Spawn scheduler background task if enabled
|
|
let scheduler_config = self.config.gateway.scheduler.clone().unwrap_or_default();
|
|
if scheduler_config.enabled {
|
|
let sched = Arc::new(Scheduler::new(
|
|
self.storage.clone(),
|
|
self.session_manager.clone(),
|
|
scheduler_config,
|
|
));
|
|
self.task_supervisor.spawn("scheduler", async move {
|
|
sched.run().await;
|
|
});
|
|
tracing::info!("Scheduler background task spawned");
|
|
}
|
|
}
|
|
}
|
|
|
|
pub async fn run(
|
|
host: Option<String>,
|
|
port: Option<u16>,
|
|
) -> Result<(), Box<dyn std::error::Error>> {
|
|
let config_path = crate::config::resolve_default_config_path();
|
|
let config = Config::load_from(&config_path)?;
|
|
|
|
// Initialize logging
|
|
logging::init_logging();
|
|
tracing::info!(config_path = %config_path.display(), "Starting PicoBot Gateway");
|
|
|
|
let state = Arc::new(GatewayState::from_config(config, config_path).await?);
|
|
|
|
// Start all channels (init already done while constructing GatewayState)
|
|
state.channel_manager.start_all().await?;
|
|
|
|
// Start message processing (inbound processor + control processor + outbound dispatcher)
|
|
state.start_message_processing().await;
|
|
|
|
// CLI args override config file values
|
|
let bind_host = host.unwrap_or_else(|| state.config.gateway.host.clone());
|
|
let bind_port = port.unwrap_or(state.config.gateway.port);
|
|
|
|
let protected = Router::new()
|
|
.route("/api/health", routing::get(http::health))
|
|
.route(
|
|
"/api/config",
|
|
routing::get(http::get_config).put(http::put_config),
|
|
)
|
|
.route(
|
|
"/api/profiles/{name}",
|
|
routing::get(http::get_profile).put(http::put_profile),
|
|
)
|
|
.route("/api/logs", routing::get(http::get_logs))
|
|
.route("/api/tasks", routing::get(http::get_tasks))
|
|
.route("/api/jobs", routing::get(http::get_jobs))
|
|
.route("/api/jobs/{id}/runs", routing::get(http::get_job_runs))
|
|
.route("/api/memories", routing::get(http::get_memories))
|
|
.route(
|
|
"/api/chat/{client_id}/uploads",
|
|
routing::post(http::upload_file).layer(axum::extract::DefaultBodyLimit::disable()),
|
|
)
|
|
.route(
|
|
"/api/chat/{client_id}/sessions/{session_id}/messages/{message_id}/attachments/{index}",
|
|
routing::get(http::download_attachment),
|
|
)
|
|
.route("/ws", routing::get(ws::ws_handler))
|
|
.route_layer(middleware::from_fn_with_state(
|
|
state.auth.clone(),
|
|
auth::require_auth,
|
|
));
|
|
|
|
let app = Router::new()
|
|
.route("/", routing::get(http::webui_index))
|
|
.route("/app.js", routing::get(http::webui_script))
|
|
.route("/styles.css", routing::get(http::webui_styles))
|
|
.route("/health", routing::get(http::health))
|
|
.route("/api/auth/status", routing::get(auth::status))
|
|
.route("/api/auth/pair", routing::post(auth::pair))
|
|
.route("/api/auth/code", routing::post(auth::issue_code))
|
|
.merge(protected)
|
|
.with_state(state.clone());
|
|
|
|
let addr = format!("{}:{}", bind_host, bind_port);
|
|
let listener = TcpListener::bind(&addr).await?;
|
|
tracing::info!(address = %addr, "Gateway listening");
|
|
|
|
let connection_shutdown = state.connection_shutdown.clone();
|
|
let serve_result = axum::serve(
|
|
listener,
|
|
app.into_make_service_with_connect_info::<SocketAddr>(),
|
|
)
|
|
.with_graceful_shutdown(async move {
|
|
wait_for_shutdown_signal().await;
|
|
tracing::info!("Shutdown signal received");
|
|
connection_shutdown.cancel();
|
|
})
|
|
.await;
|
|
|
|
// Stop external intake before waiting for internal work to finish.
|
|
if let Err(error) = state.channel_manager.stop_all().await {
|
|
tracing::error!(error = %error, "Failed to stop channels cleanly");
|
|
}
|
|
state.task_supervisor.cancel();
|
|
state
|
|
.task_supervisor
|
|
.shutdown(std::time::Duration::from_secs(10))
|
|
.await;
|
|
serve_result?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn wait_for_shutdown_signal() {
|
|
#[cfg(unix)]
|
|
{
|
|
use tokio::signal::unix::{SignalKind, signal};
|
|
|
|
match signal(SignalKind::terminate()) {
|
|
Ok(mut terminate) => {
|
|
tokio::select! {
|
|
result = tokio::signal::ctrl_c() => {
|
|
if let Err(error) = result {
|
|
tracing::error!(error = %error, "Failed to listen for Ctrl-C");
|
|
}
|
|
}
|
|
_ = terminate.recv() => {}
|
|
}
|
|
}
|
|
Err(error) => {
|
|
tracing::error!(error = %error, "Failed to listen for SIGTERM");
|
|
if let Err(error) = tokio::signal::ctrl_c().await {
|
|
tracing::error!(error = %error, "Failed to listen for Ctrl-C");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(not(unix))]
|
|
if let Err(error) = tokio::signal::ctrl_c().await {
|
|
tracing::error!(error = %error, "Failed to listen for Ctrl-C");
|
|
}
|
|
}
|
|
|
|
/// Release default AGENTS.md and USER.md templates to ~/.picobot/ if not already present.
|
|
fn ensure_default_config_files() {
|
|
let picobot_dir = dirs::home_dir().unwrap_or_default().join(".picobot");
|
|
if let Err(e) = std::fs::create_dir_all(&picobot_dir) {
|
|
tracing::warn!(dir = %picobot_dir.display(), error = %e, "Failed to create ~/.picobot directory");
|
|
return;
|
|
}
|
|
|
|
let agents_path = picobot_dir.join("AGENTS.md");
|
|
if !agents_path.exists() {
|
|
let content = include_str!("../../resources/templates/AGENTS.md");
|
|
if let Err(e) = std::fs::write(&agents_path, content) {
|
|
tracing::warn!(path = %agents_path.display(), error = %e, "Failed to write AGENTS.md template");
|
|
} else {
|
|
tracing::info!(path = %agents_path.display(), "Released default AGENTS.md template");
|
|
}
|
|
}
|
|
|
|
let user_path = picobot_dir.join("USER.md");
|
|
if !user_path.exists() {
|
|
let content = include_str!("../../resources/templates/USER.md");
|
|
if let Err(e) = std::fs::write(&user_path, content) {
|
|
tracing::warn!(path = %user_path.display(), error = %e, "Failed to write USER.md template");
|
|
} else {
|
|
tracing::info!(path = %user_path.display(), "Released default USER.md template");
|
|
}
|
|
}
|
|
|
|
let config_example_path = picobot_dir.join("config.example.json");
|
|
if !config_example_path.exists() {
|
|
let content = include_str!("../../resources/templates/config.example.json");
|
|
if let Err(e) = std::fs::write(&config_example_path, content) {
|
|
tracing::warn!(path = %config_example_path.display(), error = %e, "Failed to write config.example.json template");
|
|
} else {
|
|
tracing::info!(path = %config_example_path.display(), "Released config.example.json template");
|
|
}
|
|
}
|
|
}
|