Thin the app's comments

The same pass the server had, on the Kotlin side: comments restating what
the code says are gone, and the ones recording a measurement, a constraint
or an incident are kept but cut to a few lines each. 6540 comment lines to
5674, and 920 lines off the app.

Two doc comments had drifted onto the item above the one they describe --
`contextAfter`'s onto `sessionWorking` in Events.kt, and `UsageMonitor`'s
equivalent on the server was fixed in the previous commit. Each is back on
its own item, which is the only non-comment line this diff moves.

The comments are reflowed to the column limit at their own indentation:
several were written wide, and ktfmt re-wrapped them into lines holding a
single orphan word. `/tmp` script, not kept -- ktfmt is idempotent over the
result, which is the check.

Left alone deliberately: this codebase's remaining comment density is high
because the comments carry things the code cannot say -- what a null means,
what a number was measured against, which bug a guard exists for. Of the
238 one-line doc comments in the app, five were pure restatement of the
name and were removed; the rest each say something the signature does not.

ktfmtFormat, compileDebugKotlin, lintDebug and testDebugUnitTest pass;
cargo test (127), clippy --all-targets and fmt still clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-09-04 16:20:16 -04:00
1 parent 79682f03a7
commit edc39c7371
68 files changed
+2077 -2997

No files matched your search

@@ -11,30 +11,25 @@ import java.io.RandomAccessFile
* This phone's copy of the transcripts it has already been sent, so reopening a session does not
* download it again.
*
* What is stored is the server's own JSON for one event per line, in transcript order -- the
* elements of a `/transcript` page and the payload of each SSE frame. Reading the cache means
* running the same [parseSeqEvent] the network path runs, so a cached transcript and a fetched one
* cannot draw differently, and an event type this build does not know ([SessionEvent.Unknown])
* keeps every field it arrived with, on disk, for the build that will. Rows are deliberately *not*
* what is stored: a row is a rendering of events, its shape changes whenever the fold does, and a
* cache of rows would need throwing away on every app update that touched `foldEvent`.
* What is stored is the server's own JSON for one event per line, in transcript order. Reading the
* cache means running the same [parseSeqEvent] the network path runs, so a cached transcript and a
* fetched one cannot draw differently, and an event type this build does not know keeps every field
* it arrived with for the build that will. Rows are deliberately *not* what is stored: a row is a
* rendering, and a cache of rows would need throwing away on every update that touched `foldEvent`.
*
* See TRANSCRIPT_CACHE.md for the design. Four rules run through all of it:
* 1. what is on screen is what the server's transcript says, in order, with nothing missing -- the
* cache is a copy and is never inferred, folded or edited here;
* 2. a cached line is never ahead of the live cursor, and the cursor never ahead of the cache;
* 3. the cache is never load-bearing -- missing, evicted, damaged or unwritable all degrade to a
* cold open, never to a blank or a wrong screen;
* 4. a line already on the phone is not fetched again.
* cold open, never to a blank or a wrong screen; 4. a line already on the phone is not fetched
* again.
*
* A plain [File] root and no Compose, `Context` or network, so the whole of the file logic runs
* under the JVM unit tests. It is also why there is no JSON parser in here: what it needs off a
* line is the sequence number and whether the line is a streamed delta, and both are read with a
* regex over text the server wrote. A line it cannot read that way is treated as damage, which
* gives the same answer as having no cache at all.
*
* [warn] is where failures are said, for the same reason -- `android.util.Log` is a stub that
* throws under the JVM tests, and this file has to be exercisable there.
* under the JVM unit tests. That is also why there is no JSON parser here: what it needs off a line
* is the sequence number and whether the line is a streamed delta, both read with a regex. A line
* it cannot read that way is treated as damage. [warn] is where failures are said for the same
* reason.
*/
class TranscriptCache(
private val root: File,
@@ -44,11 +39,9 @@ class TranscriptCache(
fun session(id: String): SessionCache = SessionCache(File(root, id), warn)
/**
* Deletes every session directory not in [ids], called after a successful list fetch.
*
* The path out for a session deleted on another device or at the backend: nothing here would
* otherwise ever hear about it, and unlike a draft's few bytes what it leaves behind is
* megabytes.
* Deletes every session directory not in [ids], called after a successful list fetch. The path
* out for a session deleted on another device: nothing here would otherwise hear about it, and
* unlike a draft's few bytes what it leaves behind is megabytes.
*/
fun retainOnly(ids: Set<String>) =
guardIo(Unit, warn) {
@@ -57,11 +50,9 @@ class TranscriptCache(
/**
* Deletes least-recently-touched session directories, never [keep], until the whole of this
* server's cache is under [budget].
*
* Least-recently-touched rather than largest: what a reader is likely to open again is what
* they opened last, and evicting the big ones first would empty the cache for exactly the
* conversations it exists for.
* server's cache is under [budget]. Least-recently-touched rather than largest: what a reader
* is likely to open again is what they opened last, and evicting the big ones first would empty
* the cache for exactly the conversations it exists for.
*/
fun evictToBudget(keep: String, budget: Long = CACHE_BUDGET_BYTES) =
guardIo(Unit, warn) {
@@ -81,18 +72,16 @@ class TranscriptCache(
}
/**
* How much of this phone's cache directory all of one server's transcripts may take.
*
* A dozen of the largest transcripts seen in the dev VM (21 MB for 24,000 events) and a small
* fraction of a phone. A number to revisit against real use rather than a measurement of anything.
* How much of this phone's cache directory all of one server's transcripts may take. A dozen of the
* largest transcripts seen in the dev VM (21 MB for 24,000 events) and a small fraction of a phone.
* A number to revisit against real use rather than a measurement of anything.
*/
const val CACHE_BUDGET_BYTES: Long = 256L * 1000 * 1000
/**
* What the newest cached line says, which is what the probe checks against the server.
*
* Both halves are wanted together and by the same caller: the seq is what the request asks about,
* and the line is what its answer is compared with.
* What the newest cached line says, which is what the probe checks against the server. Both halves
* are wanted together: the seq is what the request asks about, and the line is what its answer is
* compared with.
*/
data class CachedTail(val seq: Long, val line: String)
@@ -101,35 +90,31 @@ data class CachedTail(val seq: Long, val line: String)
*
* A chunk is a set of lines *and a claim about what they cover*, and the two are not the same
* thing: a coalesced page joins each run of streamed deltas into one event carrying the seq of the
* run's oldest delta, so a page whose newest event is seq 1,200 may in fact cover everything up to
* the 1,650 it was fetched with, and nothing in the lines says so. So coverage is the half-open
* range in the file's name:
* run's oldest delta, so a page whose newest event is seq 1,200 may cover everything up to the
* 1,650 it was fetched with, and nothing in the lines says so. So coverage is the half-open range
* in the file's name:
* ```
* <first>-<end>.rows.jsonl a coalesced page; end is the `before` it was fetched with
* <first>-<end>.raw.jsonl an uncoalesced page, or a closed live run
* <first>-open.raw.jsonl the live run; end is its last line's seq + 1
* <first>-<end>.rows.jsonl a coalesced page; end is the `before` it was fetched with
* <first>-<end>.raw.jsonl an uncoalesced page, or a closed live run <first>-open.raw.jsonl the
* live run; end is its last line's seq + 1
* ```
*
* Two chunks are adjacent when one's `end` is the other's `first`. Only the contiguous run of
* adjacent chunks ending at the newest chunk -- the **suffix** -- is ever served: chunks behind a
* gap are kept, because the gap is usually closed by paging back through it, but nothing is served
* across one.
* Two chunks are adjacent when one's `end` is the other's `first`. Only the contiguous run ending
* at the newest chunk -- the **suffix** -- is ever served: chunks behind a gap are kept, because
* the gap is usually closed by paging back through it, but nothing is served across one.
*
* **The newest chunk is always raw**, which is what makes the stream cursor and the probe well
* defined -- a raw chunk's last line is a real event at a real seq, and the server never coalesces
* the newest window. It holds by construction (the opening window and every stream frame are raw)
* and is checked on read: a `.rows` chunk at the newest end can only mean this app died between
* closing one live run and opening the next, and it discards the session.
* defined. It holds by construction (the opening window and every stream frame are raw) and is
* checked on read: a `.rows` chunk at the newest end can only mean this app died between closing
* one live run and opening the next, and it discards the session.
*
* Nothing here is load-bearing. Every operation that touches the disk answers as though the cache
* were empty when it cannot, and a write failure disables writing for the rest of this instance's
* life so that a full disk costs one log line rather than one per delta.
*
* Every operation is synchronized, because two of them really do run at once: the stream appends
* live events from its own IO thread while a reader scrolling back reads pages from another. The
* lock is uncontended in the ordinary case and what it buys is that the open chunk's name, its end
* and its writer are never read half-rotated -- which would show up as a page silently fetched
* again, or as a stored chunk overlapping the run it was written beside.
* live events from its own IO thread while a reader scrolling back reads pages from another. What
* it buys is that the open chunk's name, its end and its writer are never read half-rotated.
*/
class SessionCache(
private val dir: File,
@@ -141,9 +126,8 @@ class SessionCache(
* The open chunk's writer, its file, and the seq that chunk now ends at.
*
* Buffered, and flushed on [flush], because a delta is a hundred bytes and arrives dozens of
* times a second while a reply streams -- a syscall each is the thing to avoid. What that costs
* is the unflushed tail on a crash, which is safe: a shorter cache is a longer catch-up, never
* a wrong one.
* times a second while a reply streams. What that costs is the unflushed tail on a crash, which
* is safe: a shorter cache is a longer catch-up, never a wrong one.
*/
private var writer: BufferedWriter? = null
private var openFile: File? = null
@@ -153,8 +137,7 @@ class SessionCache(
* The newest line of the suffix, or null when there is none or the newest chunk is not raw.
*
* This is the cursor the live stream would resume from, so it is also what has to be shown to
* still be the server's own line before anything is resumed from it -- see
* `TranscriptSource.probe`.
* still be the server's own line before anything is resumed from it.
*/
@Synchronized
fun tail(): CachedTail? =
@@ -187,30 +170,26 @@ class SessionCache(
* The page of lines before [before], oldest first, or null when the cache cannot answer.
*
* Null is a miss -- the suffix does not cover the ground immediately below [before] -- and
* means the server has to be asked. It is deliberately not an empty list: an empty page is how
* the screen is told it has reached the start of the conversation, and a cache saying that of
* means the server has to be asked. Deliberately not an empty list: an empty page is how the
* screen is told it has reached the start of the conversation, and a cache saying that of
* history it merely does not hold would stop the transcript scrolling back for good.
*
* [before] is anywhere inside the suffix, not only at a chunk boundary. The cursor a warm open
* leaves behind is in the middle of the live run -- the screen draws the newest eighty lines of
* it -- so a cache that could only answer at a boundary would send the very first backwards
* page to the server and, since that page would overlap the run, keep none of it.
* leaves behind is in the middle of the live run, so a cache that could only answer at a
* boundary would send the very first backwards page to the server and, since that page would
* overlap the run, keep none of it.
*
* A short page is fine, and is what a walk that reaches the oldest chunk of the suffix returns:
* the caller already treats a short page as a page.
*
* With [rows] the count is rows rather than lines, mirroring the server's `parse_coalesced`:
* every event that is not a streamed delta is a row, and each maximal run of deltas is one row.
* With [rows] the count is rows rather than lines, mirroring the server's `parse_coalesced`.
* The deltas are not joined here -- `foldEvent` does that, and the joined row keeps the seq of
* its first delta either way, so anchors and the next `before` land where they do today.
* its first delta either way.
*/
@Synchronized
fun page(before: Long, limit: Int, rows: Boolean): List<String>? =
guard(null) {
val suffix = suffix()
val newest = suffix.lastOrNull() ?: return@guard null
// Above what is held, or at or below where it starts: either way the run the caller
// is scrolling into is not continuous with this one, and only the server has it.
// Above what is held, or at or below where it starts: either way the run the caller is
// scrolling into is not continuous with this one, and only the server has it.
if (before > newest.end || before <= suffix.first().first) return@guard null
val taken = ArrayDeque<String>()
var counted = 0
@@ -220,14 +199,14 @@ class SessionCache(
if (!wanting) break
if (chunk.first >= before) continue
eachLine(chunk) { line ->
// The page is what is *before* the cursor; the rows at or above it are the
// ones already on screen.
// The page is what is *before* the cursor; the rows at or above it are already
// on screen.
if (seqOf(line)!! >= before) return@eachLine true
if (rows) {
val delta = isDelta(line)
// Stop only between rows: a delta continuing the run being gathered is
// part of a row already counted, and breaking on it would drop the half
// of that row already taken.
// Stop only between rows: a delta continuing the run being gathered is part
// of a row already counted, and breaking on it would drop the half of that
// row already taken.
if (counted >= limit && !(delta && inRun)) wanting = false
else {
if (!delta || !inRun) counted++
@@ -248,8 +227,7 @@ class SessionCache(
* asked with so that it stops where this phone's copy starts. Null when there is no such chunk.
*
* Any chunk, not only the suffix's: the whole point is to reach the run behind a gap, so that
* the gap is closed with exactly the bytes it is wide and the history behind it is served
* locally from then on.
* the gap is closed with exactly the bytes it is wide.
*/
@Synchronized
fun coveredUpTo(before: Long): Long? =
@@ -260,9 +238,8 @@ class SessionCache(
*
* Refused when it overlaps a chunk already here, because there is no clean cut: a coalesced
* event cannot be split at a seq inside its own delta run. `TranscriptSource` keeps that from
* arising by bounding what it fetches, and this is the guard for a page that arrives anyway --
* from a server without the `after` parameter, say. Such a page is still drawn; it is only not
* kept.
* arising by bounding what it fetches, and this is the guard for a page that arrives anyway.
* Such a page is still drawn; it is only not kept.
*
* The newest chunk is never stored through here: the opening window and every live frame go
* through [append], which is what keeps the newest chunk raw and open.
@@ -282,18 +259,17 @@ class SessionCache(
* Appends one live event, which is also how a freshly fetched opening window is stored.
*
* A seq equal to the open chunk's end extends it. A larger one is a gap -- which is what a
* `reset` looks like from here -- and closes the open chunk under the end it turned out to have
* before starting a new one at [seq]. A smaller one is already covered and is ignored; the SSE
* contract is `seq > after`, so that is a guard rather than a path.
* `reset` looks like from here -- and closes the open chunk under the end it turned out to
* have. A smaller one is already covered and is ignored; the SSE contract is `seq > after`.
*/
@Synchronized
fun append(line: String, seq: Long) =
guard(Unit) {
if (disabled) return@guard
val writer = writerFor(seq) ?: return@guard
// Written as it arrived. A newline inside it would split one event into two
// unreadable halves, but neither source can produce one: SSE framing forbids it, and
// a page's elements are re-serialized compactly, which escapes it.
// Written as it arrived. A newline inside it would split one event into two unreadable
// halves, but neither source can produce one: SSE framing forbids it, and a page's
// elements are re-serialized compactly, which escapes it.
writer.write(line)
writer.write("\n")
openEnd = seq + 1
@@ -329,9 +305,7 @@ class SessionCache(
/**
* Every chunk on disk, oldest first. A name this does not recognise is not ours and is ignored.
*
* Recomputed per operation rather than kept: another operation may have changed the directory,
* and a hundred names is a directory listing.
* Recomputed per operation rather than kept: another operation may have changed the directory.
*/
private fun chunks(): List<Chunk> {
writer?.flush()
@@ -354,9 +328,9 @@ class SessionCache(
* is the one writing it.
*
* An open chunk whose last line cannot be read is this app having died mid-write. That line is
* dropped and the file truncated to the last good one before anything is served from it, which
* is the one place damage is repaired rather than discarded: the tail of an append-only file is
* the only place a partial line can be.
* dropped and the file truncated to the last good one, which is the one place damage is
* repaired rather than discarded: the tail of an append-only file is the only place a partial
* line can be.
*/
private fun openEndOf(file: File, first: Long): Long {
if (openFile == file && openEnd > 0) return openEnd
@@ -373,8 +347,7 @@ class SessionCache(
* The contiguous run of adjacent chunks ending at the newest one, oldest first.
*
* A newest chunk that is not raw cannot happen while this code is the only writer, and means
* the directory is not to be trusted -- so the session is discarded rather than served across
* whatever else is wrong with it.
* the directory is not to be trusted -- so the session is discarded.
*/
private fun suffix(): List<Chunk> {
val all = chunks()
@@ -393,10 +366,9 @@ class SessionCache(
/**
* Each line of [chunk], newest first, until [take] says stop.
*
* Backwards and lazily, because every question this cache is asked is about the newest end --
* the tail, the opening window, the page before a cursor -- and a live run grows to the size of
* the conversation. Reading the file whole to answer with eighty lines of it is the cost the
* server's own reader was rewritten to stop paying.
* Backwards and lazily, because every question this cache is asked is about the newest end and
* a live run grows to the size of the conversation. Reading the file whole to answer with
* eighty lines of it is the cost the server's own reader was rewritten to stop paying.
*
* Damage anywhere but at the tail of the open chunk was not written by this code, and there is
* no honest way to say what a chunk covers with a line of it unreadable -- so it discards the
@@ -433,8 +405,8 @@ class SessionCache(
}
rename(existing.file, existing.first, existing.end)
}
// A chunk that was created and never written to would otherwise be left behind under a
// name a second one is about to want; it covers nothing, so nothing is lost with it.
// A chunk that was created and never written to would otherwise be left behind under a name
// a second one is about to want; it covers nothing, so nothing is lost with it.
dir.listFiles().orEmpty().forEach {
if (CHUNK_NAME.matchEntire(it.name)?.groupValues?.get(2) == "open" && it.length() == 0L)
it.delete()
@@ -480,12 +452,12 @@ class SessionCache(
*
* None of this is reported on screen: none of it changes what the screen shows -- every read
* here has a network path beside it producing the same result -- and the reader has nothing to
* do about it. It is logged, and damage discards this session's cache, which is what makes the
* next open an ordinary cold one.
* do about it. Damage discards this session's cache, which makes the next open an ordinary cold
* one.
*/
private fun <T> guard(ifBroken: T, body: () -> T): T =
// A disk that refused once will refuse again, once per delta, so the first refusal is
// also the last: this instance stops writing rather than logging a line a token.
// A disk that refused once will refuse again, once per delta, so the first refusal is also
// the last: this instance stops writing rather than logging a line a token.
guardIo(
ifBroken,
warn,
@@ -515,8 +487,7 @@ private val TYPE_IN_LINE = Regex(""""type"\s*:\s*"([^"]*)"""")
* One line's sequence number, or null when the line is not one of ours.
*
* A regex rather than a JSON parse, so that this file carries no parser and runs under the JVM
* tests: the seq is the first field the server writes (`SeqEvent`'s declaration order, with the
* event flattened after it), so the first match is the top-level one.
* tests: the seq is the first field the server writes, so the first match is the top-level one.
*/
private fun seqOf(line: String): Long? = SEQ_IN_LINE.find(line)?.groupValues?.get(1)?.toLongOrNull()
@@ -536,11 +507,10 @@ private const val READ_BLOCK = 64 * 1024
*
* Every question the cache is asked is about the newest end of a chunk, and a live run reaches the
* size of the conversation, so reading forwards means reading a transcript to answer with the last
* eighty lines of it. This reads blocks from the end and stops where the caller stops.
* eighty lines of it.
*
* Splitting on bytes is safe because the separator is `\n`, which cannot occur inside a multi-byte
* UTF-8 sequence; each line is decoded whole, so nothing is cut through a character. A missing file
* yields nothing, which is the same answer as an empty one.
* UTF-8 sequence; each line is decoded whole. A missing file yields nothing.
*/
private fun eachLineBackwards(file: File, onLine: (offset: Long, line: String) -> Boolean) {
if (!file.isFile) return
@@ -581,8 +551,8 @@ private const val NEWLINE = '\n'.code.toByte()
* Drops a final line that is not one of ours, by truncating the file to where it starts.
*
* This app having died mid-write is the one kind of damage that is repaired rather than discarded:
* the tail of an append-only file is the only place a partial line can be, and everything before it
* is intact. A second bad line is not this, and is left for the read path to notice.
* the tail of an append-only file is the only place a partial line can be. A second bad line is not
* this, and is left for the read path to notice.
*/
private fun repairTail(file: File) {
var truncateTo = -1L
@@ -598,9 +568,7 @@ private fun sizeOf(file: File): Long =
/**
* The disk half of [SessionCache.guard], shared with [TranscriptCache]'s own maintenance.
*
* [onFailure] is what the caller does about it beyond answering [ifBroken] -- for a session's
* cache, giving up on writing.
* [onFailure] is what the caller does about it beyond answering [ifBroken].
*/
private fun <T> guardIo(
ifBroken: T,