Run imports and deletes on the server, and say so on an event

Leaving the import screen used to cancel the batch it had started: the
request was the work, so the coroutine that owned it died with the screen
and coming back showed no sign anything had happened. A half-imported
session is the expensive kind of missing -- the row is back looking
untouched, and taking it again is the second `--resume` the import path
exists to prevent.

So the work runs on the server now. Delete and a new per-session import both
answer 202 and spawn the work, and `session::pending` is the record of it:
what is running, and how the last attempt failed. The phone reads that two
ways and needs both. Every row of the listing carries `pending` and `error`,
which is what a phone that was asleep, out of range or freshly opened has to
go on; `GET /setups/{id}/importable/events` streams the changes, which is
what makes a screen somebody is watching change by itself.

Neither alone is enough, and that is not theoretical. A broadcast has no
memory, so an operation that started and finished while the stream was still
connecting was one nothing would ever be said about -- with responses held
back far enough to make it visible, one row of a pair of deletes cleared and
the other sat on "waiting" for good. The screen now asks again after a
handover when anything still looks outstanding, and takes its row states
from that answer rather than from what it remembers.

The single tap still waits, because "take me to it" needs the session that
was made and 202 does not carry one. Both paths go through the same `spawn`
so they cannot drift about what importing means.

Resolving one importable session no longer lists every one of them:
`import::find` is the same script with one glob narrower, which takes the
import seed off the 3.7-second full scan that `delete` came off earlier.

The SSE connection and its framing are now `Sse`, shared with the session
transcript stream rather than written a second time.
This commit is contained in:
iris committed 2026-08-31 21:05:33 -04:00
1 parent b172c464ea
commit 3c159fa1e1
12 files changed
+992 -176

No files matched your search

+188 -16
View File
@@ -63,12 +63,13 @@ use axum::http::{HeaderMap, StatusCode};
use axum::response::sse::{Event as SseEvent, KeepAlive, Sse};
use axum::response::{IntoResponse, Response};
use axum::routing::{delete, get, post};
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use tokio::sync::{broadcast, mpsc};
use tokio_stream::StreamExt;
use tokio_stream::wrappers::{BroadcastStream, ReceiverStream};
use crate::session::driver::SessionCommand;
use crate::session::pending::Operation;
use crate::session::transcript::{CATCH_UP_LIMIT, CatchUp, SeqEvent, catch_up};
use crate::session::{LiveSession, SessionInfo, SessionManager, SpawnSpec};
@@ -81,6 +82,13 @@ pub fn router(manager: Arc<SessionManager>) -> Router {
"/setups/{id}/importable/{session}",
delete(delete_importable),
)
.route(
"/setups/{id}/importable/{session}/import",
post(start_import),
)
// Static segment, so this wins over `{session}` above rather than
// being read as a session called "events".
.route("/setups/{id}/importable/events", get(importable_events))
.route(
"/setups/{id}",
get(read_setup).put(update_setup).delete(delete_setup),
@@ -450,7 +458,7 @@ struct SpawnRequest {
async fn list_importable(
State(manager): State<Arc<SessionManager>>,
UrlPath(id): UrlPath<String>,
) -> Result<axum::Json<Vec<crate::session::import::Importable>>, ApiError> {
) -> Result<axum::Json<Vec<ImportableRow>>, ApiError> {
let setup = setup_by_id(&manager, &id)?;
let transport = crate::session::transport::Transport::for_setup(&setup);
let mut found = crate::session::import::list(&transport)
@@ -464,8 +472,59 @@ async fn list_importable(
// Joined here because the importer knows about files and the manager
// knows about sessions, and putting the two together is the route's
// job rather than either one's.
found.retain(|candidate| manager.session_driving(&candidate.id).is_none());
Ok(axum::Json(found))
//
// 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 be
// showing nothing at all where the thing it is waiting for used to be.
// A row with an operation on it stays until the operation settles.
found.retain(|candidate| {
manager.pending().running(&id, &candidate.id).is_some()
|| manager.session_driving(&candidate.id).is_none()
});
// What the server is doing to each of them, joined on here because a
// phone that was asleep, out of range, or freshly opened never heard
// the events -- see `pending`. An operation is *not* filtered out
// above: a row being imported has to stay visible, marked, or the list
// would say the work never started.
let present: Vec<String> = found.iter().map(|row| row.id.clone()).collect();
manager.pending().prune(&id, &present);
let rows: Vec<ImportableRow> = found
.into_iter()
.map(|importable| ImportableRow {
pending: manager
.pending()
.running(&id, &importable.id)
.map(|operation| operation.label()),
error: manager.pending().failure(&id, &importable.id),
importable,
})
.collect();
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 between "what the machine
/// said" and "what we are doing about it".
#[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
/// something replaces it, because the phone that needs to see it may
/// not have been connected when it happened.
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
}
/// Removes a Claude Code session from a machine.
@@ -480,30 +539,143 @@ async fn delete_importable(
) -> Result<StatusCode, ApiError> {
let setup = setup_by_id(&manager, &id)?;
let transport = crate::session::transport::Transport::for_setup(&setup);
crate::session::import::delete(&transport, &session)
.await
.map_err(bad_request)?;
tracing::info!("deleted Claude Code session {session} from setup {id}");
Ok(StatusCode::NO_CONTENT)
let target = session.clone();
in_background(&manager, id, session, Operation::Deleting, async move {
crate::session::import::delete(&transport, &target).await
});
Ok(StatusCode::ACCEPTED)
}
/// Continues a Claude Code session, 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 -- which is the whole
/// point, since the screen it was started from may well be gone by then.
async fn start_import(
State(manager): State<Arc<SessionManager>>,
UrlPath((id, session)): UrlPath<(String, String)>,
axum::Json(body): axum::Json<ImportRequest>,
) -> Result<StatusCode, ApiError> {
// Checked before accepting, so an unknown machine is still an error the
// caller sees rather than a failure it has to go and read off a row.
setup_by_id(&manager, &id)?;
let request = SpawnRequest {
setup: id.clone(),
provider: body.provider,
// 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,
cwd: None,
permission_mode: body.permission_mode,
params: std::collections::BTreeMap::new(),
import: Some(session.clone()),
};
let inner = Arc::clone(&manager);
in_background(&manager, id, session, Operation::Importing, async move {
spawn(&inner, request)
.await
.map(|_| ())
.map_err(|err| anyhow::anyhow!("{err}"))
});
Ok(StatusCode::ACCEPTED)
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
struct ImportRequest {
provider: String,
#[serde(default)]
model: Option<String>,
#[serde(default)]
permission_mode: Option<String>,
}
/// Runs `work` on the server, marked as in flight for as long as it takes.
///
/// Spawned rather than awaited, which is the whole difference: the phone
/// asked for it, but the phone leaving must not cancel it. What replaces
/// the reply is the pending registry -- the row says what is happening to
/// it, whoever is looking and whenever they look.
fn in_background<F>(
manager: &Arc<SessionManager>,
setup: String,
session: String,
operation: Operation,
work: F,
) where
F: std::future::Future<Output = anyhow::Result<()>> + Send + 'static,
{
let running = manager.pending().begin(&setup, &session, operation);
tokio::spawn(async move {
match work.await {
Ok(()) => {
tracing::info!("{} {session} on {setup}: done", operation.label());
running.succeeded();
}
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 -- a phone watching one machine's
/// import list has no use for another's.
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;
}
Some(Ok(SseEvent::default().json_data(&change).ok()?))
});
Sse::new(stream).keep_alive(KeepAlive::default())
}
async fn spawn_session(
State(manager): State<Arc<SessionManager>>,
axum::Json(body): axum::Json<SpawnRequest>,
) -> Result<axum::Json<SessionInfo>, ApiError> {
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 -- see [`start_import`]. Spawning has to
/// mean exactly the same thing either way: the same refusal when something
/// else already has the conversation open, the same title, the same working
/// directory.
async fn spawn(manager: &Arc<SessionManager>, body: SpawnRequest) -> Result<SessionInfo, ApiError> {
// Resolved before the spawn because both halves of it are the
// machine's answer, not the phone's: which file that id names, and
// what is in it.
let seed = match &body.import {
Some(want) => {
let setup = setup_by_id(&manager, &body.setup)?;
let setup = setup_by_id(manager, &body.setup)?;
let transport = crate::session::transport::Transport::for_setup(&setup);
let found = crate::session::import::list(&transport)
let chosen = crate::session::import::find(&transport, want)
.await
.map_err(bad_request)?;
let chosen = found
.into_iter()
.find(|candidate| &candidate.id == want)
.map_err(bad_request)?
.ok_or_else(|| {
ApiError::NotFound(format!(
"setup \"{}\" has no Claude Code session {want} to import",
@@ -615,7 +787,7 @@ async fn spawn_session(
info.id,
info.title
);
Ok(axum::Json(info))
Ok(info)
}
#[derive(Deserialize)]