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.
39 lines
1.6 KiB
Kotlin
39 lines
1.6 KiB
Kotlin
package com.example.aiapp
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
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.
|
|
*
|
|
* 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(settings: ServerSettings, private val sessionId: String) {
|
|
private val stream = Sse(settings)
|
|
|
|
fun close() = stream.close()
|
|
|
|
/**
|
|
* Streams events after [after] into [onEvent] until the stream drops.
|
|
*
|
|
* [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) {
|
|
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))
|
|
}
|
|
}
|
|
}
|