Skip to main content

opendev_web/routes/sessions/
mod.rs

1//! Session management routes.
2
3mod filesystem;
4mod models;
5
6use axum::extract::{Path as AxumPath, State};
7use axum::routing::{get, post};
8use axum::{Json, Router};
9use serde::Deserialize;
10
11use crate::error::WebError;
12use crate::state::AppState;
13
14pub use filesystem::{BrowseDirectoryRequest, ListFilesQuery, VerifyPathRequest};
15pub use models::SessionModelUpdate;
16
17/// Create session request.
18#[derive(Debug, Deserialize)]
19pub struct CreateSessionRequest {
20    #[serde(default)]
21    pub working_directory: Option<String>,
22}
23
24/// Build the sessions router.
25pub fn router() -> Router<AppState> {
26    Router::new()
27        .route("/api/sessions", get(list_sessions).post(create_session))
28        .route("/api/sessions/bridge-info", get(get_bridge_info))
29        .route("/api/sessions/files", get(filesystem::list_files))
30        .route("/api/sessions/verify-path", post(filesystem::verify_path))
31        .route(
32            "/api/sessions/browse-directory",
33            post(filesystem::browse_directory),
34        )
35        .route(
36            "/api/sessions/{id}",
37            get(get_session).delete(delete_session),
38        )
39        .route("/api/sessions/{id}/resume", post(resume_session))
40        .route("/api/sessions/{id}/messages", get(get_session_messages))
41        .route(
42            "/api/sessions/{id}/model",
43            get(models::get_session_model)
44                .put(models::update_session_model)
45                .delete(models::clear_session_model),
46        )
47}
48
49/// List all sessions.
50async fn list_sessions(State(state): State<AppState>) -> Result<Json<serde_json::Value>, WebError> {
51    let mgr = state.session_manager().await;
52    let index = mgr.index().read_index();
53
54    let sessions: Vec<serde_json::Value> = match index {
55        Some(idx) => idx
56            .entries
57            .iter()
58            .map(|entry| {
59                serde_json::json!({
60                    "id": entry.session_id,
61                    "created_at": entry.created,
62                    "updated_at": entry.modified,
63                    "message_count": entry.message_count,
64                    "title": entry.title,
65                    "working_directory": entry.working_directory,
66                })
67            })
68            .collect(),
69        None => Vec::new(),
70    };
71
72    Ok(Json(serde_json::json!(sessions)))
73}
74
75/// Create a new session.
76///
77/// Before creating a brand-new session, checks if there is an existing empty
78/// session (message_count == 0) for the same workspace. If found and not stale,
79/// the existing session is reused instead of creating a new one.
80async fn create_session(
81    State(state): State<AppState>,
82    Json(payload): Json<CreateSessionRequest>,
83) -> Result<Json<serde_json::Value>, WebError> {
84    let requested_wd = payload.working_directory.clone();
85
86    let mut mgr = state.session_manager_mut().await;
87
88    // Try to reuse an existing empty session for the same workspace.
89    if let Some(ref wd) = requested_wd
90        && let Some(index) = mgr.index().read_index()
91    {
92        let empty_match = index.entries.iter().find(|entry| {
93            entry.message_count == 0
94                && entry
95                    .working_directory
96                    .as_deref()
97                    .map(|d| d == wd.as_str())
98                    .unwrap_or(false)
99        });
100
101        if let Some(entry) = empty_match {
102            let candidate_id = entry.session_id.clone();
103
104            // Guard against stale index: if the candidate is already the
105            // current session with in-memory messages, skip reuse.
106            let is_stale = mgr
107                .current_session()
108                .map(|s| s.id == candidate_id && !s.messages.is_empty())
109                .unwrap_or(false);
110
111            if !is_stale {
112                // Try to load and resume the candidate session.
113                if mgr.resume_session(&candidate_id).is_ok() {
114                    return Ok(Json(serde_json::json!({
115                        "id": candidate_id,
116                        "status": "reused",
117                        "message": "Reusing existing empty session",
118                    })));
119                }
120                // If load fails (e.g. file deleted), fall through to create new.
121            }
122        }
123    }
124
125    // No reusable session found — create a new one.
126    let session = mgr.create_session();
127    let session_id = session.id.clone();
128
129    // Set working directory if provided.
130    if let Some(wd) = requested_wd
131        && let Some(session) = mgr.current_session_mut()
132    {
133        session.working_directory = Some(wd);
134    }
135
136    // Save the new session.
137    mgr.save_current()
138        .map_err(|e| WebError::Internal(format!("Failed to save session: {}", e)))?;
139
140    Ok(Json(serde_json::json!({
141        "id": session_id,
142        "status": "created",
143    })))
144}
145
146/// Get a specific session.
147async fn get_session(
148    State(state): State<AppState>,
149    AxumPath(id): AxumPath<String>,
150) -> Result<Json<serde_json::Value>, WebError> {
151    let mgr = state.session_manager().await;
152    let session = mgr
153        .load_session(&id)
154        .map_err(|e| WebError::NotFound(format!("Session {} not found: {}", id, e)))?;
155
156    Ok(Json(serde_json::to_value(session.get_metadata()).map_err(
157        |e| WebError::Internal(format!("Failed to serialize session: {}", e)),
158    )?))
159}
160
161/// Delete a specific session.
162async fn delete_session(
163    State(state): State<AppState>,
164    AxumPath(id): AxumPath<String>,
165) -> Result<Json<serde_json::Value>, WebError> {
166    let mut mgr = state.session_manager_mut().await;
167
168    // Check session exists.
169    mgr.load_session(&id)
170        .map_err(|e| WebError::NotFound(format!("Session {} not found: {}", id, e)))?;
171
172    // Delete session files (.json, .jsonl).
173    let session_dir = mgr.session_dir().to_path_buf();
174    let json_path = session_dir.join(format!("{}.json", id));
175    let jsonl_path = session_dir.join(format!("{}.jsonl", id));
176    let debug_path = session_dir.join(format!("{}.debug", id));
177
178    if json_path.exists() {
179        std::fs::remove_file(&json_path)
180            .map_err(|e| WebError::Internal(format!("Failed to delete session file: {}", e)))?;
181    }
182    if jsonl_path.exists() {
183        std::fs::remove_file(&jsonl_path).map_err(|e| {
184            WebError::Internal(format!("Failed to delete session transcript: {}", e))
185        })?;
186    }
187    if debug_path.exists() {
188        let _ = std::fs::remove_file(&debug_path);
189    }
190
191    // Remove from index.
192    mgr.index()
193        .remove_entry(&id)
194        .map_err(|e| WebError::Internal(format!("Failed to update index: {}", e)))?;
195
196    // Clear current session if it was the deleted one.
197    if mgr.current_session().map(|s| s.id == id).unwrap_or(false) {
198        mgr.set_current_session(opendev_models::Session::new());
199    }
200
201    Ok(Json(serde_json::json!({
202        "status": "success",
203        "message": format!("Session {} deleted", id),
204    })))
205}
206
207/// Resume a session.
208async fn resume_session(
209    State(state): State<AppState>,
210    AxumPath(id): AxumPath<String>,
211) -> Result<Json<serde_json::Value>, WebError> {
212    let mut mgr = state.session_manager_mut().await;
213    mgr.resume_session(&id)
214        .map_err(|e| WebError::NotFound(format!("Session {} not found: {}", id, e)))?;
215
216    Ok(Json(serde_json::json!({
217        "status": "resumed",
218        "session_id": id,
219    })))
220}
221
222/// Get messages for a session.
223async fn get_session_messages(
224    State(state): State<AppState>,
225    AxumPath(id): AxumPath<String>,
226) -> Result<Json<serde_json::Value>, WebError> {
227    let mgr = state.session_manager().await;
228    let session = mgr
229        .load_session(&id)
230        .map_err(|e| WebError::NotFound(format!("Session {} not found: {}", id, e)))?;
231
232    let messages: Vec<serde_json::Value> = session
233        .messages
234        .iter()
235        .map(|msg| {
236            let tool_calls: Vec<serde_json::Value> = msg
237                .tool_calls
238                .iter()
239                .map(|tc| {
240                    let mut val = serde_json::json!({
241                        "id": tc.id,
242                        "name": tc.name,
243                        "parameters": tc.parameters,
244                    });
245                    if let Some(ref result) = tc.result {
246                        val["result"] = serde_json::json!(result);
247                    }
248                    if let Some(ref summary) = tc.result_summary {
249                        val["result_summary"] = serde_json::json!(summary);
250                    }
251                    if let Some(ref error) = tc.error {
252                        val["error"] = serde_json::json!(error);
253                    }
254                    if !tc.nested_tool_calls.is_empty() {
255                        val["nested_tool_calls"] = serde_json::json!(tc.nested_tool_calls);
256                    }
257                    val
258                })
259                .collect();
260
261            let mut val = serde_json::json!({
262                "role": msg.role,
263                "content": msg.content,
264                "timestamp": msg.timestamp,
265            });
266            if !tool_calls.is_empty() {
267                val["tool_calls"] = serde_json::json!(tool_calls);
268            }
269            if let Some(ref reasoning) = msg.reasoning_content {
270                val["reasoning_content"] = serde_json::json!(reasoning);
271            }
272            if let Some(ref trace) = msg.thinking_trace {
273                val["thinking_trace"] = serde_json::json!(trace);
274            }
275            val
276        })
277        .collect();
278
279    Ok(Json(serde_json::json!(messages)))
280}
281
282/// Get bridge mode status.
283async fn get_bridge_info(State(_state): State<AppState>) -> Json<serde_json::Value> {
284    // Bridge mode is not yet implemented in the Rust port.
285    // Return a default non-bridge response.
286    Json(serde_json::json!({
287        "bridge_mode": false,
288        "session_id": null,
289    }))
290}
291
292#[cfg(test)]
293mod tests;