mod filesystem;
mod models;
use axum::extract::{Path as AxumPath, State};
use axum::routing::{get, post};
use axum::{Json, Router};
use serde::Deserialize;
use crate::error::WebError;
use crate::state::AppState;
pub use filesystem::{BrowseDirectoryRequest, ListFilesQuery, VerifyPathRequest};
pub use models::SessionModelUpdate;
#[derive(Debug, Deserialize)]
pub struct CreateSessionRequest {
#[serde(default)]
pub working_directory: Option<String>,
}
pub fn router() -> Router<AppState> {
Router::new()
.route("/api/sessions", get(list_sessions).post(create_session))
.route("/api/sessions/bridge-info", get(get_bridge_info))
.route("/api/sessions/files", get(filesystem::list_files))
.route("/api/sessions/verify-path", post(filesystem::verify_path))
.route(
"/api/sessions/browse-directory",
post(filesystem::browse_directory),
)
.route(
"/api/sessions/{id}",
get(get_session).delete(delete_session),
)
.route("/api/sessions/{id}/resume", post(resume_session))
.route("/api/sessions/{id}/messages", get(get_session_messages))
.route(
"/api/sessions/{id}/model",
get(models::get_session_model)
.put(models::update_session_model)
.delete(models::clear_session_model),
)
}
async fn list_sessions(State(state): State<AppState>) -> Result<Json<serde_json::Value>, WebError> {
let mgr = state.session_manager().await;
let index = mgr.index().read_index();
let sessions: Vec<serde_json::Value> = match index {
Some(idx) => idx
.entries
.iter()
.map(|entry| {
serde_json::json!({
"id": entry.session_id,
"created_at": entry.created,
"updated_at": entry.modified,
"message_count": entry.message_count,
"title": entry.title,
"working_directory": entry.working_directory,
})
})
.collect(),
None => Vec::new(),
};
Ok(Json(serde_json::json!(sessions)))
}
async fn create_session(
State(state): State<AppState>,
Json(payload): Json<CreateSessionRequest>,
) -> Result<Json<serde_json::Value>, WebError> {
let requested_wd = payload.working_directory.clone();
let mut mgr = state.session_manager_mut().await;
if let Some(ref wd) = requested_wd
&& let Some(index) = mgr.index().read_index()
{
let empty_match = index.entries.iter().find(|entry| {
entry.message_count == 0
&& entry
.working_directory
.as_deref()
.map(|d| d == wd.as_str())
.unwrap_or(false)
});
if let Some(entry) = empty_match {
let candidate_id = entry.session_id.clone();
let is_stale = mgr
.current_session()
.map(|s| s.id == candidate_id && !s.messages.is_empty())
.unwrap_or(false);
if !is_stale {
if mgr.resume_session(&candidate_id).is_ok() {
return Ok(Json(serde_json::json!({
"id": candidate_id,
"status": "reused",
"message": "Reusing existing empty session",
})));
}
}
}
}
let session = mgr.create_session();
let session_id = session.id.clone();
if let Some(wd) = requested_wd
&& let Some(session) = mgr.current_session_mut()
{
session.working_directory = Some(wd);
}
mgr.save_current()
.map_err(|e| WebError::Internal(format!("Failed to save session: {}", e)))?;
Ok(Json(serde_json::json!({
"id": session_id,
"status": "created",
})))
}
async fn get_session(
State(state): State<AppState>,
AxumPath(id): AxumPath<String>,
) -> Result<Json<serde_json::Value>, WebError> {
let mgr = state.session_manager().await;
let session = mgr
.load_session(&id)
.map_err(|e| WebError::NotFound(format!("Session {} not found: {}", id, e)))?;
Ok(Json(serde_json::to_value(session.get_metadata()).map_err(
|e| WebError::Internal(format!("Failed to serialize session: {}", e)),
)?))
}
async fn delete_session(
State(state): State<AppState>,
AxumPath(id): AxumPath<String>,
) -> Result<Json<serde_json::Value>, WebError> {
let mut mgr = state.session_manager_mut().await;
mgr.load_session(&id)
.map_err(|e| WebError::NotFound(format!("Session {} not found: {}", id, e)))?;
let session_dir = mgr.session_dir().to_path_buf();
let json_path = session_dir.join(format!("{}.json", id));
let jsonl_path = session_dir.join(format!("{}.jsonl", id));
let debug_path = session_dir.join(format!("{}.debug", id));
if json_path.exists() {
std::fs::remove_file(&json_path)
.map_err(|e| WebError::Internal(format!("Failed to delete session file: {}", e)))?;
}
if jsonl_path.exists() {
std::fs::remove_file(&jsonl_path).map_err(|e| {
WebError::Internal(format!("Failed to delete session transcript: {}", e))
})?;
}
if debug_path.exists() {
let _ = std::fs::remove_file(&debug_path);
}
mgr.index()
.remove_entry(&id)
.map_err(|e| WebError::Internal(format!("Failed to update index: {}", e)))?;
if mgr.current_session().map(|s| s.id == id).unwrap_or(false) {
mgr.set_current_session(opendev_models::Session::new());
}
Ok(Json(serde_json::json!({
"status": "success",
"message": format!("Session {} deleted", id),
})))
}
async fn resume_session(
State(state): State<AppState>,
AxumPath(id): AxumPath<String>,
) -> Result<Json<serde_json::Value>, WebError> {
let mut mgr = state.session_manager_mut().await;
mgr.resume_session(&id)
.map_err(|e| WebError::NotFound(format!("Session {} not found: {}", id, e)))?;
Ok(Json(serde_json::json!({
"status": "resumed",
"session_id": id,
})))
}
async fn get_session_messages(
State(state): State<AppState>,
AxumPath(id): AxumPath<String>,
) -> Result<Json<serde_json::Value>, WebError> {
let mgr = state.session_manager().await;
let session = mgr
.load_session(&id)
.map_err(|e| WebError::NotFound(format!("Session {} not found: {}", id, e)))?;
let messages: Vec<serde_json::Value> = session
.messages
.iter()
.map(|msg| {
let tool_calls: Vec<serde_json::Value> = msg
.tool_calls
.iter()
.map(|tc| {
let mut val = serde_json::json!({
"id": tc.id,
"name": tc.name,
"parameters": tc.parameters,
});
if let Some(ref result) = tc.result {
val["result"] = serde_json::json!(result);
}
if let Some(ref summary) = tc.result_summary {
val["result_summary"] = serde_json::json!(summary);
}
if let Some(ref error) = tc.error {
val["error"] = serde_json::json!(error);
}
if !tc.nested_tool_calls.is_empty() {
val["nested_tool_calls"] = serde_json::json!(tc.nested_tool_calls);
}
val
})
.collect();
let mut val = serde_json::json!({
"role": msg.role,
"content": msg.content,
"timestamp": msg.timestamp,
});
if !tool_calls.is_empty() {
val["tool_calls"] = serde_json::json!(tool_calls);
}
if let Some(ref reasoning) = msg.reasoning_content {
val["reasoning_content"] = serde_json::json!(reasoning);
}
if let Some(ref trace) = msg.thinking_trace {
val["thinking_trace"] = serde_json::json!(trace);
}
val
})
.collect();
Ok(Json(serde_json::json!(messages)))
}
async fn get_bridge_info(State(_state): State<AppState>) -> Json<serde_json::Value> {
Json(serde_json::json!({
"bridge_mode": false,
"session_id": null,
}))
}
#[cfg(test)]
mod tests;