Prune commentary and stale Rust port notes
This commit is contained in:
1 parent
5428cd75c9
commit
25370731d0
193 files changed
+693
-16219
No files matched your search
@@ -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(¬ification).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>,
|
||||
|
||||
Reference in new issue
Block a user