use std::str::FromStr; use axum::extract::{Query, State}; use axum::http::StatusCode; use axum::Json; use cm_domain::{AgentId, MessageId, MessageRole, MessageWithSteps, Session, SessionKey}; use serde::Deserialize; use serde_json::{json, Value}; use uuid::Uuid; use crate::routes::claws::workspace_agent; use crate::{ApiError, AppState, Authed}; /// Builds the deep-linkable key for a session (§12). The message anchor is /// the latest message, or the nil uuid for a fresh session. fn session_key(session: &Session, last_message: Option) -> SessionKey { SessionKey { agent_id: session.agent_id, shard: session.shard, session_id: session.id, message_id: last_message.unwrap_or_else(|| MessageId::from(Uuid::nil())), } } fn with_key(session: Session, last_message: Option) -> Value { let key = session_key(&session, last_message).to_string(); let mut value = serde_json::to_value(&session).expect("session serializes"); value["sessionKey"] = json!(key); value } /// Resolves a sessionKey to its session, enforcing workspace and claw /// scoping. Used by history and the gateway. pub(crate) async fn scoped_session( state: &AppState, user: &cm_auth::AuthedUser, raw_key: &str, ) -> Result { let key = SessionKey::from_str(raw_key).map_err(|_| ApiError::NotFound)?; let session = cm_db::repo::sessions::get(&state.pool, key.session_id).await?; if session.agent_id != key.agent_id { return Err(ApiError::NotFound); } workspace_agent(state, user, session.agent_id).await?; Ok(session) } #[derive(Deserialize)] pub struct ListQuery { #[serde(rename = "clawId")] claw_id: AgentId, } /// GET /api/sessions?clawId= — sessions column data (§6). pub async fn list( State(state): State, Authed(user): Authed, Query(query): Query, ) -> Result>, ApiError> { workspace_agent(&state, &user, query.claw_id).await?; let sessions = cm_db::repo::sessions::list_by_agent(&state.pool, query.claw_id).await?; Ok(Json( sessions.into_iter().map(|s| with_key(s, None)).collect(), )) } #[derive(Deserialize)] pub struct CreateRequest { #[serde(rename = "clawId")] claw_id: AgentId, #[serde(default)] title: String, } /// POST /api/sessions — new resumable session (§6 "+ New session"). pub async fn create( State(state): State, Authed(user): Authed, Json(body): Json, ) -> Result<(StatusCode, Json), ApiError> { let agent = workspace_agent(&state, &user, body.claw_id).await?; let session = cm_db::repo::sessions::create(&state.pool, agent.id, agent.workspace_id, &body.title) .await?; Ok((StatusCode::CREATED, Json(with_key(session, None)))) } #[derive(Deserialize)] pub struct HistoryQuery { #[serde(rename = "sessionKey")] session_key: String, #[serde(default)] tools: bool, } /// GET /api/sessions/history?sessionKey=&tools=true — the durable /// transcript; identical data to what the gateway streamed (§13). pub async fn history( State(state): State, Authed(user): Authed, Query(query): Query, ) -> Result>, ApiError> { let session = scoped_session(&state, &user, &query.session_key).await?; let mut history = cm_db::repo::messages::history(&state.pool, session.id).await?; if !query.tools { for entry in &mut history { entry.steps.clear(); } } // The empty in-flight reply row is an implementation detail; history // consumers only see finalized or non-empty messages. history.retain(|entry| { entry.message.role != MessageRole::Agent || entry.message.content["text"].as_str() != Some("") || !entry.steps.is_empty() }); Ok(Json(history)) }