1use axum::extract::{Path, Query, State};
18use axum::Json;
19use serde::{Deserialize, Serialize};
20
21use super::error::UhpFailure;
22use super::state::UhpState;
23use super::tasks::Response;
24
25const DEFAULT_LIMIT: usize = 20;
27
28#[derive(Debug, Clone, Deserialize)]
30pub struct Paging {
31 #[serde(default)]
33 pub limit: Option<usize>,
34 #[serde(default)]
36 pub cursor: Option<String>,
37}
38
39#[derive(Debug, Clone, Serialize)]
41pub struct Session {
42 pub id: String,
44 pub object: &'static str,
46 pub turn_count: usize,
48 pub created_at: u64,
50}
51
52#[derive(Debug, Clone, Serialize)]
54pub struct SessionList {
55 pub sessions: Vec<Session>,
57 pub has_more: bool,
59 pub next_cursor: Option<String>,
61}
62
63#[derive(Debug, Clone, Serialize)]
65pub struct Turn {
66 pub id: String,
68 pub status: String,
70 pub created_at: u64,
72 pub model: String,
74}
75
76#[derive(Debug, Clone, Serialize)]
78pub struct TurnList {
79 pub session_id: String,
81 pub turns: Vec<Turn>,
83}
84
85impl From<&Response> for Turn {
86 fn from(r: &Response) -> Self {
87 Self {
88 id: r.id.clone(),
89 status: r.status.to_owned(),
90 created_at: r.created_at,
91 model: r.model.clone(),
92 }
93 }
94}
95
96fn summarise(id: String, turns: &[Response]) -> Session {
98 Session {
99 object: "session",
100 turn_count: turns.len(),
101 created_at: turns.first().map_or(0, |t| t.created_at),
102 id,
103 }
104}
105
106pub async fn list(
108 State(state): State<UhpState>,
109 Query(paging): Query<Paging>,
110) -> Json<SessionList> {
111 let limit = paging.limit.unwrap_or(DEFAULT_LIMIT).max(1);
112
113 let mut ids = state.store.session_ids();
114 ids.sort();
115
116 let start = paging
119 .cursor
120 .as_ref()
121 .map_or(0, |c| ids.iter().position(|i| i == c).map_or(0, |p| p + 1));
122
123 let page: Vec<Session> = ids
124 .iter()
125 .skip(start)
126 .take(limit)
127 .map(|id| {
128 let turns = state.store.session_turns(id).unwrap_or_default();
129 summarise(id.clone(), &turns)
130 })
131 .collect();
132
133 let has_more = start + page.len() < ids.len();
134 Json(SessionList {
135 next_cursor: has_more
136 .then(|| page.last().map(|s| s.id.clone()))
137 .flatten(),
138 has_more,
139 sessions: page,
140 })
141}
142
143pub async fn get(
149 State(state): State<UhpState>,
150 Path(id): Path<String>,
151) -> Result<Json<Session>, UhpFailure> {
152 let turns = state.store.session_turns(&id).ok_or_else(|| {
153 UhpFailure::not_found("session_not_found", "no session with that id exists")
154 })?;
155 Ok(Json(summarise(id, &turns)))
156}
157
158pub async fn turns(
164 State(state): State<UhpState>,
165 Path(id): Path<String>,
166) -> Result<Json<TurnList>, UhpFailure> {
167 let responses = state.store.session_turns(&id).ok_or_else(|| {
168 UhpFailure::not_found("session_not_found", "no session with that id exists")
169 })?;
170 Ok(Json(TurnList {
171 session_id: id,
172 turns: responses.iter().map(Turn::from).collect(),
173 }))
174}
175
176pub async fn delete(
186 State(state): State<UhpState>,
187 Path(id): Path<String>,
188) -> Result<Json<Deleted>, UhpFailure> {
189 for running in state.store.running_in(&id) {
193 state.store.cancel(&running);
194 }
195
196 if !state.store.delete_session(&id) {
197 return Err(UhpFailure::not_found(
198 "session_not_found",
199 "no session with that id exists",
200 ));
201 }
202 state.shares.revoke(&id);
205 super::files::remove_workdir(&id);
208 Ok(Json(Deleted {
209 id,
210 object: "session.deleted",
211 deleted: true,
212 }))
213}
214
215#[derive(Debug, Clone, Serialize)]
217pub struct Deleted {
218 pub id: String,
220 pub object: &'static str,
222 pub deleted: bool,
224}
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229 use serde_json::Map;
230
231 fn response(id: &str, created_at: u64) -> Response {
232 Response {
233 id: id.to_owned(),
234 object: "response",
235 created_at,
236 status: "completed",
237 error: None,
238 previous_response_id: None,
239 model: "m".to_owned(),
240 output: Vec::new(),
241 store: true,
242 usage: None,
243 metadata: Map::new(),
244 }
245 }
246
247 #[test]
248 fn a_session_summarises_its_turns_oldest_first() {
249 let turns = [response("resp_a", 10), response("resp_b", 20)];
250 let s = summarise("sess_1".to_owned(), &turns);
251 assert_eq!(s.turn_count, 2);
252 assert_eq!(
253 s.created_at, 10,
254 "a session is as old as its first turn, not its last"
255 );
256 }
257
258 #[test]
259 fn an_empty_session_still_summarises() {
260 let s = summarise("sess_empty".to_owned(), &[]);
261 assert_eq!(s.turn_count, 0);
262 assert_eq!(s.created_at, 0);
263 }
264
265 #[test]
266 fn a_turn_carries_the_shape_the_contract_names() {
267 let t = Turn::from(&response("resp_a", 7));
268 assert_eq!(t.id, "resp_a");
269 assert_eq!(t.status, "completed", "at least id and status are required");
270 assert_eq!(t.created_at, 7);
271 }
272}