1use std::net::SocketAddr;
4use std::sync::Arc;
5use std::time::Duration;
6
7use async_trait::async_trait;
8use talon_core::audit::SharedAuditSink;
9use talon_core::channel::{Channel, ChannelHost};
10use talon_core::{Error, Result};
11use tokio::net::TcpListener;
12use tokio::sync::Mutex as AsyncMutex;
13use tokio::task::JoinHandle;
14use tokio_util::sync::CancellationToken;
15
16use crate::config::{HttpConfig, MIN_RECOMMENDED_KEY_LEN};
17use crate::lanes::SendOutcome;
18use crate::metrics::Metrics;
19use crate::palace::PalaceRunner;
20use crate::server::{HttpDeps, HttpState, router};
21use crate::store::HttpStore;
22
23const SHUTDOWN_GRACE: Duration = Duration::from_secs(5);
25const SHUTDOWN_SLACK: Duration = Duration::from_secs(2);
28const HTTP2_KEEP_ALIVE: Duration = Duration::from_secs(30);
30
31fn server_limits(cfg: &HttpConfig) -> talon_net::ServerLimits {
35 talon_net::ServerLimits {
36 max_connections: cfg.max_connections.max(1),
37 max_connections_per_ip: cfg.max_connections_per_ip.max(1),
38 header_read_timeout: cfg.header_read_timeout,
39 http2_keep_alive_interval: HTTP2_KEEP_ALIVE,
40 shutdown_grace: SHUTDOWN_GRACE,
41 }
42}
43
44struct Running {
45 cancel: CancellationToken,
46 server: JoinHandle<()>,
47 sweeper: JoinHandle<()>,
48 addr: SocketAddr,
49}
50
51pub struct HttpChannel {
54 state: HttpState,
55 running: AsyncMutex<Option<Running>>,
56 connected: std::sync::atomic::AtomicBool,
57 local_addr: std::sync::Mutex<Option<SocketAddr>>,
58}
59
60impl std::fmt::Debug for HttpChannel {
61 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62 f.debug_struct("HttpChannel")
63 .field("state", &self.state)
64 .field("connected", &self.is_connected())
65 .finish_non_exhaustive()
66 }
67}
68
69impl HttpChannel {
70 pub fn new(
75 config: HttpConfig,
76 host: Arc<dyn ChannelHost>,
77 db: talon_db::Db,
78 audit: SharedAuditSink,
79 ) -> Self {
80 let store: Arc<dyn HttpStore> = Arc::new(db);
81 let deps = HttpDeps::new(&config, host, store, audit);
82 Self::from_state(HttpState::new(config, deps))
83 }
84
85 pub fn with_palace(
89 config: HttpConfig,
90 host: Arc<dyn ChannelHost>,
91 db: talon_db::Db,
92 audit: SharedAuditSink,
93 palace: Arc<dyn PalaceRunner>,
94 ) -> Self {
95 let store: Arc<dyn HttpStore> = Arc::new(db);
96 let deps = HttpDeps::new(&config, host, store, audit).with_palace(palace);
97 Self::from_state(HttpState::new(config, deps))
98 }
99
100 pub fn from_state(state: HttpState) -> Self {
102 Self {
103 state,
104 running: AsyncMutex::new(None),
105 connected: std::sync::atomic::AtomicBool::new(false),
106 local_addr: std::sync::Mutex::new(None),
107 }
108 }
109
110 pub fn state(&self) -> &HttpState {
112 &self.state
113 }
114
115 pub fn local_addr(&self) -> Option<SocketAddr> {
117 *self.local_addr.lock().unwrap_or_else(|e| e.into_inner())
118 }
119
120 fn warn_about_key(&self) {
121 let key = &self.state.config().api_key;
122 if !key.is_configured() {
123 tracing::error!(
124 "HTTP_API_KEY is not set in .env: the CoPilot HTTP API will reject EVERY request \
125 except GET /health with 401 until a key is configured"
126 );
127 } else if key.len() < MIN_RECOMMENDED_KEY_LEN {
128 tracing::warn!(
129 min_len = MIN_RECOMMENDED_KEY_LEN,
130 "HTTP_API_KEY is short; use a long random key (e.g. `openssl rand -hex 32`)"
131 );
132 }
133 if let Some(warning) = self.state.config().exposure_warning() {
134 tracing::warn!("{warning}");
135 }
136 if self.state.config().evals_legacy_group_dir {
137 tracing::warn!(
138 "COPILOT_EVALS_LEGACY_GROUP_DIR is enabled: GET /evals also serves records from \
139 the shared group folder, which every CoPilot lane can write (forgeable). Disable \
140 it once old evals are no longer needed"
141 );
142 }
143 }
144}
145
146async fn sweep_loop(state: HttpState, cancel: CancellationToken) {
147 let period = state
148 .config()
149 .lane_sweep_interval
150 .max(Duration::from_secs(1));
151 let mut tick = tokio::time::interval(period);
152 tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
153 tick.tick().await;
154 loop {
155 tokio::select! {
156 _ = cancel.cancelled() => break,
157 _ = tick.tick() => {
158 let swept = state.sweep_idle_lanes().await;
159 if !swept.is_empty() {
160 tracing::info!(count = swept.len(), "HTTP channel: idle lane sweep");
161 }
162 }
163 }
164 }
165}
166
167#[async_trait]
168impl Channel for HttpChannel {
169 fn name(&self) -> &str {
170 "http"
171 }
172
173 async fn connect(&self) -> Result<()> {
174 let mut running = self.running.lock().await;
175 if running.is_some() {
176 return Ok(());
177 }
178 self.warn_about_key();
179
180 let cfg = self.state.config();
181 let bind = SocketAddr::new(cfg.bind, cfg.port);
182 let listener = TcpListener::bind(bind)
183 .await
184 .map_err(|e| Error::Channel(format!("HTTP channel: cannot bind {bind}: {e}")))?;
185 let addr = listener.local_addr()?;
186
187 self.state.register_base_lanes().await;
188 if cfg.seed_digest_task {
189 match self.state.seed_digest_task().await {
190 Ok(true) => tracing::info!(
191 task_id = crate::investigate::DIGEST_TASK_ID,
192 "Seeded CoPilot alert digest scheduled task (paused)"
193 ),
194 Ok(false) => {}
195 Err(e) => tracing::warn!(err = %e, "failed to seed the alert digest task"),
196 }
197 }
198
199 let cancel = CancellationToken::new();
200 let app = router(self.state.clone());
201 let server = tokio::spawn(talon_net::serve(
204 listener,
205 app,
206 server_limits(cfg),
207 cancel.clone(),
208 ));
209 let sweeper = tokio::spawn(sweep_loop(self.state.clone(), cancel.clone()));
210
211 tracing::info!(%addr, palace = self.state.0.palace.mode(), "HTTP channel listening");
212 tracing::info!(
213 "HTTP channel endpoints: POST /message, POST /investigate, GET /investigations[/{{id}}], \
214 GET /evals/{{alert_id}}, GET /templates, POST /palace/lesson, GET /palace/search, \
215 POST /palace/forget, GET /status, GET /metrics, POST /session/reset, GET /health"
216 );
217 *self.local_addr.lock().unwrap_or_else(|e| e.into_inner()) = Some(addr);
218 *running = Some(Running {
219 cancel,
220 server,
221 sweeper,
222 addr,
223 });
224 self.connected
225 .store(true, std::sync::atomic::Ordering::SeqCst);
226 Ok(())
227 }
228
229 async fn send_message(&self, jid: &str, text: &str) -> Result<()> {
230 let s = &self.state.0;
231 match s.streams.send_text(jid, text) {
232 SendOutcome::Delivered => {
233 tracing::info!(
234 jid,
235 length = text.len(),
236 "HTTP channel: wrote SSE text chunk"
237 );
238 }
239 SendOutcome::NoWriter => {
240 tracing::debug!(
244 jid,
245 length = text.len(),
246 "HTTP channel: no SSE writer for lane, dropping chunk"
247 );
248 }
249 SendOutcome::Overflow => {
250 Metrics::inc(&s.metrics.sse_dropped_chunks);
251 tracing::warn!(
252 jid,
253 length = text.len(),
254 "HTTP channel: SSE buffer full, dropping chunk"
255 );
256 }
257 SendOutcome::ClientGone => {
258 tracing::info!(jid, "HTTP channel: SSE client disconnected, dropping chunk");
259 }
260 }
261 Ok(())
262 }
263
264 fn is_connected(&self) -> bool {
265 self.connected.load(std::sync::atomic::Ordering::SeqCst)
266 }
267
268 fn owns_jid(&self, jid: &str) -> bool {
269 self.state.lanes().owns(jid)
270 }
271
272 async fn disconnect(&self) -> Result<()> {
273 let taken = self.running.lock().await.take();
274 self.connected
275 .store(false, std::sync::atomic::Ordering::SeqCst);
276 let Some(mut r) = taken else {
277 return Ok(());
278 };
279 r.cancel.cancel();
280 self.state.0.streams.close_all();
282 if tokio::time::timeout(SHUTDOWN_GRACE + SHUTDOWN_SLACK, &mut r.server)
283 .await
284 .is_err()
285 {
286 tracing::warn!("HTTP channel: graceful shutdown timed out; aborting");
287 r.server.abort();
288 }
289 r.sweeper.abort();
290 *self.local_addr.lock().unwrap_or_else(|e| e.into_inner()) = None;
291 tracing::info!(addr = %r.addr, "HTTP channel stopped");
292 Ok(())
293 }
294
295 async fn set_typing(&self, jid: &str, is_typing: bool) -> Result<()> {
296 let streams = &self.state.0.streams;
297 if is_typing {
298 if streams.turn_started(jid) {
300 tracing::info!(
301 jid,
302 remaining = streams.pending_len(jid),
303 "HTTP channel: dequeued SSE writer"
304 );
305 } else {
306 tracing::debug!(jid, "HTTP channel: turn start without a new SSE writer");
307 }
308 } else if streams.turn_ended(jid) {
309 tracing::warn!(
312 jid,
313 "HTTP channel: container stopped with an active writer; closed via safety net"
314 );
315 }
316 Ok(())
317 }
318
319 async fn on_turn_complete(&self, jid: &str) -> Result<()> {
320 if self.state.0.streams.turn_complete(jid) {
321 tracing::info!(
322 jid,
323 reason = "turn-complete",
324 "HTTP channel: SSE stream closed"
325 );
326 }
327 Ok(())
328 }
329}