lazyBoy/crates/api/src/sessions.rs

678 lines
21 KiB
Rust
Raw Normal View History

2026-09-03 23:43:37 +00:00
use std::convert::Infallible;
use std::time::Duration;
use axum::extract::{Path, State};
use axum::http::{HeaderMap, StatusCode};
use axum::response::sse::{Event, KeepAlive, Sse};
2026-09-04 05:41:09 +00:00
use axum::routing::{get, post};
2026-09-03 23:43:37 +00:00
use axum::{Json, Router};
use futures_util::stream;
use lazyboy_contracts::{
CreateSessionInput, SendSessionMessageInput, Session, SessionMessage, UpdateSessionInput,
};
2026-09-04 09:08:56 +00:00
use serde_json::{Value, json};
2026-09-03 23:43:37 +00:00
use uuid::Uuid;
use crate::db::Actor;
use crate::state::AppState;
type ApiError = (StatusCode, Json<Value>);
2026-09-07 13:08:10 +00:00
/// Safety net for the event stream. A wake normally lands within milliseconds,
/// so this poll only exists for the cases a wake cannot cover: a reader that
/// lagged behind the channel, or a row written outside this process.
const EVENT_FALLBACK_POLL: Duration = Duration::from_secs(5);
2026-09-03 23:43:37 +00:00
pub fn router() -> Router<AppState> {
Router::new()
2026-09-04 09:08:56 +00:00
.route(
"/api/bots/{id}/sessions",
get(list_sessions).post(create_session),
)
2026-09-03 23:43:37 +00:00
.route(
"/api/sessions/{id}",
2026-09-04 09:08:56 +00:00
get(get_session)
.patch(update_session)
.delete(delete_session),
2026-09-03 23:43:37 +00:00
)
.route(
"/api/sessions/{id}/messages",
2026-09-04 05:41:09 +00:00
get(list_messages).post(send_message).delete(clear_messages),
2026-09-03 23:43:37 +00:00
)
.route("/api/sessions/{id}/events", get(events))
2026-09-04 05:41:09 +00:00
.route("/api/sessions/{id}/stop", post(stop_session))
2026-09-03 23:43:37 +00:00
}
async fn actor(state: &AppState) -> Result<Actor, ApiError> {
state
.bootstrap()
.await
.map_err(|error| internal(error.to_string()))
}
fn internal(message: String) -> ApiError {
tracing::error!("sessions: {message}");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"message":"internal error"})),
)
}
2026-09-04 05:41:09 +00:00
pub(crate) fn session_from_row(
2026-09-03 23:43:37 +00:00
row: (
String,
String,
String,
String,
chrono::DateTime<chrono::Utc>,
chrono::DateTime<chrono::Utc>,
i32,
String,
i32,
),
) -> Session {
Session {
id: row.0,
bot_id: row.1,
title: row.2,
status: row.3,
created_at: row.4,
updated_at: row.5,
next_message_seq: row.6,
history_summary: row.7,
history_summary_seq: row.8,
}
}
async fn list_sessions(
State(state): State<AppState>,
Path(bot_id): Path<String>,
) -> Result<Json<Vec<Session>>, ApiError> {
let actor = actor(&state).await?;
2026-09-04 09:08:56 +00:00
let exists: Option<i32> =
sqlx::query_scalar("SELECT 1 FROM bots WHERE id=$1 AND space_id=$2 AND user_id=$3")
.bind(&bot_id)
.bind(&actor.space_id)
.bind(&actor.user_id)
.fetch_optional(state.pool())
.await
.map_err(|error| internal(error.to_string()))?;
2026-09-03 23:43:37 +00:00
if exists.is_none() {
2026-09-04 09:08:56 +00:00
return Err((
StatusCode::NOT_FOUND,
Json(json!({"message":"bot not found"})),
));
2026-09-03 23:43:37 +00:00
}
let rows = sqlx::query_as(
"SELECT id, bot_id, title, status, created_at, updated_at, next_message_seq,
history_summary, history_summary_seq
FROM threads
2026-09-04 05:41:09 +00:00
WHERE bot_id=$1 AND space_id=$2 AND user_id=$3 AND status='active' AND room_id IS NULL
2026-09-03 23:43:37 +00:00
ORDER BY updated_at DESC, created_at DESC",
)
.bind(bot_id)
.bind(actor.space_id)
.bind(actor.user_id)
.fetch_all(state.pool())
.await
.map_err(|error| internal(error.to_string()))?;
Ok(Json(rows.into_iter().map(session_from_row).collect()))
}
async fn create_session(
State(state): State<AppState>,
Path(bot_id): Path<String>,
Json(input): Json<CreateSessionInput>,
) -> Result<(StatusCode, Json<Session>), ApiError> {
let actor = actor(&state).await?;
2026-09-04 09:08:56 +00:00
let exists: Option<i32> =
sqlx::query_scalar("SELECT 1 FROM bots WHERE id=$1 AND space_id=$2 AND user_id=$3")
.bind(&bot_id)
.bind(&actor.space_id)
.bind(&actor.user_id)
.fetch_optional(state.pool())
.await
.map_err(|error| internal(error.to_string()))?;
2026-09-03 23:43:37 +00:00
if exists.is_none() {
2026-09-04 09:08:56 +00:00
return Err((
StatusCode::NOT_FOUND,
Json(json!({"message":"bot not found"})),
));
2026-09-03 23:43:37 +00:00
}
let title = normalized_title(&input.title);
let row = sqlx::query_as(
"INSERT INTO threads (id, space_id, bot_id, user_id, title)
VALUES ($1,$2,$3,$4,$5)
RETURNING id, bot_id, title, status, created_at, updated_at, next_message_seq,
history_summary, history_summary_seq",
)
.bind(Uuid::new_v4().to_string())
.bind(actor.space_id)
.bind(bot_id)
.bind(actor.user_id)
.bind(title)
.fetch_one(state.pool())
.await
.map_err(|error| internal(error.to_string()))?;
Ok((StatusCode::CREATED, Json(session_from_row(row))))
}
async fn get_session(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<Session>, ApiError> {
let actor = actor(&state).await?;
let row = scoped_session_row(&state, &actor, &id)
.await?
2026-09-04 09:08:56 +00:00
.ok_or_else(|| {
(
StatusCode::NOT_FOUND,
Json(json!({"message":"session not found"})),
)
})?;
2026-09-03 23:43:37 +00:00
Ok(Json(session_from_row(row)))
}
async fn update_session(
State(state): State<AppState>,
Path(id): Path<String>,
Json(input): Json<UpdateSessionInput>,
) -> Result<Json<Session>, ApiError> {
let actor = actor(&state).await?;
if input
.status
.as_deref()
.is_some_and(|status| !matches!(status, "active" | "archived"))
{
2026-09-04 09:08:56 +00:00
return Err((
StatusCode::BAD_REQUEST,
Json(json!({"message":"invalid status"})),
));
2026-09-03 23:43:37 +00:00
}
let title = input.title.as_deref().map(normalized_title);
let row = sqlx::query_as(
"UPDATE threads
SET title=COALESCE($1,title), status=COALESCE($2,status), updated_at=now()
WHERE id=$3 AND space_id=$4 AND user_id=$5
RETURNING id, bot_id, title, status, created_at, updated_at, next_message_seq,
history_summary, history_summary_seq",
)
.bind(title)
.bind(input.status)
.bind(id)
.bind(actor.space_id)
.bind(actor.user_id)
.fetch_optional(state.pool())
.await
.map_err(|error| internal(error.to_string()))?
2026-09-04 09:08:56 +00:00
.ok_or_else(|| {
(
StatusCode::NOT_FOUND,
Json(json!({"message":"session not found"})),
)
})?;
2026-09-03 23:43:37 +00:00
Ok(Json(session_from_row(row)))
}
async fn delete_session(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<StatusCode, ApiError> {
let actor = actor(&state).await?;
2026-09-04 05:41:09 +00:00
if scoped_session_row(&state, &actor, &id).await?.is_none() {
2026-09-04 09:08:56 +00:00
return Err((
StatusCode::NOT_FOUND,
Json(json!({"message":"session not found"})),
));
2026-09-04 05:41:09 +00:00
}
cancel_session_runs(&state, &id).await.map_err(internal)?;
2026-09-04 09:08:56 +00:00
let mut tx = state
.pool()
.begin()
.await
.map_err(|error| internal(error.to_string()))?;
2026-09-03 23:43:37 +00:00
let bot_id: Option<String> = sqlx::query_scalar(
"DELETE FROM threads WHERE id=$1 AND space_id=$2 AND user_id=$3 RETURNING bot_id",
)
.bind(&id)
.bind(&actor.space_id)
.bind(&actor.user_id)
.fetch_optional(&mut *tx)
.await
.map_err(|error| internal(error.to_string()))?;
let Some(bot_id) = bot_id else {
2026-09-04 09:08:56 +00:00
return Err((
StatusCode::NOT_FOUND,
Json(json!({"message":"session not found"})),
));
2026-09-03 23:43:37 +00:00
};
let remaining: bool =
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM threads WHERE bot_id=$1)")
.bind(&bot_id)
.fetch_one(&mut *tx)
.await
.map_err(|error| internal(error.to_string()))?;
if !remaining {
sqlx::query(
2026-09-04 05:41:09 +00:00
"INSERT INTO threads (id,space_id,bot_id,user_id,title) VALUES ($1,$2,$3,$4,'新對話')",
2026-09-03 23:43:37 +00:00
)
.bind(Uuid::new_v4().to_string())
.bind(&actor.space_id)
.bind(bot_id)
.bind(&actor.user_id)
.execute(&mut *tx)
.await
.map_err(|error| internal(error.to_string()))?;
}
2026-09-04 09:08:56 +00:00
tx.commit()
.await
.map_err(|error| internal(error.to_string()))?;
2026-09-03 23:43:37 +00:00
Ok(StatusCode::NO_CONTENT)
}
async fn list_messages(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<Vec<SessionMessage>>, ApiError> {
let actor = actor(&state).await?;
Ok(Json(messages_for_session(&state, &actor, &id).await?))
}
2026-09-04 05:41:09 +00:00
async fn clear_messages(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<Value>, ApiError> {
let actor = actor(&state).await?;
if scoped_session_row(&state, &actor, &id).await?.is_none() {
2026-09-04 09:08:56 +00:00
return Err((
StatusCode::NOT_FOUND,
Json(json!({"message":"session not found"})),
));
2026-09-04 05:41:09 +00:00
}
cancel_session_runs(&state, &id).await.map_err(internal)?;
2026-09-04 09:08:56 +00:00
let mut tx = state
.pool()
.begin()
.await
.map_err(|error| internal(error.to_string()))?;
2026-09-04 05:41:09 +00:00
sqlx::query("DELETE FROM messages WHERE thread_id=$1")
.bind(&id)
.execute(&mut *tx)
.await
.map_err(|error| internal(error.to_string()))?;
sqlx::query(
"UPDATE threads
SET next_message_seq=1, history_summary='', history_summary_seq=0,
history_compacted_at=NULL, updated_at=now()
WHERE id=$1 AND space_id=$2 AND user_id=$3",
)
.bind(&id)
.bind(&actor.space_id)
.bind(&actor.user_id)
.execute(&mut *tx)
.await
.map_err(|error| internal(error.to_string()))?;
2026-09-04 09:08:56 +00:00
tx.commit()
.await
.map_err(|error| internal(error.to_string()))?;
2026-09-04 05:41:09 +00:00
let _ = append_event(&state, &id, "session.cleared", json!({"sessionId":id})).await;
Ok(Json(json!({"ok":true})))
}
2026-09-03 23:43:37 +00:00
async fn send_message(
State(state): State<AppState>,
Path(id): Path<String>,
Json(input): Json<SendSessionMessageInput>,
) -> Result<(StatusCode, Json<Value>), ApiError> {
let actor = actor(&state).await?;
2026-09-05 04:20:19 +00:00
if input.text.trim().is_empty() && input.attachments.is_empty() {
2026-09-04 09:08:56 +00:00
return Err((
StatusCode::BAD_REQUEST,
Json(json!({"message":"empty message"})),
));
2026-09-03 23:43:37 +00:00
}
let session = scoped_session_row(&state, &actor, &id)
.await?
2026-09-04 09:08:56 +00:00
.ok_or_else(|| {
(
StatusCode::NOT_FOUND,
Json(json!({"message":"session not found"})),
)
})?;
2026-09-03 23:43:37 +00:00
let result = crate::runs::send(
&state,
&actor,
&session.1,
&id,
input.text.trim(),
input.client_nonce.as_deref(),
&input.blocks,
2026-09-05 04:20:19 +00:00
&input.attachments,
2026-09-03 23:43:37 +00:00
)
.await
.map_err(|message| (StatusCode::BAD_REQUEST, Json(json!({"message":message}))))?;
Ok((StatusCode::ACCEPTED, Json(result)))
}
2026-09-04 05:41:09 +00:00
async fn stop_session(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<Value>, ApiError> {
let actor = actor(&state).await?;
if scoped_session_row(&state, &actor, &id).await?.is_none() {
2026-09-04 09:08:56 +00:00
return Err((
StatusCode::NOT_FOUND,
Json(json!({"message":"session not found"})),
));
2026-09-04 05:41:09 +00:00
}
cancel_session_runs(&state, &id).await.map_err(internal)?;
2026-09-04 05:41:09 +00:00
Ok(Json(json!({"ok":true})))
}
2026-09-03 23:43:37 +00:00
async fn events(
State(state): State<AppState>,
Path(id): Path<String>,
headers: HeaderMap,
) -> Result<Sse<impl futures_util::Stream<Item = Result<Event, Infallible>>>, ApiError> {
let actor = actor(&state).await?;
if scoped_session_row(&state, &actor, &id).await?.is_none() {
2026-09-04 09:08:56 +00:00
return Err((
StatusCode::NOT_FOUND,
Json(json!({"message":"session not found"})),
));
2026-09-03 23:43:37 +00:00
}
let after = headers
.get("last-event-id")
.and_then(|value| value.to_str().ok())
.and_then(|value| value.parse::<i32>().ok())
.unwrap_or(0);
2026-09-07 13:08:10 +00:00
// Subscribe before the state moves into the stream: taking the subscription
// afterwards would open a window in which a commit could knock on a channel
// this reader is not listening to yet.
let wakes = state.wakes.subscribe();
let stream_state = (state, id, actor, after, Vec::<(i32, String, Value)>::new(), wakes);
2026-09-04 09:08:56 +00:00
let output = stream::unfold(
stream_state,
2026-09-07 13:08:10 +00:00
|(state, id, actor, mut after, mut pending, mut wakes)| async move {
2026-09-04 09:08:56 +00:00
loop {
if let Some((seq, kind, payload)) = pending.pop() {
after = seq;
let event = Event::default()
.id(seq.to_string())
.event(kind)
.json_data(payload)
.unwrap_or_else(|_| Event::default().event("error").data("{}"));
2026-09-07 13:08:10 +00:00
return Some((
Ok(event),
(state, id, actor, after, pending, wakes),
));
2026-09-04 09:08:56 +00:00
}
match sqlx::query_as::<_, (i32, String, Value)>(
"SELECT e.seq,e.type,e.payload FROM events e
2026-09-03 23:43:37 +00:00
JOIN threads t ON t.id=e.thread_id
WHERE e.thread_id=$1 AND e.seq>$2 AND t.space_id=$3 AND t.user_id=$4
ORDER BY e.seq ASC LIMIT 100",
2026-09-04 09:08:56 +00:00
)
.bind(&id)
.bind(after)
.bind(&actor.space_id)
.bind(&actor.user_id)
.fetch_all(state.pool())
.await
{
Ok(mut rows) if !rows.is_empty() => {
rows.reverse();
pending = rows;
}
2026-09-07 13:08:10 +00:00
// Idle means "wait to be knocked", not "sleep then guess":
// the reader blocks on the wake channel and re-reads the
// cursor the moment a writer commits.
Ok(_) | Err(_) => {
tokio::select! {
_ = wakes.wait(&id) => {}
_ = tokio::time::sleep(EVENT_FALLBACK_POLL) => {}
}
}
2026-09-03 23:43:37 +00:00
}
}
2026-09-04 09:08:56 +00:00
},
);
2026-09-03 23:43:37 +00:00
Ok(Sse::new(output).keep_alive(
KeepAlive::new()
.interval(Duration::from_secs(15))
.text("keep-alive"),
))
}
pub async fn default_session_for_bot(
state: &AppState,
actor: &Actor,
bot_id: &str,
) -> Result<Option<String>, sqlx::Error> {
sqlx::query_scalar(
"SELECT id FROM threads
2026-09-04 05:41:09 +00:00
WHERE bot_id=$1 AND space_id=$2 AND user_id=$3 AND status='active' AND room_id IS NULL
2026-09-03 23:43:37 +00:00
ORDER BY updated_at DESC, created_at ASC LIMIT 1",
)
.bind(bot_id)
.bind(&actor.space_id)
.bind(&actor.user_id)
.fetch_optional(state.pool())
.await
}
/// `messages_for_session` projection: message columns joined with the speaking bot.
type MessageWithSpeakerRow = (
String,
String,
i32,
String,
String,
Value,
Option<String>,
Option<String>,
chrono::DateTime<chrono::Utc>,
Option<String>,
Option<String>,
Option<String>,
Option<String>,
);
2026-09-03 23:43:37 +00:00
pub async fn messages_for_session(
state: &AppState,
actor: &Actor,
id: &str,
) -> Result<Vec<SessionMessage>, ApiError> {
let rows: Vec<MessageWithSpeakerRow> = sqlx::query_as(
2026-09-03 23:43:37 +00:00
"SELECT m.id, m.thread_id, m.seq, m.role, m.body, m.blocks, m.run_id,
2026-09-04 05:41:09 +00:00
m.client_nonce, m.created_at, m.speaker_bot_id, b.name, b.avatar_color, b.avatar_shape
FROM messages m
JOIN threads t ON t.id=m.thread_id
LEFT JOIN bots b ON b.id=m.speaker_bot_id
2026-09-03 23:43:37 +00:00
WHERE t.id=$1 AND t.space_id=$2 AND t.user_id=$3
ORDER BY m.seq ASC",
)
.bind(id)
.bind(&actor.space_id)
.bind(&actor.user_id)
.fetch_all(state.pool())
.await
.map_err(|error| internal(error.to_string()))?;
Ok(rows
.into_iter()
.map(|row| SessionMessage {
id: row.0,
session_id: row.1,
seq: row.2,
role: row.3,
body: row.4,
blocks: row.5,
run_id: row.6,
client_nonce: row.7,
created_at: row.8,
2026-09-04 05:41:09 +00:00
speaker_bot_id: row.9,
speaker_name: row.10,
speaker_color: row.11,
speaker_shape: row.12,
2026-09-03 23:43:37 +00:00
})
.collect())
}
async fn scoped_session_row(
state: &AppState,
actor: &Actor,
id: &str,
) -> Result<
Option<(
String,
String,
String,
String,
chrono::DateTime<chrono::Utc>,
chrono::DateTime<chrono::Utc>,
i32,
String,
i32,
)>,
ApiError,
> {
sqlx::query_as(
"SELECT id, bot_id, title, status, created_at, updated_at, next_message_seq,
history_summary, history_summary_seq
FROM threads WHERE id=$1 AND space_id=$2 AND user_id=$3",
)
.bind(id)
.bind(&actor.space_id)
.bind(&actor.user_id)
.fetch_optional(state.pool())
.await
.map_err(|error| internal(error.to_string()))
}
pub async fn append_event(
state: &AppState,
thread_id: &str,
kind: &str,
payload: Value,
) -> Result<i32, sqlx::Error> {
let mut tx = state.pool().begin().await?;
let seq: i32 = sqlx::query_scalar(
"UPDATE threads SET next_event_seq=next_event_seq+1, updated_at=now()
WHERE id=$1 RETURNING next_event_seq",
)
.bind(thread_id)
.fetch_one(&mut *tx)
.await?;
sqlx::query("INSERT INTO events (id,thread_id,seq,type,payload) VALUES ($1,$2,$3,$4,$5)")
.bind(Uuid::new_v4().to_string())
.bind(thread_id)
.bind(seq)
.bind(kind)
.bind(payload)
.execute(&mut *tx)
.await?;
tx.commit().await?;
2026-09-07 13:08:10 +00:00
// Every event writer funnels through here, so one knock after the commit
// covers messages, run state, and metrics without each call site having to
// remember it.
state.wakes.wake(thread_id);
2026-09-03 23:43:37 +00:00
Ok(seq)
}
2026-09-04 05:41:09 +00:00
pub(crate) fn normalized_title(title: &str) -> String {
2026-09-03 23:43:37 +00:00
let value: String = title.trim().chars().take(120).collect();
if value.is_empty() {
2026-09-04 05:41:09 +00:00
"新對話".to_string()
2026-09-03 23:43:37 +00:00
} else {
value
}
}
2026-09-04 05:41:09 +00:00
pub(crate) fn is_default_session_title(title: &str) -> bool {
let value = title.trim();
value.is_empty()
|| value == "New session"
|| value == "新對話"
|| value
.strip_prefix("對話 ")
.is_some_and(|rest| !rest.is_empty() && rest.chars().all(|c| c.is_ascii_digit()))
}
pub(crate) fn title_from_first_message(text: &str) -> String {
let line = text
.lines()
.map(str::trim)
.find(|line| !line.is_empty())
.unwrap_or("");
let truncated: String = line.chars().take(40).collect();
if truncated.is_empty() {
"新對話".to_string()
} else if line.chars().count() > 40 {
format!("{truncated}")
} else {
truncated
}
}
2026-09-04 09:08:56 +00:00
pub(crate) async fn cancel_session_runs(
state: &AppState,
thread_id: &str,
) -> Result<Vec<String>, String> {
2026-09-04 05:41:09 +00:00
let run_ids: Vec<String> = sqlx::query_scalar(
"UPDATE runs SET status='cancelled', completed_at=now(), updated_at=now()
WHERE thread_id=$1 AND status IN ('queued','leased','running','waiting_input','waiting_takeover')
RETURNING id",
)
.bind(thread_id)
.fetch_all(state.pool())
.await
.map_err(|error| error.to_string())?;
for run_id in &run_ids {
crate::computer::release_screen_execution(state, run_id).await?;
sqlx::query(
"UPDATE computers SET execution_bot_id=NULL, execution_run_id=NULL,
execution_lease_expires_at=NULL, updated_at=now()
WHERE execution_run_id=$1",
)
.bind(run_id)
.execute(state.pool())
.await
.map_err(|error| error.to_string())?;
}
Ok(run_ids)
}
2026-09-03 23:43:37 +00:00
#[cfg(test)]
mod tests {
2026-09-04 05:41:09 +00:00
use super::{is_default_session_title, normalized_title, title_from_first_message};
2026-09-03 23:43:37 +00:00
#[test]
fn session_titles_are_bounded_and_have_a_default() {
2026-09-04 05:41:09 +00:00
assert_eq!(normalized_title(" "), "新對話");
2026-09-03 23:43:37 +00:00
assert_eq!(normalized_title(" Research "), "Research");
assert_eq!(normalized_title(&"x".repeat(150)).chars().count(), 120);
}
2026-09-04 05:41:09 +00:00
#[test]
fn default_titles_include_legacy_and_numbered_names() {
assert!(is_default_session_title("New session"));
assert!(is_default_session_title("新對話"));
assert!(is_default_session_title("對話 1"));
assert!(is_default_session_title("對話 12"));
assert!(!is_default_session_title("幫我查網站"));
assert!(!is_default_session_title("對話"));
assert!(!is_default_session_title("對話 一"));
}
#[test]
fn first_message_title_uses_first_line_and_truncates() {
assert_eq!(title_from_first_message("幫我查天氣"), "幫我查天氣");
assert_eq!(title_from_first_message("\n 第一行\n第二行"), "第一行");
assert_eq!(
title_from_first_message(&"".repeat(45)),
format!("{}", "".repeat(40))
);
assert_eq!(title_from_first_message(" \n"), "新對話");
}
2026-09-03 23:43:37 +00:00
}