Skip to main content

embacle_server/uhp/
sessions.rs

1// ABOUTME: Session listing, inspection, turn history and deletion over the UHP contract
2// ABOUTME: Pagination reports its end explicitly rather than leaving a client to infer it
3//
4// SPDX-License-Identifier: Apache-2.0
5// Copyright (c) 2026 dravr.ai
6
7//! Sessions.
8//!
9//! A session is the thread a task continues. The store already records which
10//! responses belong to which session, so these endpoints read it back rather
11//! than keeping a second account of the same thing.
12//!
13//! The listing reports the end of pagination **explicitly**, with `has_more`
14//! and a `next_cursor`. Leaving a client to infer the end from a short page is
15//! a heuristic that is wrong exactly when a page comes back full.
16
17use 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
25/// How many sessions a listing returns when the client names no limit.
26const DEFAULT_LIMIT: usize = 20;
27
28/// Paging controls on a session listing.
29#[derive(Debug, Clone, Deserialize)]
30pub struct Paging {
31    /// Maximum sessions to return.
32    #[serde(default)]
33    pub limit: Option<usize>,
34    /// Opaque marker naming where the previous page stopped.
35    #[serde(default)]
36    pub cursor: Option<String>,
37}
38
39/// One session, as the listing describes it.
40#[derive(Debug, Clone, Serialize)]
41pub struct Session {
42    /// `sess_`-prefixed id.
43    pub id: String,
44    /// Always `session`.
45    pub object: &'static str,
46    /// How many turns it holds.
47    pub turn_count: usize,
48    /// When its first turn was created, in Unix seconds.
49    pub created_at: u64,
50}
51
52/// A page of sessions.
53#[derive(Debug, Clone, Serialize)]
54pub struct SessionList {
55    /// This page.
56    pub sessions: Vec<Session>,
57    /// Whether another page follows.
58    pub has_more: bool,
59    /// Where to resume, or `null` at the end.
60    pub next_cursor: Option<String>,
61}
62
63/// One turn of a session.
64#[derive(Debug, Clone, Serialize)]
65pub struct Turn {
66    /// The response id of this turn.
67    pub id: String,
68    /// Its terminal status.
69    pub status: String,
70    /// When it ran, in Unix seconds.
71    pub created_at: u64,
72    /// The model that answered.
73    pub model: String,
74}
75
76/// A session's turns, oldest first.
77#[derive(Debug, Clone, Serialize)]
78pub struct TurnList {
79    /// The session these belong to.
80    pub session_id: String,
81    /// Its turns.
82    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
96/// Build one session summary from its turns.
97fn 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
106/// Handle `GET /v1/sessions`.
107pub 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    // The cursor is the last id of the previous page, so resuming is a scan to
117    // just past it. Opaque to the client, which is all the contract requires.
118    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
143/// Handle `GET /v1/sessions/{session_id}`.
144///
145/// # Errors
146///
147/// Answers `404 session_not_found` when this server holds no such session.
148pub 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
158/// Handle `GET /v1/sessions/{session_id}/turns`.
159///
160/// # Errors
161///
162/// Answers `404 session_not_found` when this server holds no such session.
163pub 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
176/// Handle `DELETE /v1/sessions/{session_id}`, and its legacy alias.
177///
178/// `DELETE /v1/traces/{session_id}` is the older path for the same operation,
179/// which the specification says a server MAY keep serving as the same handler.
180/// It is routed here rather than reimplemented, so the two cannot diverge.
181///
182/// # Errors
183///
184/// Answers `404 session_not_found` when this server holds no such session.
185pub async fn delete(
186    State(state): State<UhpState>,
187    Path(id): Path<String>,
188) -> Result<Json<Deleted>, UhpFailure> {
189    // Cancel first, delete second. Deletion is the one place the specification
190    // couples the two deliberately: the alternative is a running task writing
191    // into storage that no longer has an owner.
192    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    // A shared view of a deleted session would outlive the thing it shows, so
203    // deleting the session takes its link with it.
204    state.shares.revoke(&id);
205    // ยง6: deletion removes the working folder the session's tasks wrote, so an
206    // artifact cannot outlive the session that produced it.
207    super::files::remove_workdir(&id);
208    Ok(Json(Deleted {
209        id,
210        object: "session.deleted",
211        deleted: true,
212    }))
213}
214
215/// What a delete answers with.
216#[derive(Debug, Clone, Serialize)]
217pub struct Deleted {
218    /// The id that was removed.
219    pub id: String,
220    /// The kind of thing removed.
221    pub object: &'static str,
222    /// Always true; a failure is an error envelope instead.
223    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}