collet 0.1.0

Relentless agentic coding orchestrator with zero-drop agent loops
Documentation
//! Session management REST API routes.

use std::sync::Arc;

use axum::Router;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::response::Json;
use axum::routing::{get, post};
use serde::Serialize;

use crate::agent::context::ConversationContext;
use crate::agent::session::SessionStore;

use super::state::AppState;

pub fn router() -> Router<Arc<AppState>> {
    Router::new()
        .route("/api/sessions", get(list_sessions))
        .route(
            "/api/sessions/{id}",
            get(get_session).delete(delete_session),
        )
        .route("/api/sessions/{id}/resume", post(resume_session))
}

// ── List sessions ────────────────────────────────────────────────────────

#[derive(Serialize)]
struct SessionListItem {
    id: String,
    timestamp: String,
    completed: bool,
    task_preview: Option<String>,
}

async fn list_sessions(State(state): State<Arc<AppState>>) -> Json<Vec<SessionListItem>> {
    let working_dir = state.working_dir().await;
    let store = SessionStore::new(&working_dir);
    let sessions = store.list().await;

    let mut items = Vec::with_capacity(sessions.len());
    for (id, timestamp, completed) in sessions {
        let task_preview = store.load(&id).await.ok().and_then(|snap| {
            // Prefer explicit user_task; fall back to first user message content.
            snap.user_task.or_else(|| {
                snap.messages
                    .iter()
                    .find(|m| m.role == "user")
                    .and_then(|m| m.content.as_ref().map(|c| c.text_content()))
                    .map(|s| {
                        let preview: String = s.chars().take(80).collect();
                        if s.len() > 80 {
                            format!("{preview}")
                        } else {
                            preview
                        }
                    })
            })
        });

        items.push(SessionListItem {
            id,
            timestamp,
            completed,
            task_preview,
        });
    }

    Json(items)
}

// ── Get session detail ───────────────────────────────────────────────────

async fn get_session(
    State(state): State<Arc<AppState>>,
    Path(id): Path<String>,
) -> Result<Json<crate::agent::session::SessionSnapshot>, StatusCode> {
    let working_dir = state.working_dir().await;
    let store = SessionStore::new(&working_dir);
    store
        .load(&id)
        .await
        .map(Json)
        .map_err(|_| StatusCode::NOT_FOUND)
}

// ── Resume session ───────────────────────────────────────────────────────

#[derive(Serialize)]
struct ResumeResponse {
    resumed: bool,
    session_id: String,
    message_count: usize,
}

async fn resume_session(
    State(state): State<Arc<AppState>>,
    Path(id): Path<String>,
) -> Result<Json<ResumeResponse>, StatusCode> {
    if state.is_agent_active() {
        return Err(StatusCode::CONFLICT);
    }

    let working_dir = state.working_dir().await;
    let store = SessionStore::new(&working_dir);
    let snapshot = store.load(&id).await.map_err(|_| StatusCode::NOT_FOUND)?;

    let message_count = snapshot.messages.len();
    let ctx = ConversationContext::restore_with_budget(
        snapshot.system_prompt.clone(),
        snapshot.messages.clone(),
        snapshot.last_reasoning.clone(),
        state.config.context_max_tokens,
        state.config.compaction_threshold,
    );

    *state.context.lock().await = Some(ctx);

    Ok(Json(ResumeResponse {
        resumed: true,
        session_id: id,
        message_count,
    }))
}

// ── Delete session ───────────────────────────────────────────────────────

#[derive(Serialize)]
struct DeleteResponse {
    deleted: bool,
}

async fn delete_session(
    State(state): State<Arc<AppState>>,
    Path(id): Path<String>,
) -> Result<Json<DeleteResponse>, StatusCode> {
    let working_dir = state.working_dir().await;
    let session_dir = crate::config::project_data_dir(&working_dir).join("sessions");
    let path = session_dir.join(format!("{id}.json"));

    tokio::fs::remove_file(&path)
        .await
        .map(|_| Json(DeleteResponse { deleted: true }))
        .map_err(|_| StatusCode::NOT_FOUND)
}