Phase 1 server: TLS + token auth, session registry, EchoDriver, SSE with cursors
The whole pipe behind one Driver trait and a common event model: spawn/list/delete sessions, message + question answering, append-only JSONL transcripts whose sequence numbers are the phone's resume cursor (surviving backend restarts), bearer-token middleware wrapping every route including the fallback, wg0-only binding that fails closed, and first-run token enrollment via a terminal QR. Verified: cargo test (10), clippy clean, and curl end-to-end over pinned TLS -- auth rejection, spawn, streamed SSE replay/resume, /question round trip, restart continuing seq numbers, delete removing everything. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
This commit is contained in:
1 parent
a6ece28344
commit
967fc814ab
13 files changed
+3349
No files matched your search
@@ -0,0 +1,302 @@
|
||||
//! The HTTP surface -- REST for actions, one SSE stream per open session
|
||||
//! screen for events, all behind the bearer-token middleware `main.rs`
|
||||
//! wraps the whole router in.
|
||||
//!
|
||||
//! ```text
|
||||
//! GET /sessions list (id, kind, title, host, model, status, last activity)
|
||||
//! POST /sessions spawn {kind, title?, host?, model?, cwd?, permissionMode?}
|
||||
//! GET /sessions/{id}/events?after=N SSE: transcript replay from N, then live
|
||||
//! POST /sessions/{id}/message {text, attachmentIds?}
|
||||
//! POST /sessions/{id}/answer {questionId, answer} (questions and permissions)
|
||||
//! POST /sessions/{id}/interrupt
|
||||
//! POST /sessions/{id}/model {model}
|
||||
//! POST /sessions/{id}/compact
|
||||
//! DELETE /sessions/{id} kill process, delete transcript + files
|
||||
//! ```
|
||||
//!
|
||||
//! Later phases add: `POST /attachments`, `GET /files/{session}/{id}`,
|
||||
//! `GET /usage`, `GET|PUT /hosts` and `/models` -- see PLAN.md's table.
|
||||
//!
|
||||
//! Everything here works purely in the common event model; nothing may
|
||||
//! branch on the session kind (that's what drivers are for).
|
||||
|
||||
use std::convert::Infallible;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::Router;
|
||||
use axum::extract::{Path as UrlPath, Query, State};
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use axum::response::sse::{Event as SseEvent, KeepAlive, Sse};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::{delete, get, post};
|
||||
use serde::Deserialize;
|
||||
use tokio::sync::{broadcast, mpsc};
|
||||
use tokio_stream::StreamExt;
|
||||
use tokio_stream::wrappers::ReceiverStream;
|
||||
|
||||
use crate::session::transcript::{SeqEvent, read_after};
|
||||
use crate::session::{LiveSession, SessionInfo, SessionManager, SpawnSpec};
|
||||
|
||||
pub fn router(manager: Arc<SessionManager>) -> Router {
|
||||
Router::new()
|
||||
.route("/sessions", get(list_sessions).post(spawn_session))
|
||||
.route("/sessions/{id}", delete(delete_session))
|
||||
.route("/sessions/{id}/events", get(events))
|
||||
.route("/sessions/{id}/message", post(message))
|
||||
.route("/sessions/{id}/answer", post(answer))
|
||||
.route("/sessions/{id}/interrupt", post(interrupt))
|
||||
.route("/sessions/{id}/model", post(set_model))
|
||||
.route("/sessions/{id}/compact", post(compact))
|
||||
// An explicit fallback so the auth middleware (layered around the
|
||||
// whole router in main.rs) also covers unknown paths -- a scanner
|
||||
// gets the same 401 everywhere, never a route map.
|
||||
.fallback(|| async { ApiError::UnknownRoute })
|
||||
.with_state(manager)
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
enum ApiError {
|
||||
#[error("no session {0}")]
|
||||
UnknownSession(String),
|
||||
#[error("no such route")]
|
||||
UnknownRoute,
|
||||
#[error("{0}")]
|
||||
BadRequest(String),
|
||||
}
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let status = match self {
|
||||
Self::UnknownSession(_) | Self::UnknownRoute => StatusCode::NOT_FOUND,
|
||||
Self::BadRequest(_) => StatusCode::BAD_REQUEST,
|
||||
};
|
||||
(status, self.to_string()).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
/// An `anyhow` error from a session mutation is a message written *for*
|
||||
/// the phone ("no session abc123") -- not an internal fault, so it comes
|
||||
/// back as a 400 with that message rather than a 500 and a log line.
|
||||
fn bad_request(err: anyhow::Error) -> ApiError {
|
||||
ApiError::BadRequest(format!("{err:#}"))
|
||||
}
|
||||
|
||||
fn lookup(manager: &SessionManager, id: &str) -> Result<Arc<LiveSession>, ApiError> {
|
||||
manager.session(id).ok_or_else(|| ApiError::UnknownSession(id.to_string()))
|
||||
}
|
||||
|
||||
async fn list_sessions(State(manager): State<Arc<SessionManager>>) -> axum::Json<Vec<SessionInfo>> {
|
||||
axum::Json(manager.sessions())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SpawnRequest {
|
||||
kind: crate::config::SessionKind,
|
||||
#[serde(default)]
|
||||
title: Option<String>,
|
||||
#[serde(default)]
|
||||
host: Option<String>,
|
||||
#[serde(default)]
|
||||
model: Option<String>,
|
||||
#[serde(default)]
|
||||
cwd: Option<PathBuf>,
|
||||
#[serde(default)]
|
||||
permission_mode: Option<String>,
|
||||
}
|
||||
|
||||
async fn spawn_session(
|
||||
State(manager): State<Arc<SessionManager>>,
|
||||
axum::Json(body): axum::Json<SpawnRequest>,
|
||||
) -> Result<axum::Json<SessionInfo>, ApiError> {
|
||||
let info = manager
|
||||
.spawn_session(SpawnSpec {
|
||||
kind: body.kind,
|
||||
title: body.title,
|
||||
host: body.host,
|
||||
model: body.model,
|
||||
cwd: body.cwd,
|
||||
permission_mode: body.permission_mode,
|
||||
})
|
||||
.map_err(bad_request)?;
|
||||
tracing::info!("spawned {:?} session {} ({})", info.kind, info.id, info.title);
|
||||
Ok(axum::Json(info))
|
||||
}
|
||||
|
||||
async fn delete_session(
|
||||
State(manager): State<Arc<SessionManager>>,
|
||||
UrlPath(id): UrlPath<String>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
manager.delete_session(&id).map_err(bad_request)?;
|
||||
tracing::info!("deleted session {id}");
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct MessageRequest {
|
||||
text: String,
|
||||
/// Ids from `POST /attachments` (phase 2); accepted now so the request
|
||||
/// shape doesn't change under the app.
|
||||
#[serde(default)]
|
||||
attachment_ids: Vec<String>,
|
||||
}
|
||||
|
||||
async fn message(
|
||||
State(manager): State<Arc<SessionManager>>,
|
||||
UrlPath(id): UrlPath<String>,
|
||||
axum::Json(body): axum::Json<MessageRequest>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let session = lookup(&manager, &id)?;
|
||||
if body.text.trim().is_empty() && body.attachment_ids.is_empty() {
|
||||
return Err(ApiError::BadRequest("message is empty".to_string()));
|
||||
}
|
||||
session.send_message(body.text, body.attachment_ids);
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct AnswerRequest {
|
||||
question_id: String,
|
||||
answer: String,
|
||||
}
|
||||
|
||||
async fn answer(
|
||||
State(manager): State<Arc<SessionManager>>,
|
||||
UrlPath(id): UrlPath<String>,
|
||||
axum::Json(body): axum::Json<AnswerRequest>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
lookup(&manager, &id)?.answer_question(&body.question_id, &body.answer);
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn interrupt(
|
||||
State(manager): State<Arc<SessionManager>>,
|
||||
UrlPath(id): UrlPath<String>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
lookup(&manager, &id)?.interrupt();
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ModelRequest {
|
||||
model: String,
|
||||
}
|
||||
|
||||
/// What happens is the driver's call -- a driver that can't switch in
|
||||
/// place reports how it handled it (or that it can't) as events.
|
||||
async fn set_model(
|
||||
State(manager): State<Arc<SessionManager>>,
|
||||
UrlPath(id): UrlPath<String>,
|
||||
axum::Json(body): axum::Json<ModelRequest>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
lookup(&manager, &id)?.set_model(&body.model);
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn compact(
|
||||
State(manager): State<Arc<SessionManager>>,
|
||||
UrlPath(id): UrlPath<String>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
lookup(&manager, &id)?.compact();
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct EventsQuery {
|
||||
#[serde(default)]
|
||||
after: u64,
|
||||
}
|
||||
|
||||
/// The session screen's one data source: replay everything after the
|
||||
/// cursor from the transcript, then live events as they happen. An SSE
|
||||
/// auto-reconnect sends the last event id it saw as `Last-Event-ID`, which
|
||||
/// takes precedence over `after` -- same cursor, native mechanism.
|
||||
async fn events(
|
||||
State(manager): State<Arc<SessionManager>>,
|
||||
UrlPath(id): UrlPath<String>,
|
||||
Query(query): Query<EventsQuery>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Sse<impl tokio_stream::Stream<Item = Result<SseEvent, Infallible>>>, ApiError> {
|
||||
let session = lookup(&manager, &id)?;
|
||||
let cursor = headers
|
||||
.get("last-event-id")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| value.parse().ok())
|
||||
.unwrap_or(query.after);
|
||||
|
||||
// Subscribe before reading the file so nothing can land in the gap
|
||||
// between replay and live; overlap is deduplicated by seq.
|
||||
let live = session.subscribe();
|
||||
let (tx, stream) = mpsc::channel(64);
|
||||
tokio::spawn(stream_session(
|
||||
session.transcript_path().to_path_buf(),
|
||||
cursor,
|
||||
live,
|
||||
tx,
|
||||
));
|
||||
Ok(Sse::new(ReceiverStream::new(stream).map(Ok)).keep_alive(KeepAlive::default()))
|
||||
}
|
||||
|
||||
/// Feeds one SSE subscriber: transcript replay after the cursor, then live
|
||||
/// events, catching back up from the file whenever the broadcast channel
|
||||
/// laps us. Ends when the client disconnects (send fails) or the session
|
||||
/// is deleted (channel closed).
|
||||
async fn stream_session(
|
||||
transcript: PathBuf,
|
||||
mut last: u64,
|
||||
mut live: broadcast::Receiver<SeqEvent>,
|
||||
tx: mpsc::Sender<SseEvent>,
|
||||
) {
|
||||
// Synchronous file reads from an async task: transcript lines are
|
||||
// small and local; revisit if daily use produces transcripts where
|
||||
// this shows (phase 6 territory).
|
||||
let catch_up = |after: u64| match read_after(&transcript, after) {
|
||||
Ok(entries) => Some(entries),
|
||||
Err(err) => {
|
||||
tracing::error!("transcript replay failed: {err:#}");
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
let Some(replay) = catch_up(last) else { return };
|
||||
for entry in replay {
|
||||
last = entry.seq;
|
||||
if send_event(&tx, &entry).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
loop {
|
||||
match live.recv().await {
|
||||
Ok(entry) => {
|
||||
if entry.seq <= last {
|
||||
continue;
|
||||
}
|
||||
last = entry.seq;
|
||||
if send_event(&tx, &entry).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(_)) => {
|
||||
let Some(missed) = catch_up(last) else { return };
|
||||
for entry in missed {
|
||||
last = entry.seq;
|
||||
if send_event(&tx, &entry).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => return,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_event(
|
||||
tx: &mpsc::Sender<SseEvent>,
|
||||
entry: &SeqEvent,
|
||||
) -> Result<(), mpsc::error::SendError<SseEvent>> {
|
||||
let data = serde_json::to_string(entry).expect("events always serialize");
|
||||
tx.send(SseEvent::default().id(entry.seq.to_string()).data(data)).await
|
||||
}
|
||||
Reference in new issue
Block a user