Prune commentary and stale Rust port notes

This commit is contained in:
iris committed 2026-09-10 00:44:13 -04:00
1 parent 5428cd75c9
commit 25370731d0
193 files changed
+693 -16219

No files matched your search

-405
View File
@@ -1,91 +1,3 @@
//! 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 /setups machines, each with what it can run
//! POST /setups add {name, ssh?} -- providers are discovered
//! POST /setups/probe dry run {ssh?}: what would be found there
//! GET /setups/{id} one machine, for refetching after a change
//! GET /setups/{id}/models GGUFs on that machine, for a llama session
//! GET /setups/{id}/dir?path=P entries of directory P, and P resolved
//! GET /setups/{id}/file?path=P content of file P, or why not
//! PUT /setups/{id}/file {path, content, ifSha256} -> new size/mtime/sha256
//! (409 when the file no longer matches ifSha256)
//! POST /setups/{id}/file {path} create empty; refused if it exists
//! POST /setups/{id}/dir {path} create; refused if it exists
//! GET /setups/{id}/importable Claude Code sessions on it that could be continued
//! POST /setups/{id}/importable/import {sessions} -> 202; runs on the server
//! POST /setups/{id}/importable/delete {sessions} -> 202; removes the machine's transcripts
//! GET /setups/{id}/importable/events SSE: what is in flight against them
//! PUT /setups/{id} rename {name?} and/or re-probe {rediscover?}
//! DELETE /setups/{id} remove, refused while sessions use it
//! GET /sessions list (id, provider, title, model, status, last activity)
//! GET /sessions/{id} one session, for refetching after a change
//! POST /sessions spawn {setup, provider, title?, model?, cwd?, params?}
//! GET /sessions/{id}/events?after=N SSE: backlog after N, then live
//! (a backlog past CATCH_UP_LIMIT arrives as a
//! `reset` frame plus the newest window)
//! GET /sessions/{id}/transcript a page of history: ?before=N (newest when absent),
//! ?limit=N, ?coalesce=true to count rows not deltas,
//! ?after=N to floor it at what the caller already holds
//! GET /sessions/{id}/subagents [{id, title, status, created, lastActivity}], oldest
//! first -- see docs/SUBAGENTS.md
//! GET /sessions/{id}/subagents/{sub}/transcript exactly the transcript route above,
//! against that subagent's own transcript
//! GET /sessions/{id}/subagents/{sub}/events?after=N exactly the events route above,
//! against that subagent's own stream
//! POST /sessions/{id}/message {text, attachmentIds?}
//! (starts the process first if it has exited)
//! POST /sessions/{id}/unqueue {messageId} -- take back one not read yet
//! (409 when the session already has it)
//! POST /sessions/{id}/answer {questionId, answers} (questions and permissions)
//! POST /sessions/{id}/interrupt stop the running turn; the process stays
//! POST /sessions/{id}/stop end the process; the session and transcript stay
//! POST /sessions/{id}/start run the process again, continuing the conversation
//! POST /sessions/{id}/title {title}
//! POST /sessions/{id}/cwd {cwd} -- move it; stops the process,
//! which starts again in the new one
//! POST /sessions/{id}/model {model}
//! POST /sessions/{id}/permission-mode {permissionMode}
//! POST /sessions/{id}/effort {effort} -- null for the CLI's default;
//! settled at launch, so this stops the process
//! POST /sessions/{id}/command {text} -- /compact, /clear, /rename x, or the dialect's own
//! (starts the process first if it has exited)
//! POST /sessions/{id}/compact
//! POST /sessions/{id}/attachments multipart upload, image or any file -> {id}, referenced by /message
//! GET /sessions/{id}/files/{name} images the session produced, and what it was sent
//! DELETE /sessions/{id} kill process, delete transcript + files
//! (?deleteForeign=true removes the machine's own copy too)
//! POST /sessions/{id}/notify {notify} -- announce this one or not
//! POST /sessions/{id}/auto-resume {autoResume, message?} -- carry on by itself
//! once the account's usage limit lifts
//! GET /notifications SSE: every session's attention-wanting
//! moments, live only (see `notifications`)
//! GET /defaults {effort} -- what a new session starts at
//! POST /defaults {effort} -- null for the CLI's own default
//! GET /usage cached usage windows per provider
//! GET /models downloaded GGUFs, and what is being fetched
//! GET /models/search?q=Q HuggingFace repositories matching Q
//! GET /models/files?repo=R the GGUFs in one repository
//! POST /models/download {repo, file}; rejoins the run already going
//! POST /models/cancel {key}; the partial stays, so starting again resumes
//! POST /models/delete {key}
//! ```
//!
//! Everything here works purely in the common event model; nothing may
//! branch on the session kind (that's what drivers are for).
//!
//! **Every request body in this module refuses fields it does not know**
//! (`serde(deny_unknown_fields)`), and a new one is expected to do the
//! same. Silently ignoring a field is the worst available answer: a caller
//! that misspells `permissionMode` got a 200 and a session running in the
//! default permission mode, which is indistinguishable from success at the
//! place they are looking. It cost an hour here, chasing a "startup race"
//! that was a snake_case key serde had dropped on the floor. Query strings
//! are deliberately left permissive -- a stale link carrying an extra
//! parameter is not a mistake worth failing a request over.
use std::convert::Infallible;
use std::path::{Path, PathBuf};
use std::sync::Arc;
@@ -123,7 +35,6 @@ pub fn router(manager: Arc<SessionManager>) -> Router {
"/setups/{id}",
get(read_setup).put(update_setup).delete(delete_setup),
)
// The models on the machine a setup names, for a llama session there.
.route("/setups/{id}/models", get(setup_models))
// The filesystem of the machine a setup names. Under the setup
// rather than under a session because a filesystem is a property of
@@ -187,8 +98,6 @@ enum ApiError {
UnknownRoute,
#[error("{0}")]
BadRequest(String),
/// The request was understood and the state it names has moved on --
/// distinct from `BadRequest`, which is a caller that got it wrong.
#[error("{0}")]
Conflict(String),
#[error(transparent)]
@@ -202,8 +111,6 @@ impl IntoResponse for ApiError {
Self::BadRequest(_) => StatusCode::BAD_REQUEST,
Self::Conflict(_) => StatusCode::CONFLICT,
Self::Internal(err) => {
// The only variant whose real cause isn't safe to hand back
// verbatim, and the only one worth a log line.
tracing::error!("{err:#}");
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
@@ -212,9 +119,6 @@ impl IntoResponse for ApiError {
}
}
/// An `anyhow` error from a session mutation is a message written *for* the
/// phone ("no session abc123"), 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:#}"))
}
@@ -241,13 +145,6 @@ async fn list_sessions(State(manager): State<Arc<SessionManager>>) -> axum::Json
axum::Json(manager.sessions())
}
/// One session's row, for a screen that has to show what is true now.
///
/// The list is a snapshot taken when somebody last looked at it, and a screen
/// opened from a row carries that snapshot with it. Fine for what a row
/// *says* and wrong for what a control is *set to*: a switch drawn from a
/// stale row shows the position it had when the list was fetched, and the
/// person reading it cannot tell. Same reason `GET /setups/{id}` exists.
async fn read_session(
State(manager): State<Arc<SessionManager>>,
UrlPath(id): UrlPath<String>,
@@ -260,10 +157,6 @@ async fn read_session(
.ok_or_else(|| ApiError::NotFound(format!("no session {id}")))
}
/// What the spawn screen needs to render itself, so the phone holds no
/// hardcoded list: a setup added to `config.ron` shows up with no app
/// rebuild.
///
/// One list rather than two, because the halves are not independent. A
/// provider only exists on a machine that has it installed, so listing them
/// separately offered the whole cross-product -- including "the Claude CLI on
@@ -271,11 +164,8 @@ async fn read_session(
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct SetupInfo {
/// Stable; what a session stores and what these routes address.
id: String,
/// The editable label.
name: String,
/// Where it runs, for telling two setups apart. Absent for this machine.
#[serde(skip_serializing_if = "Option::is_none")]
address: Option<String>,
providers: Vec<ProviderInfo>,
@@ -310,8 +200,6 @@ fn info_for(setup: crate::config::SetupConfig) -> SetupInfo {
}
}
/// How to reach a machine, as the phone describes it.
///
/// Note what is absent: nothing here names a program. Providers are found by
/// asking the machine (`crate::setups`), never sent, so the enrolled token
/// cannot introduce something to run.
@@ -328,17 +216,13 @@ struct SshRequest {
identity_file: Option<String>,
#[serde(default)]
options: Vec<String>,
/// Where attached files land on that machine; see `SshConfig`.
#[serde(default)]
attachments_dir: Option<String>,
/// Where that machine keeps its GGUF models; see `SshConfig`.
#[serde(default)]
models_dir: Option<String>,
}
impl SshRequest {
/// Tidied at the boundary rather than stored as typed -- this came from a
/// phone keyboard, so it may have a stray space or a `~`.
fn into_config(self) -> Result<crate::config::SshConfig, ApiError> {
let address = crate::setups::tidy(&self.address)
.ok_or_else(|| ApiError::BadRequest("a machine needs an address".to_string()))?;
@@ -355,16 +239,12 @@ impl SshRequest {
.iter()
.filter_map(|o| crate::setups::tidy(o))
.collect(),
// Not `tidy`: that expands `~` to *this* machine's home, and this
// path is on the other one. The remote shell expands it there.
attachments_dir: self
.attachments_dir
.as_deref()
.map(str::trim)
.filter(|dir| !dir.is_empty())
.map(std::path::PathBuf::from),
// The same rule, and for the same reason: this directory is
// on the other machine, so a `~` in it is that machine's home.
models_dir: self
.models_dir
.as_deref()
@@ -380,15 +260,10 @@ impl SshRequest {
#[serde(deny_unknown_fields)]
struct AddSetupRequest {
name: String,
/// Absent means this machine.
#[serde(default)]
ssh: Option<SshRequest>,
}
/// What a machine turned out to have, without saving anything. The point of
/// trying before committing: a wrong address or an unauthorised key is caught
/// while the person is still looking at the form that caused it, rather than
/// at the first spawn.
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
@@ -414,9 +289,6 @@ async fn probe_setup(
))
}
/// Asks the machine an `ssh` block describes -- or this one -- what it has.
/// `label` only ever appears in a failure message, so a probe of an unsaved
/// form can still say which machine would not answer.
async fn probe(
ssh: Option<crate::config::SshConfig>,
label: &str,
@@ -438,9 +310,6 @@ async fn add_setup(
axum::Json(body): axum::Json<AddSetupRequest>,
) -> Result<axum::Json<SetupInfo>, ApiError> {
let ssh = body.ssh.map(SshRequest::into_config).transpose()?;
// Ask the machine being added what it has, before writing anything, so a
// bad address fails here rather than leaving a setup that can never
// spawn.
let providers = probe(ssh.clone(), &body.name).await?;
let setup = manager
.add_setup(&body.name, ssh, providers)
@@ -448,8 +317,6 @@ async fn add_setup(
Ok(axum::Json(info_for(setup)))
}
/// One setup by id, or the 404 that says so. Three handlers ask this same
/// question; the answer, and the wording of the refusal, belong in one place.
fn setup_by_id(
manager: &Arc<SessionManager>,
id: &str,
@@ -474,8 +341,6 @@ async fn read_setup(
struct UpdateSetupRequest {
#[serde(default)]
name: Option<String>,
/// Ask the machine again what it has -- after installing something
/// there, or when a binary moved.
#[serde(default)]
rediscover: bool,
}
@@ -514,10 +379,6 @@ async fn delete_setup(
Ok(StatusCode::NO_CONTENT)
}
/// The five explorer routes below all begin the same way: find the machine,
/// and check that what the phone named is a path this will act on. The check
/// is `files::check_path`, shared with [`set_cwd`] -- one rule about what an
/// acceptable path is, and one wording for refusing it.
fn files_on(
manager: &Arc<SessionManager>,
id: &str,
@@ -531,29 +392,15 @@ fn files_on(
))
}
/// A failure from one of the scripts is the *machine's* message, written to
/// be read where it happened, which is the phone. So it comes back as a 400
/// with those words rather than a 500 and a log line only the backend sees.
fn from_machine(err: anyhow::Error) -> ApiError {
ApiError::BadRequest(format!("{err:#}"))
}
/// Where a path is named for these routes. Query rather than a path segment:
/// a path contains slashes, and a segment that had to be escaped and
/// unescaped would be a second encoding to keep in step with the phone's.
#[derive(Deserialize)]
struct PathQuery {
path: String,
}
/// The models **that machine** has, which is the list a llama.cpp session
/// on it can choose from.
///
/// Not `GET /models`, which is this backend's own downloads: those are on
/// the machine a session runs on only when they are the same machine. A
/// spawn screen offering this backend's list for a remote setup would be
/// naming files that are not there, and the session would fail at the
/// point of loading rather than at the point of choosing.
async fn setup_models(
State(manager): State<Arc<SessionManager>>,
UrlPath(id): UrlPath<String>,
@@ -567,7 +414,6 @@ async fn setup_models(
.map_err(from_machine)
}
/// What is in a directory, and what that directory resolved to.
async fn list_dir(
State(manager): State<Arc<SessionManager>>,
UrlPath(id): UrlPath<String>,
@@ -580,9 +426,6 @@ async fn list_dir(
.map_err(from_machine)
}
/// One file's content, or which of the three reasons there is none. The path
/// it was asked for rides along, so a phone that has moved on since can tell
/// which answer this is.
#[derive(Serialize)]
struct FileResponse {
path: String,
@@ -608,10 +451,6 @@ async fn read_file(
struct WriteFileRequest {
path: String,
content: String,
/// The digest the read reported. Not optional: an editor that could omit
/// it would be one overwrite away from losing an agent's edit, and "I did
/// not check" is not something a caller should be able to say by leaving a
/// field out.
if_sha256: String,
}
@@ -670,7 +509,6 @@ async fn create_dir(
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
struct SpawnRequest {
/// Which machine, and which of the things it offers.
setup: String,
provider: String,
#[serde(default)]
@@ -683,21 +521,12 @@ struct SpawnRequest {
permission_mode: Option<String>,
#[serde(default)]
effort: Option<String>,
/// Whatever the chosen driver understands -- llama.cpp's context size and
/// sampling. Opaque here on purpose: see `SessionConfig::params`.
#[serde(default)]
params: std::collections::BTreeMap<String, String>,
/// Continue a Claude Code session the machine already has, named by the id
/// `GET /setups/{id}/importable` reported.
///
/// An id and not a path, deliberately: the server looks the path up again
/// among the sessions it enumerated, so an enrolled token cannot turn this
/// field into "read me an arbitrary file".
#[serde(default)]
import: Option<String>,
}
/// What a machine already has that could be continued.
async fn list_importable(
State(manager): State<Arc<SessionManager>>,
UrlPath(id): UrlPath<String>,
@@ -710,12 +539,6 @@ async fn list_importable(
// Anything this app is already continuing is not offered again. Left out
// rather than shown-and-disabled, because it has not disappeared: it is in
// the session list, which is where it now belongs.
//
// Except while this server is in the middle of importing it. A spawn
// creates the session partway through, so the row would vanish the instant
// the work started and reappear as a session only once it finished -- and
// in between, the screen that asked for it would show nothing at all where
// the thing it is waiting for used to be.
found.retain(|candidate| {
manager.pending().running(&id, &candidate.id).is_some()
|| manager.session_driving(&candidate.id).is_none()
@@ -741,16 +564,11 @@ async fn list_importable(
Ok(axum::Json(rows))
}
/// A row of the import list: what the machine has, plus what this server is
/// doing to it. Flattened, so the two halves arrive as one object -- the phone
/// is drawing one row and has no use for the seam.
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ImportableRow {
#[serde(flatten)]
importable: crate::session::import::Importable,
/// The word the row shows while something is running: "importing" or
/// "deleting". Absent when nothing is.
#[serde(skip_serializing_if = "Option::is_none")]
pending: Option<&'static str>,
/// How the last attempt on this row failed, if it did. Kept until
@@ -760,12 +578,6 @@ struct ImportableRow {
error: Option<String>,
}
/// Removes Claude Code sessions from a machine.
///
/// The transcript *is* the session, so this ends any chance of resuming those
/// conversations. The phone confirms before calling this; the server does not
/// second-guess a decision somebody was shown the cost of.
///
/// A batch and never a single session, which is why this is a POST with a body
/// rather than a `DELETE` on each id. One request per row made a handover only
/// as atomic as the network: leave the screen, lose signal, or have the fourth
@@ -783,9 +595,6 @@ async fn delete_importable(
) -> Result<StatusCode, ApiError> {
let setup = setup_by_id(&manager, &id)?;
let transport = crate::session::transport::Transport::for_setup(&setup);
// Registered before anything is spawned, so the 202 is only sent once
// every row is already showing "deleting" -- a phone that refetches the
// instant it gets the reply cannot catch a row that has not started.
let running: Vec<(String, crate::session::pending::InFlight)> = body
.sessions
.iter()
@@ -798,8 +607,6 @@ async fn delete_importable(
.collect();
let sessions = body.sessions;
tokio::spawn(async move {
// One failure here is the machine being unreachable, which is true of
// every row rather than of any one of them.
let outcomes = match crate::session::import::delete(&transport, &sessions).await {
Ok(outcomes) => outcomes,
Err(err) => {
@@ -824,9 +631,6 @@ async fn delete_importable(
tracing::warn!("deleting {session} on {id} failed: {message}");
flight.failed(message.clone());
}
// `delete` promises an entry per id, so this is a bug rather
// than a state -- but a row stuck on "deleting" for ever is a
// worse answer than one that says so.
None => flight.failed(format!("nothing was reported about {session}")),
}
}
@@ -834,7 +638,6 @@ async fn delete_importable(
Ok(StatusCode::ACCEPTED)
}
/// Which sessions to delete. See [`delete_importable`] for why it is a list.
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
@@ -842,16 +645,12 @@ struct DeleteBatch {
sessions: Vec<String>,
}
/// Continues Claude Code sessions, in the background.
///
/// Separate from `POST /sessions` because the two are asked different
/// questions. That one means "start this and take me to it", so it waits and
/// answers with the session. This one is the import screen's batch: several at
/// once, nobody waiting on any particular one, and the answer arrives as a row
/// changing rather than as a reply -- the screen it was started from may well
/// be gone by then.
///
/// A list for the same reason [`delete_importable`] takes one.
async fn start_import(
State(manager): State<Arc<SessionManager>>,
UrlPath(id): UrlPath<String>,
@@ -864,8 +663,6 @@ async fn start_import(
let request = SpawnRequest {
setup: id.clone(),
provider: body.provider.clone(),
// Nothing to say: `spawn` titles an import from the session it
// continues, and the cwd comes from the same place.
title: None,
model: body.model.clone(),
cwd: None,
@@ -895,8 +692,6 @@ async fn start_import(
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
struct ImportRequest {
/// The sessions to continue, all with the settings below -- they were
/// picked together on one screen, so there is nothing to say per row.
sessions: Vec<String>,
provider: String,
#[serde(default)]
@@ -929,26 +724,18 @@ fn in_background<F>(
}
Err(err) => {
tracing::warn!("{} {session} on {setup} failed: {err:#}", operation.label());
// The server's own words, the way every other failure in this
// app reaches a person.
running.failed(format!("{err:#}"));
}
}
});
}
/// Every change to what is in flight against one machine. Scoped to the setup
/// the screen is showing, the same way a session's events are scoped to that
/// session.
async fn importable_events(
State(manager): State<Arc<SessionManager>>,
UrlPath(id): UrlPath<String>,
) -> Sse<impl tokio_stream::Stream<Item = Result<SseEvent, Infallible>>> {
let live = manager.pending().subscribe();
let stream = BroadcastStream::new(live).filter_map(move |item| {
// A lagged subscriber has missed changes it cannot get back here, and
// that is what the listing is for: the screen refetches on arrival and
// carries the truth whatever this stream missed.
let change = item.ok()?;
if change.setup() != id {
return None;
@@ -965,9 +752,6 @@ async fn spawn_session(
spawn(&manager, body).await.map(axum::Json)
}
/// Starts a session, continuing a Claude Code one where `body.import` names
/// it.
///
/// A function rather than only a handler because the import screen's batch runs
/// this from a background task. Spawning has to mean exactly the same thing
/// either way: the same refusal when something else already has the
@@ -995,13 +779,6 @@ async fn spawn(manager: &Arc<SessionManager>, body: SpawnRequest) -> Result<Sess
only this app's view of it."
)));
}
// Refused rather than warned about, because there is nothing
// useful on the other side of it. Importing an open session puts a
// second `--resume` on a file the first is still writing: the
// conversation gets duplicated into it, each copy replays the
// other's writes as work done elsewhere, and the adopted one is
// billed for re-reading the whole thing. On 2026-08-29 that was
// 65 MB and 154 screenshots.
if chosen.in_use == crate::session::import::InUse::Yes {
return Err(ApiError::BadRequest(format!(
"{want} is open in a terminal right now. Importing it would put a second \
@@ -1012,10 +789,6 @@ async fn spawn(manager: &Arc<SessionManager>, body: SpawnRequest) -> Result<Sess
let records = crate::session::import::read_tail(&transport, &chosen.path)
.await
.map_err(bad_request)?;
// The recorded directory can outlive itself, and resuming into one
// that is gone fails at `cd` before the CLI starts. Starting
// somewhere real keeps the conversation, and the log says which one
// was dropped.
let mut chosen = chosen;
if !crate::session::import::directory_exists(&transport, &chosen.cwd).await {
tracing::warn!(
@@ -1034,11 +807,6 @@ async fn spawn(manager: &Arc<SessionManager>, body: SpawnRequest) -> Result<Sess
let spec = SpawnSpec {
setup: body.setup,
provider: body.provider,
// An imported session is recognised by what it was about, so its
// opening message is the title unless one was typed. Blank normalised
// to absent rather than trusted as a choice: a client with nothing to
// say sends `""`, which is `Some` and so satisfied `or_else`, and every
// import arrived called "claude-cli session".
title: body
.title
.filter(|title| !title.trim().is_empty())
@@ -1048,7 +816,6 @@ async fn spawn(manager: &Arc<SessionManager>, body: SpawnRequest) -> Result<Sess
.filter(|title| !title.trim().is_empty())
}),
model: body.model,
// Resumed where it was working, so the CLI picks up the same tree.
cwd: body.cwd.or_else(|| {
seed.as_ref()
.map(|(chosen, _)| PathBuf::from(&chosen.cwd))
@@ -1070,9 +837,6 @@ async fn spawn(manager: &Arc<SessionManager>, body: SpawnRequest) -> Result<Sess
spec,
crate::session::Seed {
resume: chosen.id.clone(),
// Where it came from and how far it has been shown, so the
// session keeps itself level with the file a terminal is
// also writing to.
cursor: crate::session::import::Cursor {
path: chosen.path,
lines: chosen.lines,
@@ -1118,14 +882,9 @@ async fn delete_session(
.delete_foreign
.then(|| manager.foreign_transcript(&id))
.flatten();
// And *deleted* before it too, so a machine that cannot be reached leaves
// everything as it was rather than a deleted session and a transcript the
// phone has already promised is gone.
if let Some((setup, session)) = &foreign {
let setup = setup_by_id(&manager, setup)?;
let transport = crate::session::transport::Transport::for_setup(&setup);
// A batch of one: the same call, so there is one description of what
// deleting a foreign transcript means.
crate::session::import::delete(&transport, std::slice::from_ref(session))
.await
.map_err(bad_request)?
@@ -1144,8 +903,6 @@ async fn delete_session(
#[serde(deny_unknown_fields)]
struct MessageRequest {
text: String,
/// Ids from `POST /attachments`, uploaded before the message that
/// references them.
#[serde(default)]
attachment_ids: Vec<String>,
}
@@ -1155,8 +912,6 @@ async fn message(
UrlPath(id): UrlPath<String>,
axum::Json(body): axum::Json<MessageRequest>,
) -> Result<StatusCode, ApiError> {
// For the 404 a session that is not here has always answered with; the send
// goes through the manager, which may have to start a process first.
lookup(&manager, &id)?;
if body.text.trim().is_empty() && body.attachment_ids.is_empty() {
return Err(ApiError::BadRequest("message is empty".to_string()));
@@ -1171,18 +926,9 @@ async fn message(
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
struct UnqueueRequest {
/// The id the `messageQueued` event carried, which is what the bubble on
/// screen is drawn from.
message_id: String,
}
/// Takes back a message the session has not read yet.
///
/// The two failures are separate answers rather than one refusal, because
/// they are different things to whoever tapped: `409` means the session has
/// already been told, and `404` means nothing is waiting under that id -- a
/// bubble something else has already resolved. The Claude driver can only
/// ever give the first, since it writes a steer into the CLI on arrival.
async fn unqueue(
State(manager): State<Arc<SessionManager>>,
UrlPath(id): UrlPath<String>,
@@ -1204,9 +950,6 @@ async fn unqueue(
#[serde(deny_unknown_fields)]
struct AnswerRequest {
question_id: String,
/// Everything chosen, in the order it was offered. A question that takes
/// one answer sends a list of one, so there is one shape here rather than
/// a single-answer route and a multi-answer route beside it.
answers: Vec<String>,
}
@@ -1232,11 +975,6 @@ async fn interrupt(
Ok(StatusCode::NO_CONTENT)
}
/// Ends the session's process. The session stays, and `start` brings it back.
///
/// Not `lookup`ed: a session that failed to relaunch has no live entry and may
/// still have a process running, which is exactly one worth being able to
/// stop.
async fn stop(
State(manager): State<Arc<SessionManager>>,
UrlPath(id): UrlPath<String>,
@@ -1245,9 +983,6 @@ async fn stop(
Ok(StatusCode::NO_CONTENT)
}
/// Starts a process for a session that has none, continuing the same
/// conversation. [`SessionManager::start_session`] refuses unless the session
/// is known to have exited.
async fn start(
State(manager): State<Arc<SessionManager>>,
UrlPath(id): UrlPath<String>,
@@ -1256,10 +991,6 @@ async fn start(
Ok(StatusCode::NO_CONTENT)
}
/// The usage screen needs two things that live in different places: the
/// cache, and the current list of machines to ask. Carried together rather
/// than the monitor holding the manager, which would point the dependency
/// upward -- `usage` sits below the session layer.
#[derive(Clone)]
pub struct UsageState {
monitor: Arc<crate::usage::UsageMonitor>,
@@ -1280,11 +1011,7 @@ pub fn usage_router(
async fn usage(
State(state): State<UsageState>,
) -> Result<axum::Json<Vec<crate::usage::UsageSnapshot>>, ApiError> {
// Read here rather than inside the fetch, so the list of machines is the
// one that existed when the request arrived and cannot change under a
// fetch that takes an ssh round trip per machine.
let setups = state.manager.setups();
// The fetch is blocking by design (see `usage`); off the workers.
let snapshots = tokio::task::spawn_blocking(move || state.monitor.snapshots(&setups))
.await
.context("usage fetch panicked")?;
@@ -1315,8 +1042,6 @@ struct CwdRequest {
cwd: PathBuf,
}
/// Moves a session to a different working directory.
///
/// The directory is checked here rather than in the manager because checking
/// it is an ssh round trip on a remote setup, and the manager is not async.
///
@@ -1325,9 +1050,6 @@ struct CwdRequest {
/// cannot start, and the failure would arrive later with nothing pointing at
/// the typo. The spawn path corrects instead because it is resuming a
/// directory the *machine* recorded, which can be gone through nobody's fault.
///
/// It does not start a replacement process; see
/// [`SessionManager::set_session_cwd`].
async fn set_cwd(
State(manager): State<Arc<SessionManager>>,
UrlPath(id): UrlPath<String>,
@@ -1350,10 +1072,6 @@ async fn set_cwd(
setup.name
)));
}
// Stored in the short form, so the one path kept is the one the phone will
// draw -- rather than storing `/home/bob/…` and abbreviating it again at
// each place it is shown, which is two representations of one directory.
// Only where the setup runs here; see `setups::shorten_home`.
let stored = if setup.ssh.is_none() {
crate::setups::shorten_home(&cwd)
} else {
@@ -1407,8 +1125,6 @@ async fn defaults(State(manager): State<Arc<SessionManager>>) -> axum::Json<Defa
})
}
/// Sets what a new session's thinking level is. Applied when a session is
/// spawned, so nothing already running changes underneath anybody.
async fn set_defaults(
State(manager): State<Arc<SessionManager>>,
axum::Json(body): axum::Json<Defaults>,
@@ -1423,15 +1139,10 @@ async fn set_defaults(
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
struct EffortRequest {
/// Absent or null is the CLI's own default, which is a choice somebody can
/// make rather than only a state to start in.
#[serde(default)]
effort: Option<String>,
}
/// Records how hard this session thinks, and stops the process so the next one
/// is launched with it -- `--effort` has no control request behind it. See
/// [`SessionManager::set_session_effort`].
async fn set_effort(
State(manager): State<Arc<SessionManager>>,
UrlPath(id): UrlPath<String>,
@@ -1475,15 +1186,10 @@ async fn set_notify(
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct AutoResumeRequest {
auto_resume: bool,
/// What to send when the limit lifts. Absent -- and empty, which is what a
/// cleared field sends -- means this app's own default word, which is a
/// choice a caller has to be able to make rather than only start in.
#[serde(default)]
message: Option<String>,
}
/// Turns auto-resume on or off, and sets what it would say.
///
/// One request for both, because they are one decision: switching it on
/// without saying what to send is the ordinary case, and changing the words
/// while it is off is how somebody sets it up before it is needed.
@@ -1504,8 +1210,6 @@ struct CommandRequest {
text: String,
}
/// Runs one of the session's own commands, now or at the next boundary.
///
/// The two this server understands are turned into the operations it has, and
/// everything else is passed to the session verbatim, because a dialect's
/// vocabulary is its own and grows without this file.
@@ -1519,10 +1223,6 @@ async fn command(
Some((name, rest)) => (name, rest.trim()),
None => (text, ""),
};
// All of these start the session's process first if it has exited: a
// command is something somebody asked the session to do, and answering that
// its process is gone hands back the work of starting one.
//
// A rename still goes through `rename_session` rather than being a command
// like the rest, because the name is persisted and listed as well as
// forwarded, and that is one operation.
@@ -1552,15 +1252,8 @@ async fn compact(
Ok(StatusCode::NO_CONTENT)
}
/// The most one attachment may be. Streamed to disk, so this bounds the
/// session directory rather than memory; a day of `perfetto` is under a
/// gigabyte, and this leaves room for a few of them.
const ATTACHMENT_LIMIT: usize = 4 * 1024 * 1024 * 1024;
/// Accepts one file (any multipart field) and stores it under the session; the
/// returned id goes into a later `/message`'s attachmentIds. An image is later
/// shown to the model, anything else is named to it by path.
///
/// Written to disk as it arrives rather than collected first: a trace is bigger
/// than this process should hold. Under a `.part` name until it is whole, so a
/// tunnel that drops mid-upload leaves nothing a message could reference.
@@ -1618,7 +1311,6 @@ async fn upload_attachment(
.with_context(|| format!("name {}", path.display()))
.map_err(ApiError::Internal)?;
// Images are not copied: they ride the message itself as base64.
if crate::media::media_type_for(&name).is_none()
&& let Some((ssh, cwd)) = manager.remote_of(&id)
{
@@ -1641,14 +1333,6 @@ async fn upload_attachment(
Ok(axum::Json(serde_json::json!({ "id": name })))
}
/// Copies `local` to the machine `ssh` names, into the configured attachments
/// directory, else `cwd`, else the login home, and returns the absolute path it
/// has there.
///
/// One `ssh` invocation does the copy and answers the path: the file goes over
/// stdin to `cat`, and `pwd -P` afterwards resolves whatever the directory was
/// written as into the path the session will be told. `scp` would need a second
/// round trip for that answer.
async fn ship_attachment(
ssh: &crate::config::SshConfig,
cwd: Option<&Path>,
@@ -1659,15 +1343,10 @@ async fn ship_attachment(
let mut script = String::new();
if let Some(dir) = dir {
let dir = crate::ssh::quote_path(&dir.to_string_lossy());
// Created if missing: a configured directory may not exist yet, and a
// session's own cwd already does, so this costs it nothing.
script.push_str(&format!("mkdir -p {dir} && cd {dir} && "));
}
script.push_str(&format!("cat > {} && pwd -P", crate::ssh::quote(name)));
let source = std::fs::File::open(local).with_context(|| format!("open {}", local.display()))?;
// Through the transport's own "with this on stdin", which the explorer's
// write also uses -- one description of what that means rather than an ssh
// invocation assembled here as well.
let transport = crate::session::transport::Transport::Ssh {
name: ssh.address.clone(),
ssh: ssh.clone(),
@@ -1684,23 +1363,15 @@ async fn ship_attachment(
Ok(format!("{dir}/{name}"))
}
/// Where the remote path of a shipped attachment is recorded, beside it.
/// Read by `ClaudeDriver`'s `attachment_path`; removed with the session.
fn remote_marker(local: &Path) -> std::path::PathBuf {
let name = local.file_name().unwrap_or_default().to_string_lossy();
local.with_file_name(format!("{name}.remote"))
}
/// Serves a session's stored files -- both `files/` (images produced by tools)
/// and `attachments/` (uploaded from the phone), by the id events and uploads
/// reference.
async fn serve_file(
State(manager): State<Arc<SessionManager>>,
UrlPath((id, name)): UrlPath<(String, String)>,
) -> Result<Response, ApiError> {
// Ids are server-generated -- hex and an extension, or hex and a cleaned
// file name; anything else (any path separator in particular) is refused,
// not resolved.
if !name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_')
@@ -1718,13 +1389,9 @@ async fn serve_file(
"no file {name} in session {id}"
)));
};
// A file that is there but unreadable is this server's fault, not the
// request's.
let bytes = std::fs::read(path)
.with_context(|| format!("read {}", path.display()))
.map_err(ApiError::Internal)?;
// Every image this server writes has an extension it knows; the rest are
// files attached by name, served as the bytes they are.
let content_type = crate::media::media_type_for(&name).unwrap_or("application/octet-stream");
Ok(([(axum::http::header::CONTENT_TYPE, content_type)], bytes).into_response())
}
@@ -1735,14 +1402,9 @@ struct EventsQuery {
after: u64,
}
/// The session screen's one data source: replay everything after the cursor
/// from the transcript, then live events. An SSE auto-reconnect sends the last
/// event id it saw as `Last-Event-ID`, which takes precedence over `after` --
/// same cursor, native mechanism.
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct TranscriptQuery {
/// Page backwards from this sequence number; absent means the newest.
#[serde(default)]
before: Option<u64>,
#[serde(default = "default_window")]
@@ -1764,11 +1426,6 @@ fn default_window() -> usize {
80
}
/// A page of a session's transcript, newest first to open with.
///
/// One request rather than one stream frame per event. The SSE stream remains
/// the right shape for *live* events, which arrive one at a time by nature; it
/// is only the backlog that has to stop pretending to be live.
async fn transcript(
State(manager): State<Arc<SessionManager>>,
UrlPath(id): UrlPath<String>,
@@ -1778,8 +1435,6 @@ async fn transcript(
transcript_page(session.transcript_path(), &id, query)
}
/// Exactly [`transcript`]'s route and answer, against one subagent's own
/// transcript instead of its session's -- see `docs/SUBAGENTS.md`'s wire shape.
async fn subagent_transcript(
State(manager): State<Arc<SessionManager>>,
UrlPath((id, sub)): UrlPath<(String, String)>,
@@ -1794,10 +1449,6 @@ async fn subagent_transcript(
)
}
/// A page of history at `path`, newest first to open with -- the one
/// implementation [`transcript`] and [`subagent_transcript`] share, since a
/// subagent's transcript is read exactly the way a session's is. `label` is
/// only for the debug line below.
fn transcript_page(
path: &Path,
label: &str,
@@ -1811,10 +1462,6 @@ fn transcript_page(
query.coalesce,
)
.map_err(bad_request)?;
// How far back a phone has paged, and what each page cost it, which is the
// one question this route raises and nothing else can answer: the app asks
// for events and draws rows, and the ratio between them is a property of
// the conversation. `RUST_LOG=ai_server=debug`.
tracing::debug!(
session = %label,
before = ?query.before,
@@ -1835,8 +1482,6 @@ async fn events(
) -> Result<Sse<impl tokio_stream::Stream<Item = Result<SseEvent, Infallible>>>, ApiError> {
let session = lookup(&manager, &id)?;
let cursor = cursor_of(&headers, 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();
Ok(sse_stream(
session.transcript_path().to_path_buf(),
@@ -1845,8 +1490,6 @@ async fn events(
))
}
/// Exactly [`events`]'s route and answer, against one subagent's own stream
/// instead of its session's -- see `docs/SUBAGENTS.md`'s wire shape.
async fn subagent_events(
State(manager): State<Arc<SessionManager>>,
UrlPath((id, sub)): UrlPath<(String, String)>,
@@ -1860,8 +1503,6 @@ async fn subagent_events(
Ok(sse_stream(subagent.transcript_path(), cursor, live))
}
/// The cursor an SSE reconnect resumes from: the native `Last-Event-ID`
/// takes precedence over the query parameter, same cursor either way.
fn cursor_of(headers: &HeaderMap, query_after: u64) -> u64 {
headers
.get("last-event-id")
@@ -1870,8 +1511,6 @@ fn cursor_of(headers: &HeaderMap, query_after: u64) -> u64 {
.unwrap_or(query_after)
}
/// Spawns the backlog-then-live task and wraps it as the response, the one
/// piece [`events`] and [`subagent_events`] share.
fn sse_stream(
transcript: PathBuf,
cursor: u64,
@@ -1882,20 +1521,11 @@ fn sse_stream(
Sse::new(ReceiverStream::new(stream).map(Ok)).keep_alive(KeepAlive::default())
}
/// `GET /sessions/{id}/subagents`: every subagent this session has started,
/// oldest first, with a status read from its own transcript -- see
/// `docs/SUBAGENTS.md`'s wire shape. A subagent whose last status is `Running` is
/// reported `Unknown` instead when the session itself is not running: its
/// process was the session's, and a session with none has nothing left to
/// ask.
async fn list_subagents(
State(manager): State<Arc<SessionManager>>,
UrlPath(id): UrlPath<String>,
) -> Result<axum::Json<Vec<SubagentInfo>>, ApiError> {
let session = lookup(&manager, &id)?;
// Anything but `Exited` or `Unknown` has a process behind it, which is
// what decides whether a subagent still reading `Running` from its own
// transcript can be believed -- see `docs/SUBAGENTS.md`'s wire shape.
let running = !matches!(
session.status(),
crate::session::driver::SessionStatus::Exited
@@ -1904,32 +1534,17 @@ async fn list_subagents(
Ok(axum::Json(session.subagents().list(running)))
}
/// Every session's attention-wanting moments, on one stream.
///
/// **Live only, with no cursor**, which is the one place this server does not
/// offer to catch a client up. A notification is a claim about now: replaying
/// "your turn" from an hour ago sends somebody to a session that may have been
/// answered from another device since, and a notification that is wrong costs
/// the reader the trip *and* teaches them to distrust the next one. What was
/// missed is still on the session list, which answers "what is waiting"
/// without claiming to be news.
async fn notifications(
State(manager): State<Arc<SessionManager>>,
) -> Sse<impl tokio_stream::Stream<Item = Result<SseEvent, Infallible>>> {
let live = manager.subscribe_notifications();
let stream = BroadcastStream::new(live).filter_map(|item| {
// A lagged subscriber has lost the oldest notifications, and the ones
// it still gets are the recent ones -- the ones worth acting on.
let notification = item.ok()?;
Some(Ok(SseEvent::default().json_data(&notification).ok()?))
});
Sse::new(stream).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,
@@ -1960,15 +1575,6 @@ async fn stream_session(
}
}
/// Sends everything after `last`, advancing it, and answers whether the
/// subscriber is still there.
///
/// A [`CatchUp::Restart`] is preceded by the `reset` frame that tells the
/// client to drop what it holds. Without it the window would be spliced onto
/// rows that are no longer adjacent to it, which reads as ordinary output
/// rather than as a gap -- which is why a bounded backlog cannot simply be
/// "the newest events".
///
/// Both ways into a backlog come through here -- the first replay and the
/// recovery from a lapped broadcast -- because either can be arbitrarily far
/// behind and owes the client the same answer.
@@ -2014,11 +1620,6 @@ async fn send_event(
}
/// Separate router because its state is the model store, like `usage`'s.
///
/// Keys are `owner/repo/file.gguf` and so contain slashes, which is why
/// nothing here puts one in the path: a key travels in the body or a query
/// string, and the routes stay addressable without escaping rules nobody would
/// get right from a phone.
pub fn models_router(store: Arc<crate::models::ModelStore>) -> Router {
Router::new()
.route("/models", get(list_models))
@@ -2030,10 +1631,6 @@ pub fn models_router(store: Arc<crate::models::ModelStore>) -> Router {
.with_state(store)
}
/// What this machine has and what it is fetching, in one answer. Both
/// together deliberately: a phone showing the model list needs both to draw
/// one screen, and two routes would let it render a model as absent while its
/// download sits at 99%.
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct ModelsResponse {
@@ -2061,7 +1658,6 @@ struct SearchQuery {
async fn search_models(
Query(query): Query<SearchQuery>,
) -> Result<axum::Json<Vec<crate::models::RemoteRepo>>, ApiError> {
// Blocking HTTP, like the usage fetch: off the request workers.
let found = tokio::task::spawn_blocking(move || crate::models::search(&query.q))
.await
.context("model search panicked")?
@@ -2091,7 +1687,6 @@ struct DownloadRequest {
file: String,
}
/// Starts a download, or rejoins the one already running for that model.
async fn start_download(
State(store): State<Arc<crate::models::ModelStore>>,
axum::Json(body): axum::Json<DownloadRequest>,