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:
1 parent
b172c464ea
commit
3c159fa1e1
12 files changed
+992
-176
No files matched your search
@@ -283,8 +283,50 @@ data class Importable(
|
||||
* server refuses an import of a "yes"; the row says so before you press it.
|
||||
*/
|
||||
val inUse: String,
|
||||
/**
|
||||
* What this server is doing to the session right now -- "importing" or "deleting" -- or null
|
||||
* when nothing is.
|
||||
*
|
||||
* The server's answer rather than the phone's, because the work outlives the screen that asked
|
||||
* for it: leaving the import list and coming back has to show what is still running, and a
|
||||
* phone that was asleep or out of range never saw the events that said so.
|
||||
*/
|
||||
val pending: String?,
|
||||
/**
|
||||
* How the last attempt on this row failed, if it did. Kept by the server until something
|
||||
* replaces it, for the same reason [pending] is the server's to answer.
|
||||
*/
|
||||
val error: String?,
|
||||
)
|
||||
|
||||
/**
|
||||
* One frame of `GET /setups/{id}/importable/events`: an operation starting, finishing or failing.
|
||||
*
|
||||
* [operation] is only set by a start, and [message] only by a failure -- the three states are every
|
||||
* way an operation can be, and each carries exactly what that state knows.
|
||||
*/
|
||||
data class ImportableChange(
|
||||
val session: String,
|
||||
val state: String,
|
||||
val operation: String?,
|
||||
val message: String?,
|
||||
)
|
||||
|
||||
fun parseImportableChange(payload: String): ImportableChange? =
|
||||
try {
|
||||
val frame = JSONObject(payload)
|
||||
ImportableChange(
|
||||
session = frame.getString("session"),
|
||||
state = frame.getString("state"),
|
||||
operation = frame.optString("operation").takeIf { it.isNotEmpty() },
|
||||
message = frame.optString("message").takeIf { it.isNotEmpty() },
|
||||
)
|
||||
} catch (_: org.json.JSONException) {
|
||||
// A frame this build does not understand is not a reason to drop the stream: the listing
|
||||
// is the truth and will say what happened whatever this missed.
|
||||
null
|
||||
}
|
||||
|
||||
/**
|
||||
* What a machine has that could be continued.
|
||||
*
|
||||
@@ -314,6 +356,8 @@ fun fetchImportable(settings: ServerSettings, setup: String): List<Importable> =
|
||||
// "unknown" says -- so the default is the honest one rather than "no".
|
||||
inUse = session.optString("inUse", "unknown"),
|
||||
named = session.optBoolean("named", false),
|
||||
pending = session.optString("pending").takeIf { it.isNotEmpty() },
|
||||
error = session.optString("error").takeIf { it.isNotEmpty() },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -590,10 +634,47 @@ fun startSession(settings: ServerSettings, sessionId: String) {
|
||||
* The transcript *is* the session, so this ends any chance of resuming that conversation. The
|
||||
* caller confirms first; see ImportScreen.
|
||||
*/
|
||||
/**
|
||||
* Asks the machine to delete a Claude Code session, and returns as soon as it has accepted.
|
||||
*
|
||||
* The work runs on the server, so this returning is not the same as it being done -- what says that
|
||||
* is the row's own state, through [fetchImportable] and the change stream. That is the point:
|
||||
* leaving the screen used to cancel the delete it had started.
|
||||
*/
|
||||
fun deleteImportable(settings: ServerSettings, setup: String, sessionId: String) {
|
||||
requestFromServer(settings, "/setups/$setup/importable/$sessionId", method = "DELETE") {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Continues a Claude Code session in the background, returning once the server has accepted.
|
||||
*
|
||||
* Separate from [spawnSession] 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 is the import list's
|
||||
* batch: several at once, nobody waiting on any particular one, and the result arrives as a row
|
||||
* changing rather than as a reply -- which is what lets the screen be left.
|
||||
*/
|
||||
fun startImport(
|
||||
settings: ServerSettings,
|
||||
setup: String,
|
||||
sessionId: String,
|
||||
provider: String,
|
||||
permissionMode: String? = null,
|
||||
model: String? = null,
|
||||
) {
|
||||
val body =
|
||||
JSONObject().apply {
|
||||
put("provider", provider)
|
||||
permissionMode?.let { put("permissionMode", it) }
|
||||
model?.let { put("model", it) }
|
||||
}
|
||||
requestFromServer(
|
||||
settings,
|
||||
"/setups/$setup/importable/$sessionId/import",
|
||||
method = "POST",
|
||||
jsonBody = body.toString(),
|
||||
) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* A page of a session's transcript, oldest first within the page.
|
||||
*
|
||||
|
||||
Reference in new issue
Block a user