use std::convert::Infallible;
use std::time::Duration;
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::response::sse::{Event as SseEvent, KeepAlive, KeepAliveStream, Sse};
use axum::Json;
use serde::{Deserialize, Serialize};
use tokio_stream::wrappers::ReceiverStream;
use crate::lfd::http::dto::{format_datetime, ErrorResponse};
use crate::lfd::http::routes::ApiError;
use crate::lfd::http::state::HttpState;
use crate::lfd::http::{api_error, map_store_error, ApiMessage, ApiResult};
use crate::lfd::id::LfdId;
use crate::lfd::sessions::harness::HarnessKind;
use crate::lfd::sessions::types::{
CreateSessionParams, PersistedSessionEvent, Session, SessionConfig,
};
use crate::lfd::sessions::{SessionManager, SessionManagerError};
#[derive(Debug, Deserialize)]
pub struct CreateSessionRequest {
pub harness: String,
#[serde(default)]
pub wave_run_id: Option<String>,
#[serde(flatten)]
pub config: SessionConfig,
}
#[derive(Debug, Deserialize)]
pub struct SessionInputRequest {
pub text: String,
}
#[derive(Debug, Deserialize)]
pub struct SessionEventsQuery {
pub after_seq: Option<i64>,
}
#[derive(Debug, Serialize)]
pub struct SessionDto {
pub id: String,
pub object: String,
pub harness: String,
pub status: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub wave_run_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub provider_session_id: Option<String>,
pub config: SessionConfig,
pub input_supported: bool,
pub created_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ended_at: Option<String>,
}
pub async fn create_session_handler(
State(state): State<HttpState>,
Json(payload): Json<CreateSessionRequest>,
) -> ApiResult<SessionDto> {
let session = state
.sessions
.create_session(CreateSessionParams {
harness: payload.harness,
wave_run_id: payload.wave_run_id,
config: payload.config,
})
.await
.map_err(map_session_error)?;
Ok(Json(session_dto(session)))
}
pub async fn get_session_handler(
State(state): State<HttpState>,
Path(session_id): Path<String>,
) -> ApiResult<SessionDto> {
let session_id = parse_session_id(&session_id)?;
let session = state
.sessions
.get_session(&session_id)
.await
.map_err(map_session_error)?;
Ok(Json(session_dto(session)))
}
pub async fn send_session_input_handler(
State(state): State<HttpState>,
Path(session_id): Path<String>,
Json(payload): Json<SessionInputRequest>,
) -> ApiResult<SessionDto> {
let session_id = parse_session_id(&session_id)?;
if payload.text.trim().is_empty() {
return Err(api_error(StatusCode::BAD_REQUEST, "text cannot be empty"));
}
state
.sessions
.send_input(&session_id, &payload.text)
.await
.map_err(map_session_error)?;
let session = state
.sessions
.get_session(&session_id)
.await
.map_err(map_session_error)?;
Ok(Json(session_dto(session)))
}
pub async fn stream_session_events_handler(
State(state): State<HttpState>,
Path(session_id): Path<String>,
Query(query): Query<SessionEventsQuery>,
) -> Result<Sse<KeepAliveStream<ReceiverStream<Result<SseEvent, Infallible>>>>, ApiError> {
let session_id = parse_session_id(&session_id)?;
let live_rx = state
.sessions
.subscribe(&session_id)
.await
.map_err(map_session_error)?;
let replay = state
.sessions
.list_events(&session_id, query.after_seq)
.await
.map_err(map_session_error)?;
let sessions = state.sessions.clone();
let mut last_seq = query.after_seq.unwrap_or(-1);
if let Some(last) = replay.last() {
last_seq = last.seq;
}
let (tx, rx) = tokio::sync::mpsc::channel::<Result<SseEvent, Infallible>>(256);
tokio::spawn(async move {
for event in replay {
if tx.send(Ok(session_event_sse(&event))).await.is_err() {
return;
}
}
let sentinel = SseEvent::default()
.event("session.replay_completed")
.data(serde_json::json!({ "last_seq": last_seq }).to_string());
if tx.send(Ok(sentinel)).await.is_err() {
return;
}
let Some(mut live_rx) = live_rx else {
return;
};
loop {
match live_rx.recv().await {
Ok(event) => {
if event.seq <= last_seq {
continue;
}
last_seq = event.seq;
if tx.send(Ok(session_event_sse(&event))).await.is_err() {
return;
}
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
if !backfill_lagged_events(&sessions, &session_id, &mut last_seq, &tx).await {
return;
}
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => return,
}
}
});
Ok(Sse::new(ReceiverStream::new(rx)).keep_alive(
KeepAlive::new()
.interval(Duration::from_secs(15))
.text("keep-alive"),
))
}
pub async fn delete_session_handler(
State(state): State<HttpState>,
Path(session_id): Path<String>,
) -> ApiResult<SessionDto> {
let session_id = parse_session_id(&session_id)?;
let session = state
.sessions
.stop_session(&session_id)
.await
.map_err(map_session_error)?;
Ok(Json(session_dto(session)))
}
fn session_dto(session: Session) -> SessionDto {
let input_supported = HarnessKind::parse(&session.harness)
.map(HarnessKind::input_supported)
.unwrap_or(false);
SessionDto {
id: session.id.to_string(),
object: "session".to_string(),
harness: session.harness,
status: session.status.as_str().to_string(),
wave_run_id: session.wave_run_id,
provider_session_id: session.provider_session_id,
input_supported,
config: session.config,
created_at: format_datetime(Some(session.created_at)),
ended_at: format_datetime(session.ended_at),
}
}
fn session_event_sse(event: &PersistedSessionEvent) -> SseEvent {
let data = serde_json::to_string(&event.event).unwrap_or_else(|err| {
serde_json::json!({
"type": "error",
"code": "serialization_error",
"message": err.to_string(),
})
.to_string()
});
SseEvent::default()
.id(event.seq.to_string())
.event("session.event")
.data(data)
}
async fn backfill_lagged_events(
sessions: &SessionManager,
session_id: &LfdId,
last_seq: &mut i64,
tx: &tokio::sync::mpsc::Sender<Result<SseEvent, Infallible>>,
) -> bool {
let missed = match sessions.list_events(session_id, Some(*last_seq)).await {
Ok(events) => events,
Err(err) => {
tracing::warn!(
session_id = %session_id,
error = %err,
"failed to backfill lagged session events"
);
return true;
}
};
for event in missed {
if event.seq <= *last_seq {
continue;
}
*last_seq = event.seq;
if tx.send(Ok(session_event_sse(&event))).await.is_err() {
return false;
}
}
true
}
fn parse_session_id(value: &str) -> Result<LfdId, ApiError> {
super::parse_lfd_id(value, "invalid session id")
}
fn map_session_error(err: SessionManagerError) -> (StatusCode, Json<ErrorResponse>) {
match err {
SessionManagerError::Store(err) => map_store_error(err),
SessionManagerError::NotFound => api_error(StatusCode::NOT_FOUND, "session not found"),
SessionManagerError::InvalidState { expected, actual } => api_error(
StatusCode::CONFLICT,
ApiMessage::Safe(format!(
"invalid session state: expected {expected}, got {actual:?}"
)),
),
SessionManagerError::UnsupportedHarness(name) => api_error(
StatusCode::BAD_REQUEST,
ApiMessage::Safe(format!("unsupported harness: {name}")),
),
SessionManagerError::HarnessNotImplemented(name) => api_error(
StatusCode::NOT_IMPLEMENTED,
ApiMessage::Safe(format!("harness not implemented yet: {name}")),
),
SessionManagerError::WaveRunSessionConflict(wave_run_id) => api_error(
StatusCode::CONFLICT,
ApiMessage::Safe(format!(
"wave run already has an active session: {wave_run_id}"
)),
),
SessionManagerError::InvalidConfig(message) => {
api_error(StatusCode::BAD_REQUEST, ApiMessage::Safe(message))
}
SessionManagerError::InvalidRepoRoot(message) => api_error(
StatusCode::BAD_REQUEST,
ApiMessage::Safe(format!("invalid repo_root: {message}")),
),
SessionManagerError::TurnAlreadyInProgress => {
api_error(StatusCode::CONFLICT, "turn already in progress")
}
SessionManagerError::InputNotSupported(harness) => api_error(
StatusCode::BAD_REQUEST,
ApiMessage::Safe(format!("input not supported for this harness: {harness}")),
),
SessionManagerError::Harness(message) => api_error(
StatusCode::INTERNAL_SERVER_ERROR,
ApiMessage::Untrusted(message),
),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lfd::http::routes::test_helpers::test_http_state;
use crate::lfd::id::LfdId;
use crate::lfd::sessions::types::SessionStatus;
use crate::lfd::store::{open_store, StorageConfig};
use axum::extract::{Path, State};
use tempfile::tempdir;
#[tokio::test]
async fn get_session_handler_returns_dto() {
let state = test_http_state().await;
let session_id = LfdId::new();
let session = Session {
id: session_id.clone(),
harness: "claude".to_string(),
status: SessionStatus::Active,
wave_run_id: None,
provider_session_id: None,
config: SessionConfig {
step: "design".to_string(),
repo_root: "/tmp/repo".to_string(),
..Default::default()
},
created_at: time::OffsetDateTime::now_utc(),
ended_at: None,
};
state
.store
.create_session(&session)
.await
.expect("seed session");
let Json(dto) = get_session_handler(State(state), Path(session_id.to_string()))
.await
.expect("get session");
assert_eq!(dto.id, session_id.to_string());
assert_eq!(dto.object, "session");
assert_eq!(dto.harness, "claude");
assert_eq!(dto.status, "active");
assert!(!dto.input_supported);
assert_eq!(dto.config.step, "design");
}
#[tokio::test]
async fn codex_session_dto_supports_input() {
let state = test_http_state().await;
let session_id = LfdId::new();
let session = Session {
id: session_id.clone(),
harness: "codex".to_string(),
status: SessionStatus::Active,
wave_run_id: None,
provider_session_id: None,
config: SessionConfig {
step: "design".to_string(),
repo_root: "/tmp/repo".to_string(),
..Default::default()
},
created_at: time::OffsetDateTime::now_utc(),
ended_at: None,
};
state
.store
.create_session(&session)
.await
.expect("seed session");
let Json(dto) = get_session_handler(State(state), Path(session_id.to_string()))
.await
.expect("get session");
assert!(dto.input_supported);
}
#[tokio::test]
async fn send_session_input_rejects_unsupported_harness() {
let state = test_http_state().await;
let session_id = LfdId::new();
let session = Session {
id: session_id.clone(),
harness: "claude".to_string(),
status: SessionStatus::Active,
wave_run_id: None,
provider_session_id: None,
config: SessionConfig {
step: "design".to_string(),
repo_root: "/tmp/repo".to_string(),
..Default::default()
},
created_at: time::OffsetDateTime::now_utc(),
ended_at: None,
};
state
.store
.create_session(&session)
.await
.expect("seed session");
let result = send_session_input_handler(
State(state),
Path(session_id.to_string()),
Json(SessionInputRequest {
text: "hello".to_string(),
}),
)
.await;
let Err((status, Json(error))) = result else {
panic!("unsupported input should fail");
};
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(error.error.message.contains("input not supported"));
}
#[tokio::test]
async fn get_session_handler_not_found() {
let state = test_http_state().await;
let fake_id = LfdId::new().to_string();
let result = get_session_handler(State(state), Path(fake_id)).await;
assert!(matches!(result, Err((StatusCode::NOT_FOUND, _))));
}
#[tokio::test]
async fn delete_session_handler_terminal_is_idempotent() {
let state = test_http_state().await;
let session_id = LfdId::new();
let session = Session {
id: session_id.clone(),
harness: "claude".to_string(),
status: SessionStatus::Ended,
wave_run_id: None,
provider_session_id: None,
config: SessionConfig {
step: "implement".to_string(),
repo_root: "/tmp/repo".to_string(),
..Default::default()
},
created_at: time::OffsetDateTime::now_utc(),
ended_at: Some(time::OffsetDateTime::now_utc()),
};
state
.store
.create_session(&session)
.await
.expect("seed session");
let Json(dto) = delete_session_handler(State(state), Path(session_id.to_string()))
.await
.expect("delete ended session");
assert_eq!(dto.id, session_id.to_string());
assert_eq!(dto.status, "ended");
}
#[tokio::test]
async fn backfill_lagged_events_replays_from_store_after_last_seq() {
let tmp = tempdir().expect("tempdir");
let db_path = tmp.path().join("lfd.db");
let store = std::sync::Arc::new(
open_store(&StorageConfig::sqlite(db_path))
.await
.expect("open sqlite store"),
);
let session_id = LfdId::new();
let session = Session {
id: session_id.clone(),
harness: "claude".to_string(),
status: crate::lfd::sessions::types::SessionStatus::Active,
wave_run_id: None,
provider_session_id: None,
config: SessionConfig {
step: "design".to_string(),
repo_root: tmp.path().to_string_lossy().to_string(),
..Default::default()
},
created_at: time::OffsetDateTime::now_utc(),
ended_at: None,
};
store
.create_session(&session)
.await
.expect("create session");
store
.append_session_event(
&session_id,
0,
&crate::lfd::sessions::types::SessionEvent::StatusChanged {
status: crate::lfd::sessions::types::SessionStatus::Active,
},
time::OffsetDateTime::now_utc().unix_timestamp(),
)
.await
.expect("append status event");
store
.append_session_event(
&session_id,
1,
&crate::lfd::sessions::types::SessionEvent::TextDelta {
turn_id: "turn_1".to_string(),
content: "hello".to_string(),
},
time::OffsetDateTime::now_utc().unix_timestamp(),
)
.await
.expect("append delta event");
store
.append_session_event(
&session_id,
2,
&crate::lfd::sessions::types::SessionEvent::TurnCompleted {
turn_id: "turn_1".to_string(),
status: crate::lfd::sessions::types::TurnStatus::Completed,
},
time::OffsetDateTime::now_utc().unix_timestamp(),
)
.await
.expect("append completion event");
let sessions = SessionManager::new(store);
let (tx, mut rx) = tokio::sync::mpsc::channel(8);
let mut last_seq = 1;
let keep_streaming =
backfill_lagged_events(&sessions, &session_id, &mut last_seq, &tx).await;
assert!(keep_streaming);
assert_eq!(last_seq, 2);
let first = rx.recv().await.expect("first backfilled event");
assert!(first.is_ok());
assert!(rx.try_recv().is_err());
}
}