2026-04-12 13:11:23 +00:00
|
|
|
//! Huskies server — entry point, CLI argument parsing, and server startup.
|
|
|
|
|
|
2026-03-22 19:07:07 +00:00
|
|
|
// matrix-sdk-crypto's deeply nested types require a higher recursion limit
|
|
|
|
|
// when the `e2e-encryption` feature is enabled.
|
|
|
|
|
#![recursion_limit = "256"]
|
|
|
|
|
|
|
|
|
|
mod agent_log;
|
2026-04-10 18:46:44 +00:00
|
|
|
mod agent_mode;
|
2026-03-22 19:07:07 +00:00
|
|
|
mod agents;
|
2026-03-27 12:31:08 +00:00
|
|
|
mod chat;
|
2026-03-22 19:07:07 +00:00
|
|
|
mod config;
|
2026-04-29 10:41:32 +00:00
|
|
|
/// CRDT snapshot — serialisation and restore of the full pipeline CRDT state.
|
2026-04-26 01:14:52 +00:00
|
|
|
pub mod crdt_snapshot;
|
2026-04-29 10:41:32 +00:00
|
|
|
/// CRDT state — in-memory pipeline state machine backed by a distributed CRDT.
|
2026-04-07 16:12:19 +00:00
|
|
|
pub mod crdt_state;
|
2026-04-29 10:41:32 +00:00
|
|
|
/// CRDT sync — WebSocket-based peer synchronisation for distributed nodes.
|
2026-04-09 19:46:29 +01:00
|
|
|
pub mod crdt_sync;
|
2026-04-29 10:41:32 +00:00
|
|
|
/// CRDT wire format — on-wire message types for the crdt-sync protocol.
|
2026-04-10 15:31:22 +00:00
|
|
|
pub mod crdt_wire;
|
2026-04-07 13:09:48 +00:00
|
|
|
mod db;
|
2026-04-29 10:41:32 +00:00
|
|
|
/// Gateway mode — multi-project reverse proxy that fronts multiple project containers.
|
2026-04-13 13:02:41 +00:00
|
|
|
pub mod gateway;
|
2026-04-28 01:06:02 +00:00
|
|
|
mod gateway_relay;
|
2026-03-22 19:07:07 +00:00
|
|
|
mod http;
|
|
|
|
|
mod io;
|
|
|
|
|
mod llm;
|
2026-04-29 10:41:32 +00:00
|
|
|
/// Log buffer — in-memory ring buffer for recent server-side log lines.
|
2026-03-22 19:07:07 +00:00
|
|
|
pub mod log_buffer;
|
2026-04-29 10:41:32 +00:00
|
|
|
/// Mesh — peer discovery and multi-hop CRDT replication over WebSocket.
|
2026-04-26 01:53:23 +00:00
|
|
|
pub mod mesh;
|
2026-04-29 10:41:32 +00:00
|
|
|
/// Node identity — Ed25519 keypair generation and stable node ID management.
|
2026-04-25 13:59:37 +00:00
|
|
|
pub mod node_identity;
|
2026-04-13 14:07:08 +00:00
|
|
|
pub(crate) mod pipeline_state;
|
2026-04-29 10:41:32 +00:00
|
|
|
/// Rebuild — process restart and shutdown coordination.
|
2026-03-22 19:07:07 +00:00
|
|
|
pub mod rebuild;
|
2026-04-24 13:40:47 +00:00
|
|
|
mod service;
|
2026-04-29 10:41:32 +00:00
|
|
|
/// Services — shared service bundle injected into HTTP handlers and bot tasks.
|
2026-04-25 15:04:37 +00:00
|
|
|
pub mod services;
|
2026-04-28 19:12:55 +00:00
|
|
|
mod startup;
|
2026-03-22 19:07:07 +00:00
|
|
|
mod state;
|
|
|
|
|
mod store;
|
|
|
|
|
mod workflow;
|
|
|
|
|
mod worktree;
|
|
|
|
|
|
|
|
|
|
use crate::agents::AgentPool;
|
|
|
|
|
use crate::http::build_routes;
|
|
|
|
|
use crate::http::context::AppContext;
|
|
|
|
|
use crate::http::{remove_port_file, resolve_port, write_port_file};
|
2026-04-28 19:12:55 +00:00
|
|
|
use crate::rebuild::ShutdownReason;
|
2026-03-22 19:07:07 +00:00
|
|
|
use crate::state::SessionState;
|
|
|
|
|
use crate::store::JsonFileStore;
|
|
|
|
|
use crate::workflow::WorkflowState;
|
|
|
|
|
use poem::Server;
|
|
|
|
|
use poem::listener::TcpListener;
|
|
|
|
|
use std::path::PathBuf;
|
|
|
|
|
use std::sync::Arc;
|
|
|
|
|
use tokio::sync::broadcast;
|
|
|
|
|
|
2026-04-26 21:41:39 +00:00
|
|
|
mod cli;
|
2026-03-23 12:51:59 +00:00
|
|
|
|
2026-04-27 01:32:08 +00:00
|
|
|
use cli::{parse_cli_args, resolve_path_arg};
|
2026-03-22 19:07:07 +00:00
|
|
|
|
2026-04-26 23:38:17 +00:00
|
|
|
#[tokio::main]
|
2026-03-22 19:07:07 +00:00
|
|
|
async fn main() -> Result<(), std::io::Error> {
|
2026-04-02 10:27:34 +00:00
|
|
|
// Reap zombie grandchildren on Unix (for native deployments without tini/init).
|
|
|
|
|
// Docker containers with `init: true` in docker-compose.yml already have tini
|
|
|
|
|
// as PID 1 for this. For native macOS/Linux, poll waitpid(-1, WNOHANG) in a
|
|
|
|
|
// background thread so orphaned grandchildren don't accumulate as zombies.
|
|
|
|
|
#[cfg(unix)]
|
2026-04-09 17:58:29 +01:00
|
|
|
std::thread::spawn(|| {
|
|
|
|
|
loop {
|
|
|
|
|
// SAFETY: waitpid(-1, ...) with WNOHANG is always safe to call.
|
|
|
|
|
unsafe { while libc::waitpid(-1, std::ptr::null_mut(), libc::WNOHANG) > 0 {} }
|
|
|
|
|
std::thread::sleep(std::time::Duration::from_secs(5));
|
2026-04-02 10:27:34 +00:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-13 13:48:18 +00:00
|
|
|
// Log version and build hash so we can verify what's running.
|
2026-04-13 14:07:08 +00:00
|
|
|
let build_hash =
|
|
|
|
|
std::fs::read_to_string(".huskies/build_hash").unwrap_or_else(|_| "unknown".to_string());
|
2026-04-13 13:48:18 +00:00
|
|
|
slog!(
|
|
|
|
|
"[startup] huskies v{} (build {})",
|
|
|
|
|
env!("CARGO_PKG_VERSION"),
|
|
|
|
|
build_hash.trim()
|
|
|
|
|
);
|
2026-04-11 20:50:15 +00:00
|
|
|
|
2026-03-22 19:07:07 +00:00
|
|
|
let app_state = Arc::new(SessionState::default());
|
|
|
|
|
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
|
2026-04-03 16:12:52 +01:00
|
|
|
// Migrate legacy root-level store.json into .huskies/ if the new path does
|
2026-04-28 19:12:55 +00:00
|
|
|
// not yet exist.
|
2026-04-02 13:24:15 +00:00
|
|
|
let legacy_store_path = cwd.join("store.json");
|
2026-04-03 16:12:52 +01:00
|
|
|
let store_path = cwd.join(".huskies").join("store.json");
|
2026-04-02 13:24:15 +00:00
|
|
|
if legacy_store_path.exists() && !store_path.exists() {
|
|
|
|
|
if let Some(parent) = store_path.parent() {
|
|
|
|
|
let _ = std::fs::create_dir_all(parent);
|
|
|
|
|
}
|
|
|
|
|
let _ = std::fs::rename(&legacy_store_path, &store_path);
|
|
|
|
|
}
|
2026-04-09 17:58:29 +01:00
|
|
|
let store = Arc::new(JsonFileStore::from_path(store_path).map_err(std::io::Error::other)?);
|
2026-03-22 19:07:07 +00:00
|
|
|
|
|
|
|
|
// Collect CLI args, skipping the binary name (argv[0]).
|
2026-03-28 13:47:02 +00:00
|
|
|
let raw_args: Vec<String> = std::env::args().skip(1).collect();
|
|
|
|
|
|
|
|
|
|
let cli = match parse_cli_args(&raw_args) {
|
|
|
|
|
Ok(args) => args,
|
|
|
|
|
Err(msg) => {
|
|
|
|
|
eprintln!("error: {msg}");
|
2026-04-03 16:12:52 +01:00
|
|
|
eprintln!("Run 'huskies --help' for usage.");
|
2026-03-23 12:51:59 +00:00
|
|
|
std::process::exit(1);
|
|
|
|
|
}
|
2026-03-28 13:26:29 +00:00
|
|
|
};
|
2026-03-22 19:07:07 +00:00
|
|
|
|
2026-03-28 13:47:02 +00:00
|
|
|
let is_init = cli.init;
|
2026-04-10 18:46:44 +00:00
|
|
|
let is_agent = cli.agent;
|
2026-04-13 13:02:41 +00:00
|
|
|
let is_gateway = cli.gateway;
|
2026-04-10 18:46:44 +00:00
|
|
|
let agent_rendezvous = cli.rendezvous.clone();
|
2026-03-28 13:47:02 +00:00
|
|
|
let explicit_path = resolve_path_arg(cli.path.as_deref(), &cwd);
|
|
|
|
|
|
|
|
|
|
// Port resolution: CLI flag > project.toml (loaded later) > default.
|
|
|
|
|
let port = cli.port.unwrap_or_else(resolve_port);
|
|
|
|
|
|
2026-04-28 19:12:55 +00:00
|
|
|
// When a path is given explicitly on the CLI, it must already exist as a directory.
|
2026-03-23 12:51:59 +00:00
|
|
|
if let Some(ref path) = explicit_path {
|
|
|
|
|
if !path.exists() {
|
2026-03-27 12:31:08 +00:00
|
|
|
eprintln!("error: path does not exist: {}", path.display());
|
2026-03-23 12:51:59 +00:00
|
|
|
std::process::exit(1);
|
|
|
|
|
}
|
|
|
|
|
if !path.is_dir() {
|
2026-03-27 12:31:08 +00:00
|
|
|
eprintln!("error: path is not a directory: {}", path.display());
|
2026-03-23 12:51:59 +00:00
|
|
|
std::process::exit(1);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-28 19:12:55 +00:00
|
|
|
// ── Gateway mode: multi-project proxy ────────────────────────────────────
|
2026-04-13 13:02:41 +00:00
|
|
|
if is_gateway {
|
|
|
|
|
let config_dir = explicit_path.unwrap_or_else(|| cwd.clone());
|
|
|
|
|
let config_path = config_dir.join("projects.toml");
|
|
|
|
|
return gateway::run(&config_path, port).await;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-28 19:12:55 +00:00
|
|
|
startup::project::open_project_root(is_init, explicit_path, &cwd, &app_state, &store, port)
|
|
|
|
|
.await;
|
2026-03-22 19:07:07 +00:00
|
|
|
|
2026-04-28 19:12:55 +00:00
|
|
|
startup::project::init_subsystems(&app_state, &cwd).await;
|
2026-04-27 20:38:03 +00:00
|
|
|
|
2026-04-25 22:09:31 +00:00
|
|
|
let crdt_join_token = cli
|
|
|
|
|
.join_token
|
|
|
|
|
.clone()
|
|
|
|
|
.or_else(|| std::env::var("HUSKIES_JOIN_TOKEN").ok());
|
|
|
|
|
|
2026-04-28 19:12:55 +00:00
|
|
|
startup::project::configure_crdt_sync(
|
|
|
|
|
&app_state,
|
|
|
|
|
is_agent,
|
|
|
|
|
agent_rendezvous.clone(),
|
|
|
|
|
crdt_join_token,
|
|
|
|
|
);
|
2026-04-09 19:46:29 +01:00
|
|
|
|
2026-04-28 19:12:55 +00:00
|
|
|
// ── Agent mode: headless build agent ─────────────────────────────────────
|
2026-04-10 18:46:44 +00:00
|
|
|
if is_agent {
|
|
|
|
|
let agent_root = app_state.project_root.lock().unwrap().clone();
|
|
|
|
|
let rendezvous = agent_rendezvous.expect("agent mode requires --rendezvous");
|
2026-04-14 12:02:17 +00:00
|
|
|
let join_token = cli
|
|
|
|
|
.join_token
|
|
|
|
|
.clone()
|
|
|
|
|
.or_else(|| std::env::var("HUSKIES_JOIN_TOKEN").ok());
|
|
|
|
|
let agent_gateway_url = cli
|
|
|
|
|
.gateway_url
|
|
|
|
|
.clone()
|
|
|
|
|
.or_else(|| std::env::var("HUSKIES_GATEWAY_URL").ok());
|
|
|
|
|
return agent_mode::run(agent_root, rendezvous, port, join_token, agent_gateway_url).await;
|
2026-04-10 18:46:44 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-22 19:07:07 +00:00
|
|
|
let workflow = Arc::new(std::sync::Mutex::new(WorkflowState::default()));
|
|
|
|
|
|
2026-04-08 01:14:55 +00:00
|
|
|
// Event bus: broadcast channel for pipeline lifecycle events.
|
2026-03-22 19:07:07 +00:00
|
|
|
let (watcher_tx, _) = broadcast::channel::<io::watcher::WatcherEvent>(1024);
|
|
|
|
|
let agents = Arc::new(AgentPool::new(port, watcher_tx.clone()));
|
|
|
|
|
|
2026-04-28 19:12:55 +00:00
|
|
|
// Filesystem watcher: watches config files for hot-reload.
|
2026-03-22 19:07:07 +00:00
|
|
|
if let Some(ref root) = *app_state.project_root.lock().unwrap() {
|
2026-04-10 17:34:41 +00:00
|
|
|
io::watcher::start_watcher(root.clone(), watcher_tx.clone());
|
2026-03-22 19:07:07 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-28 19:12:55 +00:00
|
|
|
// Spawn CRDT→watcher bridge and auto-assign subscriber.
|
|
|
|
|
startup::tick_loop::spawn_event_bridges(
|
|
|
|
|
watcher_tx.clone(),
|
|
|
|
|
app_state.project_root.lock().unwrap().clone(),
|
|
|
|
|
Arc::clone(&agents),
|
|
|
|
|
);
|
2026-03-22 19:07:07 +00:00
|
|
|
|
2026-04-28 19:12:55 +00:00
|
|
|
// Reconciliation progress channel and permission channel.
|
2026-03-22 19:07:07 +00:00
|
|
|
let (reconciliation_tx, _) = broadcast::channel::<agents::ReconciliationEvent>(64);
|
|
|
|
|
let (perm_tx, perm_rx) = tokio::sync::mpsc::unbounded_channel();
|
|
|
|
|
|
|
|
|
|
let watcher_tx_for_bot = watcher_tx.clone();
|
2026-03-25 15:35:19 +00:00
|
|
|
let watcher_rx_for_whatsapp = watcher_tx.subscribe();
|
|
|
|
|
let watcher_rx_for_slack = watcher_tx.subscribe();
|
2026-04-04 12:08:39 +00:00
|
|
|
let watcher_rx_for_discord = watcher_tx.subscribe();
|
2026-04-23 12:05:27 +00:00
|
|
|
let watcher_rx_for_events = watcher_tx.subscribe();
|
2026-04-28 19:12:55 +00:00
|
|
|
|
2026-03-22 19:07:07 +00:00
|
|
|
let perm_rx = Arc::new(tokio::sync::Mutex::new(perm_rx));
|
|
|
|
|
let startup_root: Option<PathBuf> = app_state.project_root.lock().unwrap().clone();
|
|
|
|
|
let startup_agents = Arc::clone(&agents);
|
|
|
|
|
let startup_reconciliation_tx = reconciliation_tx.clone();
|
|
|
|
|
let agents_for_shutdown = Arc::clone(&agents);
|
|
|
|
|
|
2026-04-28 19:12:55 +00:00
|
|
|
// ── Construct the shared Services bundle ──────────────────────────────────
|
|
|
|
|
let bot_cfg = startup_root
|
2026-04-25 15:04:37 +00:00
|
|
|
.as_ref()
|
|
|
|
|
.and_then(|root| chat::transport::matrix::BotConfig::load(root));
|
|
|
|
|
let services = Arc::new(services::Services {
|
|
|
|
|
project_root: startup_root.clone().unwrap_or_default(),
|
|
|
|
|
agents: Arc::clone(&agents),
|
2026-04-28 19:12:55 +00:00
|
|
|
bot_name: bot_cfg
|
2026-04-25 15:04:37 +00:00
|
|
|
.as_ref()
|
|
|
|
|
.and_then(|c| c.display_name.clone())
|
|
|
|
|
.unwrap_or_else(|| "Assistant".to_string()),
|
|
|
|
|
bot_user_id: String::new(),
|
|
|
|
|
ambient_rooms: Arc::new(std::sync::Mutex::new(
|
2026-04-28 19:12:55 +00:00
|
|
|
bot_cfg
|
2026-04-25 15:04:37 +00:00
|
|
|
.as_ref()
|
|
|
|
|
.map(|c| c.ambient_rooms.iter().cloned().collect())
|
|
|
|
|
.unwrap_or_default(),
|
|
|
|
|
)),
|
|
|
|
|
perm_rx: Arc::clone(&perm_rx),
|
|
|
|
|
pending_perm_replies: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())),
|
2026-04-28 19:12:55 +00:00
|
|
|
permission_timeout_secs: bot_cfg
|
2026-04-25 15:04:37 +00:00
|
|
|
.as_ref()
|
|
|
|
|
.map(|c| c.permission_timeout_secs)
|
|
|
|
|
.unwrap_or(120),
|
2026-04-27 18:00:53 +00:00
|
|
|
status: agents.status_broadcaster(),
|
2026-04-25 15:04:37 +00:00
|
|
|
});
|
|
|
|
|
|
2026-04-28 19:12:55 +00:00
|
|
|
// ── Build bot contexts (WhatsApp / Slack / Discord) ───────────────────────
|
|
|
|
|
let (bot_ctxs, matrix_shutdown_rx) =
|
|
|
|
|
startup::bots::build_bot_contexts(&startup_root, &services);
|
|
|
|
|
startup::bots::spawn_startup_announcements(&bot_ctxs);
|
2026-03-22 19:07:07 +00:00
|
|
|
|
2026-04-28 19:12:55 +00:00
|
|
|
let matrix_shutdown_tx_for_rebuild = Arc::clone(&bot_ctxs.matrix_shutdown_tx);
|
2026-04-04 12:08:39 +00:00
|
|
|
|
2026-04-28 19:12:55 +00:00
|
|
|
// Shared rate-limit retry timer store.
|
2026-04-24 17:39:42 +00:00
|
|
|
let timer_store = std::sync::Arc::new(crate::service::timer::TimerStore::load(
|
2026-04-09 21:28:48 +01:00
|
|
|
startup_root
|
|
|
|
|
.as_ref()
|
|
|
|
|
.map(|r| r.join(".huskies").join("timers.json"))
|
|
|
|
|
.unwrap_or_else(|| std::path::PathBuf::from("/tmp/huskies-timers.json")),
|
|
|
|
|
));
|
2026-04-10 17:34:41 +00:00
|
|
|
let timer_store_for_tick = Arc::clone(&timer_store);
|
2026-04-27 11:28:21 +00:00
|
|
|
let timer_store_for_bot = Arc::clone(&timer_store);
|
2026-04-10 17:34:41 +00:00
|
|
|
|
2026-03-22 19:08:41 +00:00
|
|
|
let ctx = AppContext {
|
|
|
|
|
state: app_state,
|
|
|
|
|
store,
|
|
|
|
|
workflow,
|
2026-04-25 15:04:37 +00:00
|
|
|
services: Arc::clone(&services),
|
2026-03-22 19:08:41 +00:00
|
|
|
watcher_tx,
|
|
|
|
|
reconciliation_tx,
|
|
|
|
|
perm_tx,
|
|
|
|
|
qa_app_process: Arc::new(std::sync::Mutex::new(None)),
|
2026-04-28 19:12:55 +00:00
|
|
|
bot_shutdown: bot_ctxs.shutdown_notifier.clone(),
|
|
|
|
|
matrix_shutdown_tx: Some(Arc::clone(&bot_ctxs.matrix_shutdown_tx)),
|
2026-04-09 21:28:48 +01:00
|
|
|
timer_store,
|
2026-03-22 19:08:41 +00:00
|
|
|
};
|
|
|
|
|
|
2026-04-28 19:12:55 +00:00
|
|
|
// Per-project event buffer for the gateway's `/api/events` poller.
|
2026-04-23 12:05:27 +00:00
|
|
|
let event_buffer = crate::http::events::EventBuffer::new();
|
|
|
|
|
crate::http::events::subscribe_to_watcher(event_buffer.clone(), watcher_rx_for_events);
|
|
|
|
|
|
2026-04-28 19:12:55 +00:00
|
|
|
// Gateway relay task (pushes StatusEvents to a configured gateway).
|
|
|
|
|
startup::tick_loop::spawn_gateway_relay(&startup_root, Arc::clone(&services.status));
|
2026-04-28 01:06:02 +00:00
|
|
|
|
2026-04-23 12:05:27 +00:00
|
|
|
let app = build_routes(
|
|
|
|
|
ctx,
|
2026-04-28 19:12:55 +00:00
|
|
|
bot_ctxs.whatsapp_ctx.clone(),
|
|
|
|
|
bot_ctxs.slack_ctx.clone(),
|
2026-04-23 12:05:27 +00:00
|
|
|
port,
|
|
|
|
|
Some(event_buffer),
|
|
|
|
|
);
|
2026-03-22 19:07:07 +00:00
|
|
|
|
2026-04-28 19:12:55 +00:00
|
|
|
// Unified 1-second background tick loop.
|
|
|
|
|
startup::tick_loop::spawn_tick_loop(
|
|
|
|
|
Arc::clone(&startup_agents),
|
|
|
|
|
timer_store_for_tick,
|
|
|
|
|
startup_root.clone(),
|
|
|
|
|
);
|
2026-04-10 17:34:41 +00:00
|
|
|
|
2026-04-28 19:12:55 +00:00
|
|
|
// Optional Matrix bot.
|
2026-03-22 19:07:07 +00:00
|
|
|
if let Some(ref root) = startup_root {
|
2026-04-14 18:53:41 +00:00
|
|
|
let _ = chat::transport::matrix::spawn_bot(
|
2026-03-22 19:07:07 +00:00
|
|
|
root,
|
|
|
|
|
watcher_tx_for_bot,
|
2026-04-25 15:04:37 +00:00
|
|
|
Arc::clone(&services),
|
2026-03-22 19:08:41 +00:00
|
|
|
matrix_shutdown_rx,
|
2026-04-14 09:57:11 +00:00
|
|
|
None,
|
|
|
|
|
vec![],
|
2026-04-21 11:47:06 +01:00
|
|
|
std::collections::BTreeMap::new(),
|
2026-04-27 11:28:21 +00:00
|
|
|
timer_store_for_bot,
|
2026-04-28 01:27:00 +00:00
|
|
|
None,
|
2026-03-22 19:07:07 +00:00
|
|
|
);
|
2026-03-22 19:08:41 +00:00
|
|
|
} else {
|
|
|
|
|
drop(matrix_shutdown_rx);
|
2026-03-22 19:07:07 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-28 19:12:55 +00:00
|
|
|
// Notification listeners for WhatsApp, Slack, Discord.
|
|
|
|
|
startup::bots::spawn_notification_listeners(
|
|
|
|
|
&bot_ctxs,
|
|
|
|
|
&startup_root,
|
|
|
|
|
watcher_rx_for_whatsapp,
|
|
|
|
|
watcher_rx_for_slack,
|
|
|
|
|
watcher_rx_for_discord,
|
|
|
|
|
);
|
2026-04-27 13:57:19 +00:00
|
|
|
|
2026-04-28 19:12:55 +00:00
|
|
|
// Reconcile completed worktrees and auto-assign free agents.
|
|
|
|
|
startup::tick_loop::spawn_startup_reconciliation(
|
|
|
|
|
startup_root.clone(),
|
|
|
|
|
startup_agents,
|
|
|
|
|
startup_reconciliation_tx,
|
|
|
|
|
);
|
2026-03-25 15:35:19 +00:00
|
|
|
|
2026-04-03 16:12:52 +01:00
|
|
|
let host = std::env::var("HUSKIES_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
|
2026-03-24 21:26:48 +00:00
|
|
|
let addr = format!("{host}:{port}");
|
2026-03-22 19:07:07 +00:00
|
|
|
|
2026-04-03 21:38:58 +01:00
|
|
|
println!("\x1b[97;1m");
|
|
|
|
|
println!(" /\\_/\\ \x1b[96;1m _ _ _ _ \x1b[97;1m");
|
|
|
|
|
println!(" / o o \\ \x1b[96;1m| | | |_ _ ___| | _(_) ___ ___\x1b[97;1m");
|
|
|
|
|
println!(" ( Y ) \x1b[96;1m| |_| | | | / __| |/ / |/ _ \\/ __|\x1b[97;1m");
|
|
|
|
|
println!(" \\ ^ / \x1b[96;1m| _ | |_| \\__ \\ <| | __/\\__ \\\x1b[97;1m");
|
|
|
|
|
println!(" )===( \\ \x1b[96;1m|_| |_|\\__,_|___/_|\\_\\_|\\___||___/\x1b[97;1m");
|
|
|
|
|
println!(" / \\ \\ \x1b[90mStory-driven development, powered by AI\x1b[97;1m");
|
|
|
|
|
println!(" | | | |");
|
|
|
|
|
println!(" /| | |\\|");
|
|
|
|
|
println!(" \\|__|__|/\x1b[0m");
|
2026-04-03 21:03:54 +01:00
|
|
|
println!();
|
2026-04-03 16:12:52 +01:00
|
|
|
println!("HUSKIES_PORT={port}");
|
2026-03-22 19:07:07 +00:00
|
|
|
println!("\x1b[96;1mFrontend:\x1b[0m \x1b[94mhttp://{addr}\x1b[0m");
|
|
|
|
|
println!("\x1b[92;1mOpenAPI Docs:\x1b[0m \x1b[94mhttp://{addr}/docs\x1b[0m");
|
|
|
|
|
|
|
|
|
|
let port_file = write_port_file(&cwd, port);
|
|
|
|
|
|
|
|
|
|
let result = Server::new(TcpListener::bind(&addr)).run(app).await;
|
|
|
|
|
|
2026-04-28 19:12:55 +00:00
|
|
|
// ── Shutdown notifications (best-effort) ──────────────────────────────────
|
|
|
|
|
startup::bots::notify_shutdown(&bot_ctxs).await;
|
2026-03-22 19:08:41 +00:00
|
|
|
|
|
|
|
|
// Matrix: signal the bot task and give it a short window to send its message.
|
|
|
|
|
let _ = matrix_shutdown_tx_for_rebuild.send(Some(ShutdownReason::Manual));
|
|
|
|
|
tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
|
|
|
|
|
|
2026-04-28 19:12:55 +00:00
|
|
|
// Kill all active PTY child processes before exiting.
|
2026-03-22 19:07:07 +00:00
|
|
|
agents_for_shutdown.kill_all_children();
|
|
|
|
|
|
|
|
|
|
if let Some(ref path) = port_file {
|
|
|
|
|
remove_port_file(path);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
result
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
|
2026-04-26 22:01:06 +00:00
|
|
|
#[test]
|
|
|
|
|
#[should_panic(expected = "Invalid project.toml: Duplicate agent name")]
|
2026-03-22 19:07:07 +00:00
|
|
|
fn panics_on_duplicate_agent_names() {
|
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
2026-04-03 16:12:52 +01:00
|
|
|
let sk = tmp.path().join(".huskies");
|
2026-03-22 19:07:07 +00:00
|
|
|
std::fs::create_dir_all(&sk).unwrap();
|
|
|
|
|
std::fs::write(
|
|
|
|
|
sk.join("project.toml"),
|
|
|
|
|
r#"
|
|
|
|
|
[[agent]]
|
|
|
|
|
name = "coder"
|
|
|
|
|
|
|
|
|
|
[[agent]]
|
|
|
|
|
name = "coder"
|
|
|
|
|
"#,
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
|
|
|
|
config::ProjectConfig::load(tmp.path())
|
|
|
|
|
.unwrap_or_else(|e| panic!("Invalid project.toml: {e}"));
|
|
|
|
|
}
|
|
|
|
|
}
|