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.
|
||||
*
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import java.io.IOException
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
/**
|
||||
* The frame name the server uses to say a cursor was too far behind to continue from. Must match
|
||||
* `send_backlog` in the backend's routes.rs.
|
||||
@@ -14,88 +10,29 @@ private const val RESET_EVENT = "reset"
|
||||
* The SSE half of the API: one long-lived GET per open session screen, replaying the transcript
|
||||
* after a cursor and then following it live.
|
||||
*
|
||||
* Blocking -- run() occupies its thread until the stream ends. [close] (from any thread) is the
|
||||
* cancellation path: it disconnects the socket, which unblocks the read; run() then returns instead
|
||||
* of throwing, so a deliberate close doesn't surface as a connection error. The caller owns
|
||||
* reconnecting (with the last seq it saw as the new cursor) -- see SessionScreen.
|
||||
* The connection and its framing belong to [Sse]; what stays here is what this stream's frames
|
||||
* mean. [close] from any thread ends it, and the caller owns reconnecting -- with the last seq it
|
||||
* saw as the new cursor. See SessionScreen.
|
||||
*/
|
||||
class EventStream(private val settings: ServerSettings, private val sessionId: String) {
|
||||
@Volatile private var connection: HttpURLConnection? = null
|
||||
@Volatile private var closed = false
|
||||
class EventStream(settings: ServerSettings, private val sessionId: String) {
|
||||
private val stream = Sse(settings)
|
||||
|
||||
fun close() {
|
||||
closed = true
|
||||
connection?.disconnect()
|
||||
}
|
||||
fun close() = stream.close()
|
||||
|
||||
/**
|
||||
* Streams events after [after] into [onEvent] until the stream drops.
|
||||
*
|
||||
* [onOpen] fires once the server has accepted the connection. That is the measured moment the
|
||||
* stream is live again, and the only honest thing to clear a previous failure on: an earlier
|
||||
* version cleared on the first event instead, so an idle session went on displaying a
|
||||
* connection error that had already been recovered from, indefinitely.
|
||||
*
|
||||
* [onReset] fires when the server answers that the cursor is too far behind to continue from:
|
||||
* everything already displayed is stale and the events that follow are a fresh window, so the
|
||||
* caller drops what it holds and rebuilds -- the same thing it does when the screen opens. It
|
||||
* arrives before those events, so a caller that clears on it stays in order.
|
||||
*/
|
||||
fun run(after: Long, onOpen: () -> Unit, onReset: () -> Unit, onEvent: (SeqEvent) -> Unit) {
|
||||
val connection =
|
||||
URL("${settings.baseUrl}/sessions/$sessionId/events?after=$after").openConnection()
|
||||
as HttpURLConnection
|
||||
this.connection = connection
|
||||
try {
|
||||
connection.applyPinnedTls()
|
||||
connection.connectTimeout = CONNECT_TIMEOUT_MS
|
||||
// No read timeout: between events there is nothing to read for
|
||||
// as long as the session is idle; the server's keep-alives and
|
||||
// a dead socket erroring out are the liveness story.
|
||||
connection.readTimeout = 0
|
||||
connection.setRequestProperty("Authorization", "Bearer ${settings.token}")
|
||||
connection.setRequestProperty("Accept", "text/event-stream")
|
||||
if (connection.responseCode != 200) {
|
||||
val detail = connection.errorStream?.bufferedReader()?.readText()?.trim()
|
||||
throw ApiException(detail ?: "HTTP ${connection.responseCode} for the event stream")
|
||||
}
|
||||
|
||||
onOpen()
|
||||
val reader = connection.inputStream.bufferedReader()
|
||||
// SSE framing: `data:` and `event:` lines accumulate until a
|
||||
// blank line ends the frame. `id:` (the seq) is also inside the
|
||||
// JSON payload, so it needs no separate handling; comment lines
|
||||
// (keep-alives) start with ':' and are skipped.
|
||||
val data = StringBuilder()
|
||||
var name: String? = null
|
||||
while (true) {
|
||||
val line = reader.readLine() ?: break
|
||||
when {
|
||||
line.isEmpty() -> {
|
||||
// A named frame carries no payload and a data frame
|
||||
// has no name, so this is one or the other.
|
||||
if (name == RESET_EVENT) onReset()
|
||||
else if (data.isNotEmpty()) onEvent(parseSeqEvent(data.toString()))
|
||||
data.clear()
|
||||
name = null
|
||||
}
|
||||
line.startsWith("data:") -> data.append(line.removePrefix("data:").trim())
|
||||
line.startsWith("event:") -> name = line.removePrefix("event:").trim()
|
||||
else -> {} // id:, comments -- nothing to do
|
||||
}
|
||||
}
|
||||
} catch (e: ApiException) {
|
||||
throw e
|
||||
} catch (e: IOException) {
|
||||
if (!closed) {
|
||||
throw ApiException(
|
||||
"Can't reach the server -- retrying. (${e.message ?: e::class.simpleName})",
|
||||
e,
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
this.connection = null
|
||||
stream.run("/sessions/$sessionId/events?after=$after", onOpen) { name, data ->
|
||||
// A named frame carries no payload and a data frame has no name, so this is one or
|
||||
// the other.
|
||||
if (name == RESET_EVENT) onReset()
|
||||
else if (data.isNotEmpty()) onEvent(parseSeqEvent(data))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -38,6 +39,9 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@@ -115,19 +119,36 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
|
||||
val movedAt = remember { mutableMapOf<String, Long>() }
|
||||
fun settling(id: String) = System.currentTimeMillis() - (movedAt[id] ?: 0L) < SETTLE_MS
|
||||
|
||||
/**
|
||||
* Fetches the list and takes the row states from it.
|
||||
*
|
||||
* Taken from the answer rather than kept across the load: the server is what knows what is
|
||||
* running, and this screen may be opening on work another screen -- or another phone --
|
||||
* started. Anything held locally would be a second version of that, and the stale one.
|
||||
*/
|
||||
suspend fun fetchInto(setup: Setup): LoadState<List<Importable>> =
|
||||
try {
|
||||
val rows = withContext(Dispatchers.IO) { fetchImportable(settings, setup.id) }
|
||||
running = rows.mapNotNull { row -> row.pending?.let { row.id to it } }.toMap()
|
||||
rowErrors = rows.mapNotNull { row -> row.error?.let { row.id to it } }.toMap()
|
||||
LoadState.Loaded(rows)
|
||||
} catch (err: Exception) {
|
||||
LoadState.Error(err.message ?: "Couldn't list sessions")
|
||||
}
|
||||
|
||||
fun loadSessions(setup: Setup) {
|
||||
sessions = LoadState.Loading
|
||||
selected = emptySet()
|
||||
rowErrors = emptyMap()
|
||||
scope.launch {
|
||||
sessions =
|
||||
try {
|
||||
LoadState.Loaded(
|
||||
withContext(Dispatchers.IO) { fetchImportable(settings, setup.id) }
|
||||
)
|
||||
} catch (err: Exception) {
|
||||
LoadState.Error(err.message ?: "Couldn't list sessions")
|
||||
}
|
||||
scope.launch { sessions = fetchInto(setup) }
|
||||
}
|
||||
|
||||
/** Takes a row out of the list, once the machine no longer has it to offer. */
|
||||
fun forget(id: String) {
|
||||
val loaded = sessions
|
||||
if (loaded is LoadState.Loaded) {
|
||||
// Only this row, and only what changed -- refetching instead put every other row back
|
||||
// through a loading spinner to report a change that was never in doubt.
|
||||
sessions = LoadState.Loaded(loaded.value.filterNot { it.id == id })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,58 +167,62 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs [operation] over [targets] one at a time, marking each row with [label] while its turn
|
||||
* lasts and taking it off the list when it succeeds.
|
||||
* Hands [targets] to the server, marking each row as it goes.
|
||||
*
|
||||
* One runner for both operations and for both the single tap and the batch, so "what a row
|
||||
* looks like while something is happening to it" and "what happens when one of ten fails" are
|
||||
* decided once. Sequentially, because each import starts a CLI on the machine and ten at once
|
||||
* is a load nobody asked for; the reader sees the work walk down the list, which is also the
|
||||
* only honest progress this screen can show.
|
||||
* The requests only *start* the work now -- the server runs it and says how it went on the
|
||||
* change stream, which is what lets this screen be left while a batch is still going. So there
|
||||
* is nothing here to wait for and nothing to sequence: each row is marked, its request goes,
|
||||
* and everything after that arrives as an event.
|
||||
*
|
||||
* The selection is dropped the moment the work is handed over, not when it finishes: the screen
|
||||
* goes back to how it started, and what says the work is happening is the rows it is happening
|
||||
* to. Holding the selection until the end left the bar up over rows that could no longer be
|
||||
* pressed, offering to start again something already running.
|
||||
* Marked [WAITING] rather than with the operation's own word until the server confirms. Between
|
||||
* the request leaving and the `started` event coming back, "we have asked" is the truth and "it
|
||||
* is importing" is a guess -- and the row is inert either way, which is the part that matters.
|
||||
*
|
||||
* A failure keeps its row and puts the server's words on it. Selecting those rows again is then
|
||||
* the reader's decision rather than a state the screen carried for them — and it is the
|
||||
* decision worth making deliberately, because retrying a delete that the server refused is
|
||||
* usually not what somebody wants to do by pressing the same button twice.
|
||||
* The selection is dropped as the work is handed over, not when it finishes: the screen goes
|
||||
* back to how it started, and what says the work is happening is the rows it is happening to.
|
||||
*/
|
||||
fun runOn(targets: List<Importable>, label: String, operation: suspend (Importable) -> Unit) {
|
||||
fun handOver(targets: List<Importable>, send: suspend (Importable) -> Unit) {
|
||||
selected = emptySet()
|
||||
running = running + targets.associate { it.id to WAITING }
|
||||
rowErrors = rowErrors - targets.map { it.id }.toSet()
|
||||
val setup = chosen
|
||||
scope.launch {
|
||||
for (target in targets) {
|
||||
running = running + (target.id to label)
|
||||
rowErrors = rowErrors - target.id
|
||||
try {
|
||||
operation(target)
|
||||
val loaded = sessions
|
||||
if (loaded is LoadState.Loaded) {
|
||||
// As each one lands, not all of them at the end. Holding the finished
|
||||
// rows in place to keep the list still was tried and is worse: a row
|
||||
// that has been imported but is still sitting there looks exactly like
|
||||
// one that has not, and tapping it starts a second CLI on the same
|
||||
// transcript. A row that is gone cannot be tapped at all.
|
||||
//
|
||||
// Only this row, and only what changed -- refetching instead put every
|
||||
// other row back through a loading spinner to report a change that was
|
||||
// never in doubt.
|
||||
val now = System.currentTimeMillis()
|
||||
loaded.value
|
||||
.asSequence()
|
||||
.dropWhile { it.id != target.id }
|
||||
.drop(1)
|
||||
.forEach { movedAt[it.id] = now }
|
||||
sessions = LoadState.Loaded(loaded.value.filterNot { it.id == target.id })
|
||||
// All at once rather than a loop that awaits each: the handover is what has to
|
||||
// survive leaving the screen, so it should take one round trip rather than one per
|
||||
// row. What each request starts is already safe once the server has it.
|
||||
targets
|
||||
.map { target ->
|
||||
async {
|
||||
try {
|
||||
withContext(Dispatchers.IO) { send(target) }
|
||||
} catch (err: Exception) {
|
||||
// The server never took it, so nothing is running and no event will
|
||||
// arrive to say so. This is the one failure the screen must report
|
||||
// itself.
|
||||
running = running - target.id
|
||||
rowErrors = rowErrors + (target.id to (err.message ?: "Couldn't ask"))
|
||||
}
|
||||
}
|
||||
} catch (err: Exception) {
|
||||
rowErrors = rowErrors + (target.id to (err.message ?: "Didn't work"))
|
||||
} finally {
|
||||
running = running - target.id
|
||||
}
|
||||
.awaitAll()
|
||||
|
||||
// Then ask what actually happened, if anything still looks outstanding.
|
||||
//
|
||||
// The change stream is a broadcast with no memory, so an operation that started and
|
||||
// finished while it was still connecting is one nothing will ever be said about --
|
||||
// and the row sits marked for ever. That is not hypothetical: with responses held
|
||||
// back far enough for the stream to open late, one row of a pair of deletes cleared
|
||||
// and the other stayed on "waiting".
|
||||
//
|
||||
// The listing is the repair, because it carries the same state the events do. Only
|
||||
// when something still looks outstanding, so the ordinary case -- where the events
|
||||
// arrived and the rows are already gone -- does not pay for a second listing, which
|
||||
// is the most expensive call this screen makes.
|
||||
if (setup != null && targets.any { running.containsKey(it.id) }) {
|
||||
// Quietly: no Loading, because blanking the list to report on rows that are
|
||||
// already saying what is happening to them is the flicker this screen avoids
|
||||
// everywhere else.
|
||||
sessions = fetchInto(setup)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -211,26 +236,111 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
|
||||
* and then looking at it is what a tap on a row means, and a batch has several results and no
|
||||
* reason to pick one of them to become the screen.
|
||||
*/
|
||||
fun importAll(targets: List<Importable>, thenOpen: Boolean) {
|
||||
/** Continues [targets] in the background, leaving the screen where it is. */
|
||||
fun importAll(targets: List<Importable>) {
|
||||
val setup = chosen ?: return
|
||||
val useProvider = provider ?: return
|
||||
runOn(targets, IMPORTING) { session ->
|
||||
val spawned =
|
||||
withContext(Dispatchers.IO) {
|
||||
spawnSession(
|
||||
settings,
|
||||
setup = setup.id,
|
||||
provider = useProvider.name,
|
||||
// Nothing to say: the server titles it from the session it is continuing.
|
||||
title = "",
|
||||
permissionMode = permissionMode,
|
||||
import = session.id,
|
||||
)
|
||||
}
|
||||
if (thenOpen) onImported(spawned)
|
||||
handOver(targets) { session ->
|
||||
startImport(
|
||||
settings,
|
||||
setup = setup.id,
|
||||
sessionId = session.id,
|
||||
provider = useProvider.name,
|
||||
permissionMode = permissionMode,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Continues one session and goes to it.
|
||||
*
|
||||
* The tap keeps waiting, because "take me there" needs the session it made and the server's
|
||||
* accepted-and-running answer does not carry one. It is one session and somebody is watching
|
||||
* it, which is the case where waiting is the right thing anyway.
|
||||
*/
|
||||
fun importAndOpen(target: Importable) {
|
||||
val setup = chosen ?: return
|
||||
val useProvider = provider ?: return
|
||||
running = running + (target.id to IMPORTING)
|
||||
rowErrors = rowErrors - target.id
|
||||
scope.launch {
|
||||
try {
|
||||
val spawned =
|
||||
withContext(Dispatchers.IO) {
|
||||
spawnSession(
|
||||
settings,
|
||||
setup = setup.id,
|
||||
provider = useProvider.name,
|
||||
// Nothing to say: the server titles it from the session it continues.
|
||||
title = "",
|
||||
permissionMode = permissionMode,
|
||||
import = target.id,
|
||||
)
|
||||
}
|
||||
forget(target.id)
|
||||
onImported(spawned)
|
||||
} catch (err: Exception) {
|
||||
rowErrors = rowErrors + (target.id to (err.message ?: "Couldn't import that one"))
|
||||
} finally {
|
||||
running = running - target.id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Live changes to what the server is doing to these sessions, for as long as this screen is
|
||||
// up. The listing already carried the same state when the screen opened -- this is what keeps
|
||||
// it current afterwards, including for work another screen or another phone started.
|
||||
//
|
||||
// Failures here are deliberately quiet. There is nothing for a reader to do about a dropped
|
||||
// event stream, and nothing is lost by one: every state it would have carried is in the next
|
||||
// listing, which is what Refresh and re-entering the tab already fetch.
|
||||
val liveChanges = remember {
|
||||
java.util.concurrent.atomic.AtomicReference<ImportableStream?>(null)
|
||||
}
|
||||
LaunchedEffect(chosen?.id) {
|
||||
val setup = chosen?.id ?: return@LaunchedEffect
|
||||
try {
|
||||
while (true) {
|
||||
val stream = ImportableStream(settings, setup)
|
||||
liveChanges.set(stream)
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
stream.run(onOpen = {}) { change ->
|
||||
when (change.state) {
|
||||
"started" ->
|
||||
running =
|
||||
running + (change.session to (change.operation ?: WAITING))
|
||||
// Gone from the machine either way: a delete removed the
|
||||
// transcript, an import made it a session, and neither is
|
||||
// something this list still has to offer.
|
||||
"finished" -> {
|
||||
running = running - change.session
|
||||
forget(change.session)
|
||||
}
|
||||
"failed" -> {
|
||||
running = running - change.session
|
||||
rowErrors =
|
||||
rowErrors +
|
||||
(change.session to (change.message ?: "Didn't work"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_: ApiException) {
|
||||
// Retried below; the listing is the truth in the meantime.
|
||||
} finally {
|
||||
stream.close()
|
||||
}
|
||||
delay(RECONNECT_DELAY_MS)
|
||||
}
|
||||
} finally {
|
||||
// Cancellation cannot interrupt a blocking socket read; closing is what unblocks it.
|
||||
liveChanges.getAndSet(null)?.close()
|
||||
}
|
||||
}
|
||||
// The screen leaving the composition entirely, which the effect above does not cover.
|
||||
DisposableEffect(chosen?.id) { onDispose { liveChanges.get()?.close() } }
|
||||
|
||||
// Back leaves selection mode rather than the tab, which is the level it is one step above.
|
||||
// Nested inside MainScreen's own handler, so it wins while there is a selection.
|
||||
BackHandler(enabled = selected.isNotEmpty()) { selected = emptySet() }
|
||||
@@ -305,7 +415,7 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
|
||||
if (session.id in selected) selected - session.id
|
||||
else selected + session.id
|
||||
},
|
||||
onOpen = { session -> importAll(listOf(session), thenOpen = true) },
|
||||
onOpen = { session -> importAndOpen(session) },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -324,7 +434,7 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
|
||||
barHeight = with(density) { it.height.toDp() }
|
||||
},
|
||||
onDelete = { confirming = picked },
|
||||
onImport = { importAll(picked, thenOpen = false) },
|
||||
onImport = { importAll(picked) },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -354,10 +464,8 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
|
||||
onClick = {
|
||||
val setup = chosen ?: return@TextButton
|
||||
confirming = null
|
||||
runOn(targets, DELETING) { session ->
|
||||
withContext(Dispatchers.IO) {
|
||||
deleteImportable(settings, setup.id, session.id)
|
||||
}
|
||||
handOver(targets) { session ->
|
||||
deleteImportable(settings, setup.id, session.id)
|
||||
}
|
||||
}
|
||||
) {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.example.aiapp
|
||||
|
||||
/**
|
||||
* What a machine's Claude Code sessions are having done to them, live.
|
||||
*
|
||||
* The import screen starts and then leaves work behind: the server runs it, so the phone that asked
|
||||
* is free to go elsewhere and the answer arrives here rather than as a reply. What the screen shows
|
||||
* on arrival comes from the listing, which carries the same state for whoever was not connected
|
||||
* when it changed; this is only what keeps a screen somebody is watching current.
|
||||
*
|
||||
* The connection and its framing belong to [Sse]. Closing is the caller's cancellation path, and
|
||||
* the caller owns reconnecting -- there is no cursor to resume from, because anything missed is in
|
||||
* the next listing.
|
||||
*/
|
||||
class ImportableStream(settings: ServerSettings, private val setup: String) {
|
||||
private val stream = Sse(settings)
|
||||
|
||||
fun close() = stream.close()
|
||||
|
||||
fun run(onOpen: () -> Unit, onChange: (ImportableChange) -> Unit) {
|
||||
stream.run("/setups/$setup/importable/events", onOpen) { _, data ->
|
||||
if (data.isNotEmpty()) parseImportableChange(data)?.let(onChange)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -81,8 +81,6 @@ import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
private const val RECONNECT_DELAY_MS = 1500L
|
||||
|
||||
/**
|
||||
* How big the "still loading this conversation" spinner is.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import java.io.IOException
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
/**
|
||||
* How long to wait before opening a dropped stream again.
|
||||
*
|
||||
* Shared by every screen that follows one, so a reconnect is not paced differently depending on
|
||||
* which stream dropped. Short enough that a tunnel coming back is not noticed, long enough that a
|
||||
* server which is genuinely down is not being asked several times a second.
|
||||
*/
|
||||
const val RECONNECT_DELAY_MS = 1500L
|
||||
|
||||
/**
|
||||
* One server-sent-events connection, framed.
|
||||
*
|
||||
* The framing is the part worth having once: `data:` and `event:` lines accumulate until a blank
|
||||
* line ends the frame, comments (keep-alives) start with `:`, and a frame is either named with no
|
||||
* payload or a payload with no name. Two screens follow two different streams — a session's
|
||||
* transcript and what a machine's import list is doing — and neither should be re-deriving that.
|
||||
*
|
||||
* Blocking: [run] occupies its thread until the stream ends. [close], from any thread, is the
|
||||
* cancellation path — it disconnects the socket, which unblocks the read, and [run] then returns
|
||||
* rather than throwing, so a deliberate close is not reported as a connection error. Reconnecting
|
||||
* belongs to the caller, which is the only one that knows where to resume from.
|
||||
*/
|
||||
class Sse(private val settings: ServerSettings) {
|
||||
@Volatile private var connection: HttpURLConnection? = null
|
||||
@Volatile private var closed = false
|
||||
|
||||
fun close() {
|
||||
closed = true
|
||||
connection?.disconnect()
|
||||
}
|
||||
|
||||
/**
|
||||
* Follows the stream at [path], handing each frame to [onFrame] as its name (null for an
|
||||
* ordinary data frame) and its payload. The path is given here rather than at construction
|
||||
* because a caller that reconnects usually resumes from somewhere new -- a cursor it has
|
||||
* advanced past -- and that lives in the query string.
|
||||
*
|
||||
* [onOpen] fires once the server has accepted the connection. That is the measured moment the
|
||||
* stream is live, and the only honest thing to clear a previous failure on: clearing on the
|
||||
* first *event* instead left an idle stream displaying a connection error it had already
|
||||
* recovered from, indefinitely.
|
||||
*/
|
||||
fun run(path: String, onOpen: () -> Unit, onFrame: (name: String?, data: String) -> Unit) {
|
||||
val connection = URL("${settings.baseUrl}$path").openConnection() as HttpURLConnection
|
||||
this.connection = connection
|
||||
try {
|
||||
connection.applyPinnedTls()
|
||||
connection.connectTimeout = CONNECT_TIMEOUT_MS
|
||||
// No read timeout: between events there is nothing to read for as long as the thing
|
||||
// being followed is idle; the server's keep-alives and a dead socket erroring out are
|
||||
// the liveness story.
|
||||
connection.readTimeout = 0
|
||||
connection.setRequestProperty("Authorization", "Bearer ${settings.token}")
|
||||
connection.setRequestProperty("Accept", "text/event-stream")
|
||||
if (connection.responseCode != 200) {
|
||||
val detail = connection.errorStream?.bufferedReader()?.readText()?.trim()
|
||||
throw ApiException(detail ?: "HTTP ${connection.responseCode} for the event stream")
|
||||
}
|
||||
|
||||
onOpen()
|
||||
val reader = connection.inputStream.bufferedReader()
|
||||
val data = StringBuilder()
|
||||
var name: String? = null
|
||||
while (true) {
|
||||
val line = reader.readLine() ?: break
|
||||
when {
|
||||
line.isEmpty() -> {
|
||||
if (name != null || data.isNotEmpty()) onFrame(name, data.toString())
|
||||
data.clear()
|
||||
name = null
|
||||
}
|
||||
line.startsWith("data:") -> data.append(line.removePrefix("data:").trim())
|
||||
line.startsWith("event:") -> name = line.removePrefix("event:").trim()
|
||||
else -> {} // id:, comments -- nothing to do
|
||||
}
|
||||
}
|
||||
} catch (e: ApiException) {
|
||||
throw e
|
||||
} catch (e: IOException) {
|
||||
if (!closed) {
|
||||
throw ApiException(
|
||||
"Can't reach the server -- retrying. (${e.message ?: e::class.simpleName})",
|
||||
e,
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
this.connection = null
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user