Skip to main content

xcodeai/http/
routes.rs

1//! Route handlers for the xcodeai HTTP API.
2//!
3//! Endpoints implemented here:
4//! - `POST   /sessions`      — create a new session
5//! - `GET    /sessions`      — list recent sessions (default: latest 50)
6//! - `GET    /sessions/:id`  — get one session + its messages
7//! - `DELETE /sessions/:id`  — delete a session and its messages
8//!
9//! Agent execution (`POST /sessions/:id/messages`) is implemented in Task 34.
10//!
11//! ## Locking convention
12//!
13//! All handlers acquire `state.store.lock().await` before any DB call,
14//! because rusqlite's Connection is `!Sync` (uses RefCell internally).
15use crate::http::AppState;
16use axum::{
17    extract::{Path, Query, State},
18    http::StatusCode,
19    response::{
20        sse::{Event, KeepAlive, Sse},
21        IntoResponse,
22    },
23    routing::{delete, get, post},
24    Json, Router,
25};
26use futures_util::stream;
27use serde::{Deserialize, Serialize};
28use std::convert::Infallible;
29use std::sync::Arc;
30
31// ─── Request / Response types ─────────────────────────────────────────────────
32
33/// Body for `POST /sessions`.
34///
35/// All fields are optional: a bare `{}` creates an untitled session.
36#[derive(Debug, Deserialize)]
37pub struct CreateSessionRequest {
38    /// Optional human-readable title (e.g. "Refactor auth module").
39    pub title: Option<String>,
40}
41
42/// Query-string parameters for `GET /sessions`.
43#[derive(Debug, Deserialize)]
44pub struct ListSessionsQuery {
45    /// How many sessions to return (default: 50, max enforced by caller).
46    #[serde(default = "default_limit")]
47    pub limit: u32,
48}
49
50fn default_limit() -> u32 {
51    50
52}
53
54/// A single session entry returned in list / get responses.
55#[derive(Debug, Serialize)]
56pub struct SessionResponse {
57    pub id: String,
58    pub title: Option<String>,
59    pub created_at: String,
60    pub updated_at: String,
61}
62
63/// Full session with messages, returned by `GET /sessions/:id`.
64#[derive(Debug, Serialize)]
65pub struct SessionDetailResponse {
66    pub id: String,
67    pub title: Option<String>,
68    pub created_at: String,
69    pub updated_at: String,
70    /// All messages in chronological order.
71    pub messages: Vec<MessageResponse>,
72}
73
74/// A single stored message.
75#[derive(Debug, Serialize)]
76pub struct MessageResponse {
77    pub id: String,
78    pub role: String,
79    pub content: Option<String>,
80    pub created_at: String,
81}
82
83/// Generic JSON error body.
84#[derive(Debug, Serialize)]
85struct ErrorResponse {
86    error: String,
87}
88
89// ─── Router factory ───────────────────────────────────────────────────────────
90
91/// Build the session sub-router.
92pub fn session_router() -> Router<Arc<AppState>> {
93    Router::new()
94        .route("/sessions", post(create_session))
95        .route("/sessions", get(list_sessions))
96        .route("/sessions/:id", get(get_session))
97        .route("/sessions/:id", delete(delete_session))
98        .route("/sessions/:id/messages", post(post_message))
99}
100
101// ─── Handlers ─────────────────────────────────────────────────────────────────
102
103/// `POST /sessions` — create a new session.
104///
105/// Returns 201 Created with `{"session_id": "<uuid>"}` on success.
106pub async fn create_session(
107    State(state): State<Arc<AppState>>,
108    body: Option<Json<CreateSessionRequest>>,
109) -> impl IntoResponse {
110    let title = body.and_then(|b| b.title.clone());
111    let store = state.store.lock().await;
112    match store.create_session(title.as_deref()) {
113        Ok(session) => (
114            StatusCode::CREATED,
115            Json(serde_json::json!({ "session_id": session.id })),
116        )
117            .into_response(),
118        Err(e) => (
119            StatusCode::INTERNAL_SERVER_ERROR,
120            Json(ErrorResponse {
121                error: e.to_string(),
122            }),
123        )
124            .into_response(),
125    }
126}
127
128/// `GET /sessions?limit=N` — list recent sessions.
129///
130/// Returns 200 OK with a JSON array of session objects, ordered newest first.
131pub async fn list_sessions(
132    State(state): State<Arc<AppState>>,
133    Query(params): Query<ListSessionsQuery>,
134) -> impl IntoResponse {
135    let limit = params.limit.min(200);
136    let store = state.store.lock().await;
137    match store.list_sessions(limit) {
138        Ok(sessions) => {
139            let resp: Vec<SessionResponse> = sessions
140                .into_iter()
141                .map(|s| SessionResponse {
142                    id: s.id,
143                    title: s.title,
144                    created_at: s.created_at.to_rfc3339(),
145                    updated_at: s.updated_at.to_rfc3339(),
146                })
147                .collect();
148            (StatusCode::OK, Json(resp)).into_response()
149        }
150        Err(e) => (
151            StatusCode::INTERNAL_SERVER_ERROR,
152            Json(ErrorResponse {
153                error: e.to_string(),
154            }),
155        )
156            .into_response(),
157    }
158}
159
160/// `GET /sessions/:id` — get a single session with all messages.
161///
162/// Returns 200 OK with a `SessionDetailResponse`, or 404 if not found.
163pub async fn get_session(
164    State(state): State<Arc<AppState>>,
165    Path(id): Path<String>,
166) -> impl IntoResponse {
167    let store = state.store.lock().await;
168
169    let session = match store.get_session(&id) {
170        Ok(Some(s)) => s,
171        Ok(None) => {
172            return (
173                StatusCode::NOT_FOUND,
174                Json(ErrorResponse {
175                    error: format!("Session '{id}' not found"),
176                }),
177            )
178                .into_response()
179        }
180        Err(e) => {
181            return (
182                StatusCode::INTERNAL_SERVER_ERROR,
183                Json(ErrorResponse {
184                    error: e.to_string(),
185                }),
186            )
187                .into_response()
188        }
189    };
190
191    let messages = match store.get_messages(&id) {
192        Ok(msgs) => msgs,
193        Err(e) => {
194            return (
195                StatusCode::INTERNAL_SERVER_ERROR,
196                Json(ErrorResponse {
197                    error: e.to_string(),
198                }),
199            )
200                .into_response()
201        }
202    };
203
204    let detail = SessionDetailResponse {
205        id: session.id,
206        title: session.title,
207        created_at: session.created_at.to_rfc3339(),
208        updated_at: session.updated_at.to_rfc3339(),
209        messages: messages
210            .into_iter()
211            .map(|m| MessageResponse {
212                id: m.id,
213                role: m.role,
214                content: m.content,
215                created_at: m.created_at.to_rfc3339(),
216            })
217            .collect(),
218    };
219
220    (StatusCode::OK, Json(detail)).into_response()
221}
222
223/// `DELETE /sessions/:id` — delete a session and all its messages.
224///
225/// Returns 204 No Content on success, 404 if not found.
226pub async fn delete_session(
227    State(state): State<Arc<AppState>>,
228    Path(id): Path<String>,
229) -> impl IntoResponse {
230    let store = state.store.lock().await;
231
232    // Verify the session exists first so we can return a proper 404.
233    match store.get_session(&id) {
234        Ok(None) => {
235            return (
236                StatusCode::NOT_FOUND,
237                Json(ErrorResponse {
238                    error: format!("Session '{id}' not found"),
239                }),
240            )
241                .into_response()
242        }
243        Err(e) => {
244            return (
245                StatusCode::INTERNAL_SERVER_ERROR,
246                Json(ErrorResponse {
247                    error: e.to_string(),
248                }),
249            )
250                .into_response()
251        }
252        Ok(Some(_)) => {} // proceed
253    }
254
255    match store.delete_session(&id) {
256        Ok(()) => StatusCode::NO_CONTENT.into_response(),
257        Err(e) => (
258            StatusCode::INTERNAL_SERVER_ERROR,
259            Json(ErrorResponse {
260                error: e.to_string(),
261            }),
262        )
263            .into_response(),
264    }
265}
266
267// ─── POST /sessions/:id/messages ────────────────────────────────────────────
268
269/// Request body for `POST /sessions/:id/messages`.
270///
271/// Sends a user message to an existing session and runs the agent loop,
272/// streaming the output as Server-Sent Events.
273#[derive(Debug, Deserialize)]
274pub struct PostMessageRequest {
275    /// The task/message text for the agent.
276    pub content: String,
277    /// Optional list of image file paths to attach as multimodal content.
278    /// Each entry is a filesystem path; the handler reads and base64-encodes it.
279    #[serde(default)]
280    #[allow(dead_code)]
281    pub images: Vec<String>,
282}
283
284/// `POST /sessions/:id/messages`
285///
286/// Accepts a JSON body `{ "content": "...", "images": [...] }`, persists the
287/// user message in the session, then runs the CoderAgent loop and streams
288/// all agent output as a Server-Sent Events response.
289///
290/// ## SSE event types
291///
292/// | event       | data fields                             |
293/// |-------------|----------------------------------------|
294/// | `status`    | `{"msg": "..."}` — progress update     |
295/// | `tool_call` | `{"name": "...", "args": "..."}` — tool invocation |
296/// | `tool_result` | `{"preview": "...", "is_error": bool}` — tool result |
297/// | `error`     | `{"msg": "..."}` — agent-level error   |
298/// | `complete`  | `{}` — agent finished, stream ends     |
299///
300/// ## Error responses (non-SSE)
301///
302/// - `404 Not Found` — session ID does not exist
303/// - `409 Conflict` — session already has an active agent execution
304/// - `500 Internal Server Error` — failed to start agent
305pub async fn post_message(
306    State(state): State<Arc<AppState>>,
307    Path(session_id): Path<String>,
308    Json(body): Json<PostMessageRequest>,
309) -> impl IntoResponse {
310    use crate::agent::director::Director;
311    use crate::agent::Agent;
312    use crate::context::AgentContext;
313    use crate::io::http::HttpIO;
314    use crate::llm;
315
316    // ── 1. Verify the session exists ──────────────────────────────────────
317    {
318        let store = state.store.lock().await;
319        match store.get_session(&session_id) {
320            Ok(Some(_)) => {} // exists, proceed
321            Ok(None) => {
322                return (
323                    StatusCode::NOT_FOUND,
324                    Json(ErrorResponse {
325                        error: format!("Session '{}' not found", session_id),
326                    }),
327                )
328                    .into_response();
329            }
330            Err(e) => {
331                return (
332                    StatusCode::INTERNAL_SERVER_ERROR,
333                    Json(ErrorResponse {
334                        error: e.to_string(),
335                    }),
336                )
337                    .into_response();
338            }
339        }
340    }
341
342    // ── 2. Concurrency check — 409 if session already active ──────────────
343    {
344        let mut active = state.active_sessions.lock().await;
345        if active.contains(&session_id) {
346            return (
347                StatusCode::CONFLICT,
348                Json(ErrorResponse {
349                    error: format!(
350                        "Session '{}' already has an active agent execution",
351                        session_id
352                    ),
353                }),
354            )
355                .into_response();
356        }
357        active.insert(session_id.clone());
358    }
359
360    // ── 3. Persist the user message ───────────────────────────────────────
361    let content = body.content.clone();
362    {
363        let store = state.store.lock().await;
364        if let Err(e) = store.add_message(&session_id, &llm::Message::user(&content)) {
365            // Remove from active set on early failure.
366            state.active_sessions.lock().await.remove(&session_id);
367            return (
368                StatusCode::INTERNAL_SERVER_ERROR,
369                Json(ErrorResponse {
370                    error: e.to_string(),
371                }),
372            )
373                .into_response();
374        }
375    }
376
377    // ── 4. Build HttpIO — agent will push SseEvents into the channel ──────
378    let (http_io, rx) = HttpIO::new();
379    let io: std::sync::Arc<dyn crate::io::AgentIO> = std::sync::Arc::new(http_io);
380
381    // Clone the config from AppState — AgentContext::new() takes ownership of
382    // these values and the task runs asynchronously so we need owned copies.
383    let config = state.config.clone();
384    let sid = session_id.clone();
385    let state2 = std::sync::Arc::clone(&state);
386
387    // ── 5. Spawn the agent loop in a background task ──────────────────────
388    //
389    // The task:
390    //   a) Builds a fresh AgentContext (registers tools, optional MCP, etc.)
391    //   b) Constructs the message list (system prompt + user message)
392    //   c) Runs Director::execute() — the agent → tool loop
393    //   d) Persists the final assistant message
394    //   e) Sends SseEvent::Complete so the HTTP client knows the stream ended
395    //   f) Removes the session from `active_sessions`
396    //
397    // The HTTP handler returns the SSE stream immediately (step below) and does
398    // NOT await the spawned task — the client receives events as they arrive.
399    tokio::spawn(async move {
400        // Build IO + AgentContext.
401        // We pass None for project/sandbox/model/provider/key — they are all
402        // read from `config` which was already loaded at serve startup.
403        let ctx_result = AgentContext::new(
404            config.project_dir.clone(), // project dir from config
405            false,                      // no_sandbox: read from config inside new()
406            Some(config.model.clone()),
407            Some(config.provider.api_base.clone()),
408            Some(config.provider.api_key.clone()),
409            config.agent.compact_mode,
410            std::sync::Arc::clone(&io),
411        )
412        .await;
413
414        let ctx = match ctx_result {
415            Ok(c) => c,
416            Err(e) => {
417                let _ = io.write_error(&format!("Agent init failed: {:#}", e)).await;
418                state2.active_sessions.lock().await.remove(&sid);
419                return;
420            }
421        };
422
423        // Build system prompt and initial messages.
424        let agents_md = crate::agent::agents_md::load_agents_md(&ctx.project_dir);
425        let coder = crate::agent::coder::CoderAgent::new_with_agents_md(
426            ctx.config.agent.clone(),
427            agents_md,
428        );
429        let mut messages = vec![
430            llm::Message::system(coder.system_prompt().as_str()),
431            llm::Message::user(content.as_str()),
432        ];
433
434        // Run the agent loop.
435        let director = Director::new(ctx.config.agent.clone());
436        let result = director
437            .execute(
438                &mut messages,
439                ctx.registry.as_ref(),
440                ctx.llm.as_ref(),
441                &ctx.tool_ctx,
442            )
443            .await;
444
445        // Persist result and send Complete event.
446        match result {
447            Ok(agent_result) => {
448                let store = state2.store.lock().await;
449                let _ = store.add_message(
450                    &sid,
451                    &llm::Message::assistant(Some(agent_result.final_message), None),
452                );
453                let _ = store.update_session_timestamp(&sid);
454            }
455            Err(e) => {
456                let _ = io.write_error(&format!("Agent error: {:#}", e)).await;
457            }
458        }
459
460        // Signal the SSE stream that the agent is done.
461        // The Sender is owned by HttpIO which is wrapped in `io` (Arc).
462        // Drop io before removing from active_sessions so the channel closes
463        // and the SSE stream terminates cleanly.
464        let _ = io.show_status("[DONE]").await;
465        drop(io);
466        state2.active_sessions.lock().await.remove(&sid);
467    });
468
469    // ── 6. Convert the mpsc Receiver into an axum SSE stream ─────────────
470    //
471    // `stream::unfold` turns our Receiver into an async Stream<Item=...>.
472    // Each received SseEvent is converted to an axum `Event` with the
473    // appropriate `event` name and JSON `data` field.
474    // When the channel closes (sender dropped), the stream ends naturally.
475    let sse_stream = stream::unfold(rx, |mut receiver| async move {
476        // recv() returns None when the channel is closed.
477        let event = receiver.recv().await?;
478
479        // Build the axum SSE Event.
480        let axum_event = Event::default()
481            .event(event.event_name())
482            .data(event.data_json());
483
484        Some((Ok::<Event, Infallible>(axum_event), receiver))
485    });
486
487    // Return the SSE response.  KeepAlive sends periodic comments to prevent
488    // proxy/browser timeouts on long-running agent executions.
489    Sse::new(sse_stream)
490        .keep_alive(KeepAlive::default())
491        .into_response()
492}