Compare commits
2
Commits
74110b4d72
...
e3e02d55f7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3e02d55f7 | ||
|
|
a802522039 |
No files matched your search
@@ -911,6 +911,54 @@ machine belongs in `~/.claude/TOOLCHAIN.md` (toolchain versions) or
|
|||||||
every page. Both are off it now. The shape to watch for is a
|
every page. Both are off it now. The shape to watch for is a
|
||||||
`withContext` that wraps the *fetch* and leaves the work done with the
|
`withContext` that wraps the *fetch* and leaves the work done with the
|
||||||
result outside it.
|
result outside it.
|
||||||
|
- **The phone keeps the transcripts it has been sent, and the design is
|
||||||
|
`TRANSCRIPT_CACHE.md`** -- read that before touching `TranscriptCache.kt`,
|
||||||
|
`TranscriptSource.kt`, or the opening and stream effects in
|
||||||
|
`SessionScreen.kt`. What the day-to-day work needs to know:
|
||||||
|
`<cacheDir>/transcripts/v1/<host>_<port>/<sessionId>/` holds the server's
|
||||||
|
own event lines in chunks named for the range they cover
|
||||||
|
(`<first>-<end>.rows.jsonl`, `.raw.jsonl`, and one `<first>-open.raw.jsonl`
|
||||||
|
the live stream appends to), and only the contiguous run ending at the
|
||||||
|
newest chunk is ever served. Measured on the emulator 2026-09-04 against
|
||||||
|
the sandbox: reopening a 500-event session costs **one request for one
|
||||||
|
event** -- the probe -- and scrolling the whole conversation back costs
|
||||||
|
nothing more. A cold open of the same session is two pages, 100 events.
|
||||||
|
Four things are easy to undo by accident.
|
||||||
|
**The probe is not optional**: before a stream is resumed from a cached
|
||||||
|
cursor, `GET /transcript?before=<cursor+1>&limit=1` has to come back as the
|
||||||
|
line the cache holds, or the cache is thrown away and the open is cold. It
|
||||||
|
is what stops a replaced or truncated file being spliced onto this phone's
|
||||||
|
copy of a different conversation, with no seam to see.
|
||||||
|
**A page fetched for the gap passes `after`** (the transcript route's own
|
||||||
|
parameter, added for this), so it stops where the phone's copy starts. A
|
||||||
|
page that overlaps a chunk cannot be stored -- a coalesced event has no
|
||||||
|
clean cut inside its delta run -- so without the bound the first scroll
|
||||||
|
back after a reset throws away everything behind it.
|
||||||
|
**Nothing here is load-bearing.** Every read has a network path beside it
|
||||||
|
giving the same answer, and a missing, evicted, damaged or unwritable cache
|
||||||
|
degrades to a cold open. Keep it that way: a cache that can blank the
|
||||||
|
screen is worse than no cache.
|
||||||
|
**Reload, in session settings, is the answer to what the probe cannot
|
||||||
|
see** -- a line changed in the middle of the file with the tail intact.
|
||||||
|
It purges and rebuilds the screen as a cold open, putting the reader back
|
||||||
|
where they were.
|
||||||
|
Exercise all of it with `./ui-sandbox.sh` and
|
||||||
|
`RUST_LOG=ai_server=debug`, which logs every page with its `before`,
|
||||||
|
`after` and what came back; `adb shell run-as com.example.aiapp ls
|
||||||
|
cache/transcripts/v1/*/<id>` shows whether the chunk names are adjacent,
|
||||||
|
which is the one thing the screen cannot tell you.
|
||||||
|
- **The server used to hand out the same transcript line two different
|
||||||
|
ways.** `serde_json`'s default float parser is not correctly rounded, so a
|
||||||
|
`ts` of `1788546972.6030757` in the transcript came back from
|
||||||
|
`/transcript` as `...0755` while the SSE stream, serializing the same
|
||||||
|
struct, sent the original. Nothing on screen could show it -- a `ts` is
|
||||||
|
drawn as a relative time -- and what found it was the phone's cache
|
||||||
|
comparing a line it held against the server's own answer, which turned an
|
||||||
|
invisible last-bit difference into a cache silently thrown away and a
|
||||||
|
transcript downloaded again. The `float_roundtrip` feature in
|
||||||
|
`server/Cargo.toml` is the fix and
|
||||||
|
`a_line_read_back_is_the_line_that_was_written` is what keeps it; that test
|
||||||
|
fails within a second of the feature being dropped.
|
||||||
- **ZXing only looks for a dark code on a light ground.** The enrollment
|
- **ZXing only looks for a dark code on a light ground.** The enrollment
|
||||||
QR is block characters in the terminal's foreground colour, so a
|
QR is block characters in the terminal's foreground colour, so a
|
||||||
dark-themed terminal renders it as a negative and the in-app scanner
|
dark-themed terminal renders it as a negative and the in-app scanner
|
||||||
|
|||||||
@@ -1016,9 +1016,16 @@ dev-updater (Kotlin 2.4.x, CMP 1.11.x, JDK 21). Screens:
|
|||||||
|
|
||||||
Networking mirrors dev-updater's app layer (`AppsApi.kt` style thin client +
|
Networking mirrors dev-updater's app layer (`AppsApi.kt` style thin client +
|
||||||
pinned transport), plus an SSE client with `after=` resume driven by
|
pinned transport), plus an SSE client with `after=` resume driven by
|
||||||
connectivity/lifecycle. The app keeps no persistent transcript store — the
|
connectivity/lifecycle. The backend's transcript is the source of truth, and
|
||||||
backend's transcript is the source of truth; the app caches only for the
|
the app keeps a **copy of what it has already been sent** — see
|
||||||
screen it's showing.
|
`TRANSCRIPT_CACHE.md`, added 2026-09-04, because reopening a session over the
|
||||||
|
tunnel was re-downloading a conversation the phone had just read. The copy is
|
||||||
|
the server's own event lines, per session, under `cacheDir`; it is checked
|
||||||
|
against the server before a stream is resumed from it, thrown away rather than
|
||||||
|
patched when that check fails, and never load-bearing — every path that reads
|
||||||
|
it has a network path beside it giving the same answer. What the app still
|
||||||
|
does not keep is anything *derived*: the folded rows are rebuilt from events
|
||||||
|
every time.
|
||||||
|
|
||||||
### Notifications: two places, never both (decided 2026-08-30)
|
### Notifications: two places, never both (decided 2026-08-30)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,512 @@
|
|||||||
|
# The transcript cache
|
||||||
|
|
||||||
|
Asked for by Iris on 2026-09-04: keep the transcripts of recently visited
|
||||||
|
sessions on the phone, so reopening one does not download it again. It has
|
||||||
|
to save data over the tunnel, it must not disturb a reply that is streaming
|
||||||
|
when the screen is reopened, it must never skip an event, and session
|
||||||
|
settings needs a manual reload for when the file on the machine has
|
||||||
|
changed under it.
|
||||||
|
|
||||||
|
Built 2026-09-04. Like EXPLORER.md this records each decision with its
|
||||||
|
reason and what was rejected, so that when one changes it is changed here
|
||||||
|
rather than re-argued -- three of them changed during the building, and
|
||||||
|
"What building it changed" at the foot says which and why. What it is *not*
|
||||||
|
is the operational half: how to exercise it, and what has bitten, are in
|
||||||
|
AGENTS.md with the rest of the working notes.
|
||||||
|
|
||||||
|
## What it is, in one paragraph
|
||||||
|
|
||||||
|
A per-session file on the phone holding the exact JSON lines the server has
|
||||||
|
already sent, in transcript order, with a record of which sequence numbers
|
||||||
|
each run of lines covers. Everything the session screen fetches today --
|
||||||
|
the opening window, the pages it scrolls back through, the span an anchor
|
||||||
|
restore reaches for -- is asked of the cache first and of the server only
|
||||||
|
for what the cache does not hold, and everything that arrives from the
|
||||||
|
server is written into it. The live stream then resumes from the newest
|
||||||
|
cached event, exactly as it resumes today from the newest event on screen,
|
||||||
|
so the server sends only what happened since. One tiny request checks that
|
||||||
|
the cached tail is still what the server has before the stream is opened
|
||||||
|
from it, and a button in session settings throws the cache away and
|
||||||
|
rebuilds the screen as a cold open for the cases that check cannot see.
|
||||||
|
|
||||||
|
## The invariants
|
||||||
|
|
||||||
|
Everything below is in service of four rules. When a decision looks
|
||||||
|
arbitrary, it is one of these forcing it.
|
||||||
|
|
||||||
|
1. **What is on screen is what the server's transcript says, in order,
|
||||||
|
with nothing missing, for every sequence number the screen claims to
|
||||||
|
show.** The cache is a copy of server output and is never inferred,
|
||||||
|
folded, or edited on the phone. Where the copy cannot be shown to be
|
||||||
|
current, it is thrown away, not patched.
|
||||||
|
2. **A cached line is never ahead of the live cursor, and the live cursor
|
||||||
|
is never ahead of the cache.** The stream resumes from the newest cached
|
||||||
|
event, so a reply that was mid-stream when the screen closed picks up at
|
||||||
|
its next delta and folds into the same row, as it does today when the
|
||||||
|
phone merely lost the tunnel for a second.
|
||||||
|
3. **The cache is never load-bearing.** A missing, evicted, corrupt or
|
||||||
|
unwritable cache degrades to today's behaviour -- a cold open -- and
|
||||||
|
never to a blank or wrong screen. Every path that reads it has a
|
||||||
|
network path beside it that produces the same result.
|
||||||
|
4. **Data crosses the tunnel once.** A line already on the phone is not
|
||||||
|
fetched again unless the reader asks for that (the reload button) or the
|
||||||
|
check in decision 3 says it must be.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### 1. Raw server lines, on the phone, keyed by server and session
|
||||||
|
|
||||||
|
The cache stores the server's own JSON, one event per line, byte-for-byte
|
||||||
|
as it arrived: the elements of the `/transcript` array and the `data:`
|
||||||
|
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`) survives on disk for the build that will.
|
||||||
|
|
||||||
|
It lives under `context.cacheDir` -- `<cacheDir>/transcripts/v1/<host>_<port>/<sessionId>/`
|
||||||
|
-- because it is exactly what that directory is for: bytes the phone can
|
||||||
|
regenerate from the server, which Android may delete under storage
|
||||||
|
pressure without asking. Keyed by the server's host and port because two
|
||||||
|
servers can hold a session with the same id (the sandbox and the real
|
||||||
|
server, or a re-enrolment), and a line from one shown against the other
|
||||||
|
is invariant 1 broken. `ServerSettings` has both fields; the key is
|
||||||
|
`"${settings.host}_${settings.port}"` with `:` never appearing in it.
|
||||||
|
The `v1` segment is the format version: any change to the layout below
|
||||||
|
bumps it, and a directory of another version is deleted on first use.
|
||||||
|
|
||||||
|
Rejected: a database (Room, SQLite). The access pattern is "the newest N
|
||||||
|
lines" and "the lines before seq X", on files of tens of megabytes at most,
|
||||||
|
and a JSONL file per contiguous run answers both by reading from its end.
|
||||||
|
A database would be a new dependency for an index the file layout already
|
||||||
|
provides.
|
||||||
|
|
||||||
|
Rejected: caching folded `TranscriptItem` rows instead of events. Rows are
|
||||||
|
a *rendering* of events, and their shape changes when the fold changes;
|
||||||
|
the cache would need invalidating on every app update that touched
|
||||||
|
`foldEvent`, and would still have to keep raw seqs for the stream cursor.
|
||||||
|
Events are the server's contract and the only thing that is stable.
|
||||||
|
|
||||||
|
### 2. Chunks with explicit coverage; one contiguous run behind the cursor
|
||||||
|
|
||||||
|
A page from the server is a set of lines *and a claim about what they
|
||||||
|
cover*, and the two are not the same thing. A coalesced page
|
||||||
|
(`coalesce=true`, which the scroll-back pager asks for) joins each run of
|
||||||
|
`assistantText` deltas into one event carrying the seq of its *oldest*
|
||||||
|
delta, so a page whose newest event has seq 1,200 may in fact cover every
|
||||||
|
line up to the `before` it was asked with, say 1,650. Nothing in the lines
|
||||||
|
themselves says so. So each stored chunk records its coverage as a
|
||||||
|
half-open range `[first, end)`, where `first` is the seq of its oldest
|
||||||
|
event and `end` is the `before` the request was made with -- or, for a
|
||||||
|
raw chunk, its newest seq plus one.
|
||||||
|
|
||||||
|
Chunks are files named by their coverage:
|
||||||
|
|
||||||
|
<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: appended to by the stream; end = last line's seq + 1
|
||||||
|
|
||||||
|
Two chunks are **adjacent** when one's `end` equals the other's `first`.
|
||||||
|
The cache serves only the contiguous run of adjacent chunks that ends at
|
||||||
|
the newest raw chunk (the **suffix**); chunks behind a gap are kept on
|
||||||
|
disk, because the gap is usually filled (decision 4), but are never served
|
||||||
|
across the gap.
|
||||||
|
|
||||||
|
**The newest chunk is always raw.** That is what makes the stream cursor
|
||||||
|
and the check in decision 3 well defined: a raw chunk's last line is a real
|
||||||
|
event at a real seq, and the server never coalesces the newest window
|
||||||
|
("the live cursor depends on real seqs", `read_window`). It holds by
|
||||||
|
construction -- the opening window is fetched with no `before`, stream
|
||||||
|
frames are raw, and a `reset` window is raw -- and is *checked* on read:
|
||||||
|
if the newest chunk on disk is a `.rows` chunk (which can only happen if
|
||||||
|
the app died between closing one live run and appending to the next), the
|
||||||
|
session's cache is purged and the open is cold.
|
||||||
|
|
||||||
|
There is at most one open chunk. When a stream event arrives whose seq is
|
||||||
|
not the open chunk's `end` -- which is what a `reset` looks like from
|
||||||
|
here, see decision 6 -- the open chunk is closed by renaming it with its
|
||||||
|
real end, and a new open chunk starts at the arriving seq. An event whose
|
||||||
|
seq is below the open chunk's `end` is already covered and is not written
|
||||||
|
(the SSE contract is `seq > after`, so this is a guard, not a path).
|
||||||
|
|
||||||
|
Rejected: one file per session, rewritten to prepend older pages. A
|
||||||
|
20 MB transcript would be rewritten on every page scrolled back to. The
|
||||||
|
chunk directory costs a directory listing per open instead.
|
||||||
|
|
||||||
|
Rejected: trimming chunks to resolve overlaps. A coalesced event cannot be
|
||||||
|
split at a seq inside its run, so an overlap between a coalesced page and
|
||||||
|
an existing chunk has no clean cut. The cache therefore **never stores a
|
||||||
|
page that overlaps an existing chunk**; decision 4 makes sure such a page
|
||||||
|
is never fetched in the first place, and if one arrives anyway (a server
|
||||||
|
without decision 4's change) it is used for display and not stored.
|
||||||
|
|
||||||
|
### 3. The cached tail is checked against the server before the stream opens from it
|
||||||
|
|
||||||
|
The screen must not resume a stream from a cached seq unless the server's
|
||||||
|
event at that seq is the one in the cache. The transcript file on the
|
||||||
|
machine is append-only in ordinary use, but it can be replaced or
|
||||||
|
truncated -- a sandbox re-seeded with the same ids, a backup restored, a
|
||||||
|
directory deleted and the session re-imported under the same name -- and
|
||||||
|
`catch_up` on such a file would hand the phone a continuation of a
|
||||||
|
different conversation, spliced onto the cached one with no seam. That is
|
||||||
|
the worst thing this feature can do, and it is caught with one request.
|
||||||
|
|
||||||
|
**The probe:** `GET /sessions/{id}/transcript?before=<cursor+1>&limit=1`,
|
||||||
|
where `cursor` is the seq of the cache's newest line. `read_window` with
|
||||||
|
that `before` returns the single newest event with seq ≤ cursor, which is
|
||||||
|
the event *at* the cursor when it exists. The probe passes when that
|
||||||
|
response, parsed with `parseSeqEvent`, is `==` to the cached line parsed
|
||||||
|
the same way -- data-class equality over seq, ts, and the whole event. It
|
||||||
|
fails when the response is empty, is a different seq, or differs in any
|
||||||
|
field.
|
||||||
|
|
||||||
|
That equality rested on an assumption this plan stated and did not check:
|
||||||
|
that the two ways the server hands out a line agree bit for bit. **They did
|
||||||
|
not.** `serde_json`'s default float parser is not correctly rounded, so a
|
||||||
|
`ts` of `1788546972.6030757` written to the transcript came back from
|
||||||
|
`/transcript` as `...0755`, while the SSE stream -- serializing the same
|
||||||
|
struct -- sent the original. Measured on the emulator 2026-09-04: 23 of 330
|
||||||
|
cached lines differed from the server's answer in the last bit, so the probe
|
||||||
|
would have failed on any session whose cached tail happened to be one of
|
||||||
|
them, silently and only sometimes. That is a defect in the server
|
||||||
|
independent of this feature -- two answers to "what is line 30" -- and it is
|
||||||
|
fixed there, with `float_roundtrip` and a test
|
||||||
|
(`a_line_read_back_is_the_line_that_was_written`) that fails the moment the
|
||||||
|
feature is dropped. Comparing everything *except* `ts` was the other option
|
||||||
|
and was rejected: a re-seeded fixture is identical in content and differs
|
||||||
|
only in when it happened, which is exactly the case the probe exists for. A failed probe **purges the session's cache
|
||||||
|
and proceeds as a cold open**. A probe that cannot be made (no route to
|
||||||
|
the server) leaves the cached transcript on screen, shows the request's
|
||||||
|
error on the stream banner where a connection failure shows today, and is
|
||||||
|
retried on the stream loop's schedule (`RECONNECT_DELAY_MS`); the stream
|
||||||
|
is never opened until a probe has passed once for this screen instance.
|
||||||
|
|
||||||
|
What the probe does *not* catch: a line changed in the middle of the file
|
||||||
|
with the tail intact, or a file rewritten so that the event at the cursor
|
||||||
|
happens to be identical. Those are what the reload button is for, and the
|
||||||
|
button's caption says so.
|
||||||
|
|
||||||
|
Cost: one request of a few hundred bytes, one round trip, in the slot
|
||||||
|
where the opening page's request is today -- so the round trips before
|
||||||
|
the stream is live are unchanged at two, and the bytes fall from a page to
|
||||||
|
a line. The cached rows are drawn *before* the probe returns, which is the
|
||||||
|
whole point of the feature; a failed probe replaces them, the same
|
||||||
|
appearance as a `reset`.
|
||||||
|
|
||||||
|
Rejected: a server-side check on the stream (`events?after=N&ts=T`,
|
||||||
|
answered with a distinct frame when the event at N is not what the phone
|
||||||
|
thinks). Strictly better coverage -- it would run on every reconnect, not
|
||||||
|
only on open -- and no extra round trip. Not chosen because it puts a
|
||||||
|
cache's validation into a protocol that otherwise knows nothing about
|
||||||
|
caching, and because the reset frame already has to keep meaning "you are
|
||||||
|
behind, your history is fine" (decision 6), so a second frame would be
|
||||||
|
needed. Worth revisiting if the probe's round trip is ever measured as the
|
||||||
|
thing making reopen slow; note it as the alternative here and in PLAN.md.
|
||||||
|
|
||||||
|
Rejected: trusting the cache without a check and relying on the reload
|
||||||
|
button. Invariant 1 is not something a button restores after the fact.
|
||||||
|
|
||||||
|
Rejected: fetching the newest page as today and using it to validate the
|
||||||
|
overlap. Zero saving on the opening page, which is the request paid on
|
||||||
|
every open.
|
||||||
|
|
||||||
|
### 4. Pages ask the server only for the gap: `after` on `/transcript`
|
||||||
|
|
||||||
|
After a reader has been away, the cache holds `[a, b)` and the screen
|
||||||
|
holds the newest window `[W, …)` with a gap between `b` and `W`. Paging
|
||||||
|
back from `W` asks the server for a coalesced page before `W`, and that
|
||||||
|
page may reach back past `b` -- a single reply is hundreds of lines, so
|
||||||
|
forty rows can be thousands of seqs -- producing exactly the overlap
|
||||||
|
decision 2 refuses to store. Left like that, every cached chunk would be
|
||||||
|
overlapped and dropped in turn as the reader paged back through the gap,
|
||||||
|
and the cache would save nothing for the sessions it exists for.
|
||||||
|
|
||||||
|
So the transcript route gains a lower bound. `TranscriptQuery` in
|
||||||
|
`server/src/routes.rs` gets
|
||||||
|
|
||||||
|
/// Return nothing at or below this seq; the page stops here instead of at `limit`.
|
||||||
|
/// The phone passes the end of what it already holds, so a page never overlaps it.
|
||||||
|
#[serde(default)]
|
||||||
|
after: Option<u64>,
|
||||||
|
|
||||||
|
named to match the SSE route's `after` (exclusive, `seq > after`).
|
||||||
|
`read_window(path, before, after, limit, coalesce)` in
|
||||||
|
`server/src/session/transcript.rs` computes
|
||||||
|
`start = first_at_or_after(after + 1)` and stops the walk there: the raw
|
||||||
|
branch parses `max(start, end - limit)..end`; `parse_coalesced` takes a
|
||||||
|
`start` and its `while index > 0` becomes `while index > start`. A delta
|
||||||
|
run cut at `start` is emitted as the partial it is, exactly as one cut by
|
||||||
|
`limit` already is, and `healSplitMessage` welds it on the phone -- no new
|
||||||
|
mechanism. The route's table comment in `routes.rs` gains the parameter,
|
||||||
|
and `transcript.rs` gets a test beside
|
||||||
|
`a_window_is_the_events_before_a_cursor_and_nothing_else`: with `after`
|
||||||
|
set, the page's oldest seq is greater than `after`, and with `after` set
|
||||||
|
inside a delta run the partial run's seq is the first delta above `after`.
|
||||||
|
|
||||||
|
The phone passes `after = b - 1` where `b` is the `end` of the nearest
|
||||||
|
chunk whose `end ≤ before`, and nothing when there is none. A page that
|
||||||
|
comes back with `first == b` is adjacent, and the suffix now runs through
|
||||||
|
the old chunks: the gap is closed with exactly the bytes it was wide, and
|
||||||
|
the history behind it is served locally from then on.
|
||||||
|
|
||||||
|
Rejected: fetching the gap raw in one request (`before=W&limit=W-b`,
|
||||||
|
which is what the anchor restore already does). Exact, but a gap of ten
|
||||||
|
thousand lines is several megabytes downloaded to save re-downloading
|
||||||
|
history the reader may never scroll to; the feature exists to save data.
|
||||||
|
Paging as today with a bound saves the same bytes and fetches only what
|
||||||
|
is read.
|
||||||
|
|
||||||
|
Rejected: dropping the cached run whenever a gap opens. Being more than
|
||||||
|
`CATCH_UP_LIMIT` (200) events behind is the *ordinary* state of an active
|
||||||
|
session revisited -- 200 raw events is one reply -- so this would empty
|
||||||
|
the cache for exactly the sessions that are opened most.
|
||||||
|
|
||||||
|
### 5. A page is served locally in rows, mirroring the server's count
|
||||||
|
|
||||||
|
`loadOlderPage` asks for `HISTORY_PAGE` (40) **rows** when
|
||||||
|
`coalesce = true`, and for a number of **events** otherwise (the anchor
|
||||||
|
restore). Served from the cache, the events branch is the `limit` lines
|
||||||
|
before `before`. The rows branch walks back from the line before `before`
|
||||||
|
counting rows the way `parse_coalesced` does: every event that is not an
|
||||||
|
`assistantText` is a row, and each maximal run of `assistantText` lines is
|
||||||
|
one row; it stops only between rows, once `limit` rows are complete, and
|
||||||
|
returns the raw lines oldest-first. It does not join the deltas -- the
|
||||||
|
fold does that (`foldEvent` appends a delta to a preceding
|
||||||
|
`AssistantMsg`), and the joined row keeps the seq of its first delta either
|
||||||
|
way, so anchors and the next `before` land where they do today.
|
||||||
|
|
||||||
|
A cached page is allowed to be **short**: the suffix's oldest chunk starts
|
||||||
|
at some `first`, and a walk that reaches it returns what it found. The
|
||||||
|
caller already treats a short page as a page; only an *empty* page means
|
||||||
|
"start of the conversation" (`moreHistory = false`), and the cache never
|
||||||
|
returns an empty page -- it returns `null` (a miss) and the network is
|
||||||
|
asked. The walk may cross a chunk boundary inside the suffix, since adjacent
|
||||||
|
chunks are one run; a delta run straddling a boundary counts as one row, as
|
||||||
|
it should.
|
||||||
|
|
||||||
|
A miss is `before` **outside what the suffix covers continuously** -- above
|
||||||
|
its newest `end`, or at or below its oldest `first`. This plan first said a
|
||||||
|
miss was "no chunk of the suffix ends at `before`", which is wrong in the
|
||||||
|
commonest case there is: a warm open draws the newest eighty lines of the
|
||||||
|
live run, so the cursor the reader then scrolls back from is in the *middle*
|
||||||
|
of a chunk, not at a boundary. Under the narrower rule every warm open sent
|
||||||
|
its first backwards page to the server, and that page -- reaching back past
|
||||||
|
the run the phone already held -- overlapped it and could not be stored, so
|
||||||
|
the same history was fetched again on every visit. The feature would have
|
||||||
|
saved the opening window and nothing else.
|
||||||
|
|
||||||
|
The row rule is a copy of the server's, and copies drift. It is short
|
||||||
|
(one comparison), it is pure, and it goes under a JVM unit test with the
|
||||||
|
same fixture as the server's `coalescing_counts_rows_and_joins_delta_runs`
|
||||||
|
-- the three cases are a run cut by the limit, a `usageDelta` inside a run
|
||||||
|
(the server flushes the run there, so it is two rows), and a page that is
|
||||||
|
all one run.
|
||||||
|
|
||||||
|
### 6. What a `reset` means for the cache: behind, not wrong
|
||||||
|
|
||||||
|
The server sends `reset` when the cursor is more than `CATCH_UP_LIMIT`
|
||||||
|
events behind, then the newest 200 raw events. The screen already drops
|
||||||
|
everything and rebuilds from that window. For the cache, a reset means
|
||||||
|
**the history is intact and there is a gap**: the probe passed, the file
|
||||||
|
is append-only, and the window's first seq is above the open chunk's end.
|
||||||
|
The store learns this from the first window event's seq (decision 2:
|
||||||
|
a seq that is not the open chunk's `end` closes it and opens a new chunk)
|
||||||
|
and needs no signal from the screen; the gap is filled by paging
|
||||||
|
(decision 4).
|
||||||
|
|
||||||
|
Two things the reset handler in `SessionScreen` does not clear today and
|
||||||
|
must: `queued` and `waitingCommands`. Both are folded from events, and a
|
||||||
|
`messageQueued` whose resolving `userMessage` fell in the gap would
|
||||||
|
otherwise draw a waiting bubble for a message the session has long since
|
||||||
|
read. This is a latent bug today, made likely by the cache because a
|
||||||
|
cached tail is older than a fetched one. `contextTokens` needs no change:
|
||||||
|
`UsageDelta.context` is absolute, so the window's first one corrects it.
|
||||||
|
|
||||||
|
### 7. Session state that is not the transcript comes from the list, not the cache
|
||||||
|
|
||||||
|
`apply` derives `status`, `model`, `permissionMode` and `compactingSince`
|
||||||
|
from `Status` and `Settings` events. Replayed from a fetched page those are
|
||||||
|
current; replayed from the cache they are as old as the last visit, while
|
||||||
|
`summary.status`, `summary.model` and `summary.permissionMode` -- the row
|
||||||
|
the reader just tapped -- were fetched moments ago. So the cache replay
|
||||||
|
runs through `apply` for the transcript's sake (queued bubbles, context,
|
||||||
|
rows) and then **reassigns those four from `summary`**, which is the newer
|
||||||
|
of the two measurements; the stream's catch-up then makes them current.
|
||||||
|
Without this a session that finished an hour ago would open saying
|
||||||
|
"working" until the stream connected, which is a status row lying for a
|
||||||
|
round trip.
|
||||||
|
|
||||||
|
### 8. Reload, in session settings
|
||||||
|
|
||||||
|
`SessionSettingsDialog` gains a row under the working directory:
|
||||||
|
|
||||||
|
[ Transcript ] 2.3 MB cached [ Reload ]
|
||||||
|
|
||||||
|
The size is what the button discards, and it is the unknown state made
|
||||||
|
visible: `null` while the directory is being measured (spinner, as the
|
||||||
|
notifications switch does), "nothing cached" when the directory is absent
|
||||||
|
or empty, else the size. A caption in the style of Move's, because the
|
||||||
|
button costs something the reader cannot see:
|
||||||
|
|
||||||
|
Reload throws away this phone's copy and fetches the transcript from the
|
||||||
|
server again. Use it when what is shown here disagrees with the file on
|
||||||
|
the machine.
|
||||||
|
|
||||||
|
Pressing it: purge the session's cache directory, close the dialog, and
|
||||||
|
rebuild the screen as a cold open -- the same sequence as `reset` plus a
|
||||||
|
fresh opening fetch, with the reader put back where they were. The
|
||||||
|
mechanism is an `epoch` counter (`mutableIntStateOf(0)`) added to the key
|
||||||
|
of the opening effect and the stream effect; incrementing it cancels both
|
||||||
|
(the stream's `finally` closes the socket) and relaunches them. State the
|
||||||
|
relaunch must see cleared: `items`, `replies.clear()`, `held`, `oldestSeq
|
||||||
|
= 0`, `moreHistory = true`, `queued`, `waitingCommands`, `lastSeq.set(0)`,
|
||||||
|
`ready = false`. `savedAnchor` becomes `remember(summary.id, epoch)` so
|
||||||
|
the restore path reads the anchor saved at the reader's *current*
|
||||||
|
position (the anchor saver writes on every settle, so it is there), and
|
||||||
|
`restoring` is re-derived from it. The button is enabled whether or not
|
||||||
|
anything is cached: "what I see disagrees with the machine" is a state an
|
||||||
|
empty cache can also be in, and a control that comes and goes makes its
|
||||||
|
own presence the signal.
|
||||||
|
|
||||||
|
Nothing is announced on success. The transcript shows the opening spinner
|
||||||
|
and then the rows, which is what the screen already says about a reload.
|
||||||
|
A failure is the opening fetch's, and lands on the stream banner where
|
||||||
|
that failure lands today.
|
||||||
|
|
||||||
|
Rejected: a global "clear transcript cache" in the app's settings screen.
|
||||||
|
Not asked for; eviction (decision 9) bounds the total, and the per-session
|
||||||
|
button is where the reader is when they notice a problem. Easy to add as
|
||||||
|
one more caller of `TranscriptCache.purgeAll` if wanted.
|
||||||
|
|
||||||
|
### 9. Budget, eviction, pruning
|
||||||
|
|
||||||
|
The cache is bounded three ways, each with its path out written beside
|
||||||
|
the path in:
|
||||||
|
|
||||||
|
- **Budget.** `CACHE_BUDGET_BYTES = 256 MB` across all sessions of one
|
||||||
|
server. Each open touches the session directory's mtime; after the
|
||||||
|
opening replay, on `Dispatchers.IO`, the store sums the server's
|
||||||
|
directories and deletes least-recently-touched session directories
|
||||||
|
(never the one on screen) until under budget. 256 MB is a dozen of the
|
||||||
|
largest transcripts seen in this VM (21 MB for 24,000 events) and a
|
||||||
|
small fraction of a phone; it is a number to revisit against real use,
|
||||||
|
not a measurement.
|
||||||
|
- **Deleted sessions.** `SessionListScreen`'s delete calls
|
||||||
|
`cache.session(id).purge()` after `deleteSession` succeeds, and every
|
||||||
|
successful list fetch calls `cache.retainOnly(ids)` for that server, so
|
||||||
|
a session deleted from another device or from the backend is pruned on
|
||||||
|
the next visit to the list. `Drafts.kt` chose not to prune because its
|
||||||
|
residue is bytes; here it is megabytes, so the pass is worth having.
|
||||||
|
- **Android.** `cacheDir` may be emptied under pressure at any moment,
|
||||||
|
including while a screen is open. Every read tolerates a missing
|
||||||
|
directory (cold open) and every write failure is swallowed once and
|
||||||
|
disables writing for that screen instance (decision 10).
|
||||||
|
|
||||||
|
### 10. The cache never breaks the screen
|
||||||
|
|
||||||
|
Every store operation that touches the disk catches `IOException` and
|
||||||
|
answers as if the cache were empty: `null` from a read, no-op from a
|
||||||
|
write, with the failure logged once at `Log.w("ai-app", …)`. After a
|
||||||
|
write failure the `SessionCache` instance sets `disabled = true` and
|
||||||
|
writes nothing more, so a full disk costs one log line rather than one
|
||||||
|
per delta. A line at the end of an open chunk that does not parse -- the
|
||||||
|
app died mid-write -- is dropped and the file truncated to the last
|
||||||
|
good line before anything is served from it; a line that does not parse
|
||||||
|
anywhere else purges the session's cache (that file was not written by
|
||||||
|
this code). None of this is reported on screen: none of it changes what
|
||||||
|
the screen shows, and the reader has nothing to do about it.
|
||||||
|
|
||||||
|
## Layout on disk
|
||||||
|
|
||||||
|
<cacheDir>/transcripts/
|
||||||
|
v1/
|
||||||
|
10.0.2.2_8443/ one directory per server (host_port)
|
||||||
|
3f2c…/ one per session id
|
||||||
|
1-1650.rows.jsonl coalesced page: covers seqs 1..1649
|
||||||
|
1650-2001.rows.jsonl
|
||||||
|
2001-2400.raw.jsonl a closed live run
|
||||||
|
2600-open.raw.jsonl the live run; end = last line's seq + 1
|
||||||
|
|
||||||
|
Here 2400..2599 is a gap: the reader was away for two hundred events and
|
||||||
|
the stream reset. The suffix is the single chunk `2600-open`; the first
|
||||||
|
backwards page asks the server for `before=2600&after=2399&coalesce=true`,
|
||||||
|
and once a page comes back with `first == 2400` the suffix runs to seq 1.
|
||||||
|
|
||||||
|
Each `.jsonl` is one JSON object per line, oldest first, exactly as the
|
||||||
|
server sent it. No header, no index: coverage is in the name, order is the
|
||||||
|
file's, and the seq is in every line.
|
||||||
|
|
||||||
|
## What building it changed
|
||||||
|
|
||||||
|
Each of these contradicted something written above, and each was found by
|
||||||
|
running it rather than by reading it. The decisions themselves are amended
|
||||||
|
in place; this is the list of what moved, so that a reader who remembers the
|
||||||
|
first version knows what to re-read.
|
||||||
|
|
||||||
|
- **The probe's equality had a false premise** -- decision 3. The server did
|
||||||
|
not hand out the same line twice the same way. Fixed on the server.
|
||||||
|
- **A cached page starts anywhere inside the run** -- decision 5. Requiring
|
||||||
|
a chunk boundary would have made the cache save the opening window and
|
||||||
|
nothing else.
|
||||||
|
- **The opening window is stored by `append`, not by `storePage`.** The
|
||||||
|
sketch below had `storePage` grow a special case for "this page is the new
|
||||||
|
open chunk", decided by an implicit condition that a raw history page also
|
||||||
|
satisfies. Appending each line instead is the mechanism that already
|
||||||
|
exists, and the open chunk stays the one thing that grows.
|
||||||
|
- **Chunks are read backwards, in blocks, and never whole.** Every question
|
||||||
|
the cache is asked is about the newest end, and a live run reaches the size
|
||||||
|
of the conversation -- so reading a chunk to answer with eighty lines of it
|
||||||
|
is the cost the server's own reader was rewritten to stop paying, arriving
|
||||||
|
on the phone. Damage is therefore noticed when a read reaches it rather
|
||||||
|
than up front, which is the better time: what is not read cannot be wrong.
|
||||||
|
- **The stream waits for the opening effect's probe.** The screen lifts
|
||||||
|
`ready` before the probe returns -- that is the point of the cache -- so
|
||||||
|
`ready` stopped being the whole gate, and the stream loop asked the same
|
||||||
|
question a second time and raced its own answer. Two probes per warm open,
|
||||||
|
visible in the server's log.
|
||||||
|
- **`SessionCache` is synchronized.** The stream appends live events from
|
||||||
|
one IO thread while a reader scrolling back reads pages from another; the
|
||||||
|
open chunk's name, its end and its writer must never be seen
|
||||||
|
half-rotated.
|
||||||
|
|
||||||
|
## What it cost, measured
|
||||||
|
|
||||||
|
On the emulator against `app/ui-sandbox.sh`, 2026-09-04, on a session of
|
||||||
|
505 events (three short exchanges and two 300-delta replies):
|
||||||
|
|
||||||
|
- **Reopening it: one request, for one event.** The probe, and nothing else
|
||||||
|
-- including scrolling the whole conversation back to its first line. A
|
||||||
|
cold open of the same session is two requests and 100 events.
|
||||||
|
- **A reset after falling 300 events behind costs the gap and no more.**
|
||||||
|
The window arrived at seq 306, the phone held up to 202, and the first
|
||||||
|
backwards page asked `before=306&after=201` and came back with **four
|
||||||
|
coalesced rows** covering 202..305 -- against the 104 raw events an
|
||||||
|
unbounded page would have re-fetched and then thrown away.
|
||||||
|
- **Every chunk is exactly what the server says for the range its name
|
||||||
|
claims**, checked line by line against `/transcript` for each chunk's own
|
||||||
|
`before`/`after`/`coalesce`, across a reset and a gap-fill.
|
||||||
|
- **Nothing about drawing changed**, which is what a cache must not do:
|
||||||
|
`transcript-bench.sh` before and after, same viewport content and the same
|
||||||
|
gestures, reported p50 16.9ms both times and the transcript's own draw
|
||||||
|
accounting at 0.33ms against 0.32ms.
|
||||||
|
|
||||||
|
Still to measure, in real use rather than here: the size the cache reaches
|
||||||
|
against `CACHE_BUDGET_BYTES`, and whether the probe's round trip is ever
|
||||||
|
what a reader waits on.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
- **The probe on every reconnect, not only on open?** Decision 3 probes
|
||||||
|
once per screen instance. A file replaced *while* the screen is open is
|
||||||
|
today's behaviour and not made worse, but the server-side check it
|
||||||
|
rejects would close it. Decide after measuring how often the probe's
|
||||||
|
round trip is what the reader waits on.
|
||||||
|
- **A reset arriving during an anchor restore** was an open worry when this
|
||||||
|
was written, and was measured and closed on 2026-09-04 (see "The reconnect
|
||||||
|
loop does not reproduce") before this landed. The cache makes the restore
|
||||||
|
cheaper again -- a warm one is now the probe and nothing else -- so it can
|
||||||
|
only have narrowed the window further. Worth re-measuring here only if a
|
||||||
|
reader reports the screen reconnecting on reopen.
|
||||||
|
- **Images.** `SessionImage` fetches bytes from the files route on draw;
|
||||||
|
they are not part of this cache and are re-downloaded per view. A
|
||||||
|
separate, simpler cache (a directory of refs, no ordering) if the
|
||||||
|
measurement above says the images are where the data goes.
|
||||||
@@ -956,15 +956,27 @@ fun fetchTranscript(
|
|||||||
// seq). Ignored by the server for the newest window, where the live cursor needs real seqs.
|
// seq). Ignored by the server for the newest window, where the live cursor needs real seqs.
|
||||||
// See the server's `read_window`.
|
// See the server's `read_window`.
|
||||||
coalesce: Boolean = false,
|
coalesce: Boolean = false,
|
||||||
): List<SeqEvent> {
|
// Return nothing at or below this seq, stopping the page here instead of at [limit]. The
|
||||||
|
// phone passes the end of the run it already holds cached, so a page never overlaps that copy
|
||||||
|
// -- an overlap it cannot store, since a coalesced event cannot be cut at a seq inside its own
|
||||||
|
// delta run. Exclusive, like the SSE route's cursor. See TranscriptCache and `read_window`.
|
||||||
|
after: Long? = null,
|
||||||
|
): List<Pair<String, SeqEvent>> {
|
||||||
val query = buildString {
|
val query = buildString {
|
||||||
append("?limit=").append(limit)
|
append("?limit=").append(limit)
|
||||||
if (before != null) append("&before=").append(before)
|
if (before != null) append("&before=").append(before)
|
||||||
if (coalesce) append("&coalesce=true")
|
if (coalesce) append("&coalesce=true")
|
||||||
|
if (after != null) append("&after=").append(after)
|
||||||
}
|
}
|
||||||
return requestFromServer(settings, "/sessions/$sessionId/transcript$query") { connection ->
|
return requestFromServer(settings, "/sessions/$sessionId/transcript$query") { connection ->
|
||||||
val body = JSONArray(connection.inputStream.bufferedReader().readText())
|
val body = JSONArray(connection.inputStream.bufferedReader().readText())
|
||||||
(0 until body.length()).map { parseSeqEvent(body.getJSONObject(it).toString()) }
|
// The text as well as the event: the transcript cache stores the one and the fold needs
|
||||||
|
// the other, and they have to be the same line -- a second entry point differing only in
|
||||||
|
// return type would be two answers to one question.
|
||||||
|
(0 until body.length()).map {
|
||||||
|
val line = body.getJSONObject(it).toString()
|
||||||
|
line to parseSeqEvent(line)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,12 +27,19 @@ class EventStream(settings: ServerSettings, private val sessionId: String) {
|
|||||||
* caller drops what it holds and rebuilds -- the same thing it does when the screen opens. It
|
* 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.
|
* 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) {
|
fun run(
|
||||||
|
after: Long,
|
||||||
|
onOpen: () -> Unit,
|
||||||
|
onReset: () -> Unit,
|
||||||
|
// The frame's own text as well as the event parsed from it: the transcript cache stores
|
||||||
|
// the one and the screen folds the other, and they have to be the same line.
|
||||||
|
onEvent: (raw: String, event: SeqEvent) -> Unit,
|
||||||
|
) {
|
||||||
stream.run("/sessions/$sessionId/events?after=$after", onOpen) { name, data ->
|
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
|
// A named frame carries no payload and a data frame has no name, so this is one or
|
||||||
// the other.
|
// the other.
|
||||||
if (name == RESET_EVENT) onReset()
|
if (name == RESET_EVENT) onReset()
|
||||||
else if (data.isNotEmpty()) onEvent(parseSeqEvent(data))
|
else if (data.isNotEmpty()) onEvent(data, parseSeqEvent(data))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -29,6 +29,7 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -68,6 +69,11 @@ fun SessionListScreen(
|
|||||||
// request rather than to the session.
|
// request rather than to the session.
|
||||||
var deleting by remember { mutableStateOf<Set<String>>(emptySet()) }
|
var deleting by remember { mutableStateOf<Set<String>>(emptySet()) }
|
||||||
|
|
||||||
|
// This phone's copies of these sessions' transcripts, pruned from here because this is where
|
||||||
|
// a session stops existing. See TranscriptCache.
|
||||||
|
val context = LocalContext.current
|
||||||
|
val transcriptCache = remember(settings) { TranscriptCache(cacheRoot(context, settings)) }
|
||||||
|
|
||||||
fun refresh() {
|
fun refresh() {
|
||||||
listState = LoadState.Loading
|
listState = LoadState.Loading
|
||||||
scope.launch {
|
scope.launch {
|
||||||
@@ -76,6 +82,14 @@ fun SessionListScreen(
|
|||||||
val loaded =
|
val loaded =
|
||||||
withContext(Dispatchers.IO) { LoadState.Loaded(fetchSessions(settings)) }
|
withContext(Dispatchers.IO) { LoadState.Loaded(fetchSessions(settings)) }
|
||||||
deleteErrors = emptyMap()
|
deleteErrors = emptyMap()
|
||||||
|
// The path out for a cached transcript whose session was deleted somewhere
|
||||||
|
// else -- from another device, or at the backend. This list is the only place
|
||||||
|
// that ever learns the full set, and what the residue costs here is megabytes
|
||||||
|
// rather than a draft's few bytes. On the answer rather than in `finally`: a
|
||||||
|
// list that failed to arrive says nothing about which sessions exist.
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
transcriptCache.retainOnly(loaded.value.map { it.id }.toSet())
|
||||||
|
}
|
||||||
loaded
|
loaded
|
||||||
} catch (e: ApiException) {
|
} catch (e: ApiException) {
|
||||||
LoadState.failed(e)
|
LoadState.failed(e)
|
||||||
@@ -223,6 +237,9 @@ fun SessionListScreen(
|
|||||||
try {
|
try {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
deleteSession(settings, session.id, alsoDeleteForeign)
|
deleteSession(settings, session.id, alsoDeleteForeign)
|
||||||
|
// After it succeeded, not before: a refused delete leaves the
|
||||||
|
// session exactly as it was, and its transcript with it.
|
||||||
|
transcriptCache.session(session.id).purge()
|
||||||
}
|
}
|
||||||
// Only this row, and only what changed. Refetching the list
|
// Only this row, and only what changed. Refetching the list
|
||||||
// instead put every other session back through loading and
|
// instead put every other session back through loading and
|
||||||
|
|||||||
@@ -81,7 +81,6 @@ import androidx.lifecycle.Lifecycle
|
|||||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
import androidx.lifecycle.repeatOnLifecycle
|
import androidx.lifecycle.repeatOnLifecycle
|
||||||
import java.util.concurrent.atomic.AtomicLong
|
import java.util.concurrent.atomic.AtomicLong
|
||||||
import java.util.concurrent.atomic.AtomicReference
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.awaitCancellation
|
import kotlinx.coroutines.awaitCancellation
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
@@ -326,7 +325,27 @@ fun SessionScreen(
|
|||||||
val lifecycleOwner = LocalLifecycleOwner.current
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
// The resume cursor, written from the stream's IO thread.
|
// The resume cursor, written from the stream's IO thread.
|
||||||
val lastSeq = remember { AtomicLong(0) }
|
val lastSeq = remember { AtomicLong(0) }
|
||||||
val activeStream = remember { AtomicReference<EventStream?>(null) }
|
// Bumped to rebuild this screen from nothing -- what Reload in the settings dialog does. It
|
||||||
|
// keys everything that describes one visit to this session: the source below, the opening
|
||||||
|
// effect, the stream, and the anchor being put back. See TRANSCRIPT_CACHE.md's decision 8.
|
||||||
|
var epoch by remember(summary.id) { mutableIntStateOf(0) }
|
||||||
|
// This server's cached transcripts, and this session's half of them. The cache is per server
|
||||||
|
// because two servers can hold a session with the same id; the source is per visit because
|
||||||
|
// Reload throws away what it was reading from.
|
||||||
|
val cache = remember(settings) { TranscriptCache(cacheRoot(context, settings)) }
|
||||||
|
val source =
|
||||||
|
remember(summary.id, epoch) {
|
||||||
|
TranscriptSource(settings, summary.id, cache.session(summary.id))
|
||||||
|
}
|
||||||
|
// Whether the cached tail has been shown to still be the server's own line. Nothing is
|
||||||
|
// resumed from a cached cursor until it has -- see [TranscriptSource.probe] -- and a probe
|
||||||
|
// that could not be made leaves this false for the stream loop to try again.
|
||||||
|
var probePassed by remember(summary.id, epoch) { mutableStateOf(false) }
|
||||||
|
// Whether the opening effect is still settling that question. It draws the cached rows and
|
||||||
|
// lifts [ready] before the answer arrives, which is the point of the cache -- so the stream
|
||||||
|
// below has to wait for this rather than for `ready`, or it asks the very same question a
|
||||||
|
// second time and races the answer.
|
||||||
|
var probing by remember(summary.id, epoch) { mutableStateOf(true) }
|
||||||
// The oldest sequence number loaded, and whether there is more behind
|
// The oldest sequence number loaded, and whether there is more behind
|
||||||
// it. Paging backwards is what keeps opening a long session cheap: the
|
// it. Paging backwards is what keeps opening a long session cheap: the
|
||||||
// screen starts with the end of the conversation and fetches earlier
|
// screen starts with the end of the conversation and fetches earlier
|
||||||
@@ -335,12 +354,12 @@ fun SessionScreen(
|
|||||||
// Where this session was last being read, from this device's own store. Read once, because
|
// Where this session was last being read, from this device's own store. Read once, because
|
||||||
// it is the question "where did I leave off" and the answer stops being interesting the
|
// it is the question "where did I leave off" and the answer stops being interesting the
|
||||||
// moment the list is on screen.
|
// moment the list is on screen.
|
||||||
val savedAnchor = remember(summary.id) { loadScrollAnchor(context, summary.id) }
|
val savedAnchor = remember(summary.id, epoch) { loadScrollAnchor(context, summary.id) }
|
||||||
// Whether the saved position is still being put back -- the history it needs fetched, and the
|
// Whether the saved position is still being put back -- the history it needs fetched, and the
|
||||||
// scroll applied. Nothing is drawn while it is: opening at the newest end and then travelling
|
// scroll applied. Nothing is drawn while it is: opening at the newest end and then travelling
|
||||||
// to the anchor is exactly the journey a reader must never see, and this transcript is not
|
// to the anchor is exactly the journey a reader must never see, and this transcript is not
|
||||||
// allowed to move under one.
|
// allowed to move under one.
|
||||||
var restoring by remember(summary.id) { mutableStateOf(savedAnchor != null) }
|
var restoring by remember(summary.id, epoch) { mutableStateOf(savedAnchor != null) }
|
||||||
// Sent, but not yet read by the session -- which is when the backend
|
// Sent, but not yet read by the session -- which is when the backend
|
||||||
// records it and it comes back as a row. Until then it is drawn below
|
// records it and it comes back as a row. Until then it is drawn below
|
||||||
// the working indicator, because that is where it is in the session's
|
// the working indicator, because that is where it is in the session's
|
||||||
@@ -402,6 +421,31 @@ fun SessionScreen(
|
|||||||
val currentUnits by rememberUpdatedState(units)
|
val currentUnits by rememberUpdatedState(units)
|
||||||
val lastTouch = remember { LastTouch() }
|
val lastTouch = remember { LastTouch() }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drops everything loaded, so the screen can be rebuilt from a window that is not adjacent to
|
||||||
|
* it.
|
||||||
|
*
|
||||||
|
* One function rather than a clearing written at each of the three places that need it -- a
|
||||||
|
* stream reset, a cached transcript the server turns out not to have, and Reload -- because
|
||||||
|
* what has to go is a property of "these rows are no longer continuous with what comes next",
|
||||||
|
* not of who noticed. The two easy ones to leave out are [queued] and [waitingCommands]: both
|
||||||
|
* are folded from events, so a `messageQueued` whose resolving `userMessage` fell in the gap
|
||||||
|
* draws a bubble waiting for a message the session read long ago. [contextTokens] needs no
|
||||||
|
* clearing, because `UsageDelta.context` is absolute and the next one corrects it.
|
||||||
|
*
|
||||||
|
* The resume cursor is deliberately *not* cleared here: a reset continues from where it was,
|
||||||
|
* and only a caller that is starting the conversation again from the server says so itself.
|
||||||
|
*/
|
||||||
|
fun dropLoadedTranscript() {
|
||||||
|
items = listOf()
|
||||||
|
replies.clear()
|
||||||
|
held = listOf()
|
||||||
|
oldestSeq = 0L
|
||||||
|
moreHistory = true
|
||||||
|
queued = listOf()
|
||||||
|
waitingCommands = listOf()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Everything the transcript list draws, from one event.
|
* Everything the transcript list draws, from one event.
|
||||||
*
|
*
|
||||||
@@ -616,14 +660,7 @@ fun SessionScreen(
|
|||||||
// `items` read below happens back on the caller's thread, where the write does too.
|
// `items` read below happens back on the caller's thread, where the write does too.
|
||||||
val page =
|
val page =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val older =
|
val older = source.page(before = oldestSeq, limit = limit, coalesce = coalesce)
|
||||||
fetchTranscript(
|
|
||||||
settings,
|
|
||||||
summary.id,
|
|
||||||
before = oldestSeq,
|
|
||||||
limit = limit,
|
|
||||||
coalesce = coalesce,
|
|
||||||
)
|
|
||||||
if (older.isEmpty()) return@withContext null
|
if (older.isEmpty()) return@withContext null
|
||||||
// Folded oldest-first into a list of their own, then put in front: `foldEvent`
|
// Folded oldest-first into a list of their own, then put in front: `foldEvent`
|
||||||
// merges streaming text into the item before it, so replaying an older page
|
// merges streaming text into the item before it, so replaying an older page
|
||||||
@@ -696,17 +733,22 @@ fun SessionScreen(
|
|||||||
// The stream lifecycle: connect, follow, and on any drop reconnect
|
// The stream lifecycle: connect, follow, and on any drop reconnect
|
||||||
// from the cursor -- so a flaky link (or a backend restart) costs
|
// from the cursor -- so a flaky link (or a backend restart) costs
|
||||||
// nothing but the gap's latency.
|
// nothing but the gap's latency.
|
||||||
// The newest page first, in one request, before the stream opens. The
|
// The newest window first, before the stream opens, so the stream starts from where that
|
||||||
// stream then starts from where that page ended, so it carries live
|
// window ended and carries live events only -- which is what it is good at. The window comes
|
||||||
// events only -- which is what it is good at.
|
// from this phone's own copy when there is one, and then costs a single request to check
|
||||||
LaunchedEffect(summary.id) {
|
// that the server's transcript is still the one it came from; otherwise it is a page fetched
|
||||||
try {
|
// as it always was. See TRANSCRIPT_CACHE.md.
|
||||||
val page = withContext(Dispatchers.IO) { fetchTranscript(settings, summary.id) }
|
LaunchedEffect(summary.id, epoch) {
|
||||||
// Warmed before the fold lands rather than after: flattening the rows into units
|
/**
|
||||||
// splits every settled reply ([transcriptUnits]), and the flatten runs in the
|
* One opening window onto the screen, whichever side it came from.
|
||||||
// composition that first sees the rows. Folded into a scratch list off this thread
|
*
|
||||||
// to find out what needs warming; the real fold below also maintains the queue and
|
* Warmed before the fold lands rather than after: flattening the rows into units splits
|
||||||
// the cursor, so it cannot be reused here.
|
* every settled reply ([transcriptUnits]), and the flatten runs in the composition that
|
||||||
|
* first sees the rows. Folded into a scratch list off this thread to find out what needs
|
||||||
|
* warming; the real fold below also maintains the queue and the cursor, so it cannot be
|
||||||
|
* reused here.
|
||||||
|
*/
|
||||||
|
suspend fun open(page: List<SeqEvent>) {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
var scratch = listOf<TranscriptItem>()
|
var scratch = listOf<TranscriptItem>()
|
||||||
page.forEach { entry ->
|
page.forEach { entry ->
|
||||||
@@ -717,6 +759,58 @@ fun SessionScreen(
|
|||||||
warm(replies, scratch)
|
warm(replies, scratch)
|
||||||
}
|
}
|
||||||
page.forEach { apply(it) }
|
page.forEach { apply(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// This phone's own copy first, drawn before anything is asked of the server -- which
|
||||||
|
// is the whole point of the cache. What makes it safe to draw before it is checked is
|
||||||
|
// that a failed check replaces these rows, with the same appearance as a reset.
|
||||||
|
val cached = withContext(Dispatchers.IO) { source.cachedOpening() }
|
||||||
|
if (cached != null) {
|
||||||
|
open(cached)
|
||||||
|
// A replay is as old as the last visit; the row this screen was opened from was
|
||||||
|
// fetched moments ago. So the transcript comes from the cache and everything that
|
||||||
|
// is not the transcript comes from the summary, which is the newer measurement of
|
||||||
|
// the same thing -- otherwise a session that finished an hour ago opens saying
|
||||||
|
// "working" until the stream connects, which is a status row lying for a round
|
||||||
|
// trip.
|
||||||
|
status = summary.status
|
||||||
|
model = summary.model
|
||||||
|
permissionMode = summary.permissionMode ?: "auto"
|
||||||
|
if (summary.status != "compacting") compactingSince = null
|
||||||
|
// Nothing to put back, so these rows are the screen and the probe can return
|
||||||
|
// under them. A restore still has history to fetch and is gated below.
|
||||||
|
if (savedAnchor == null) ready = true
|
||||||
|
}
|
||||||
|
// The one thing a cached cursor has to be shown before the stream resumes from it.
|
||||||
|
val usable = cached != null && withContext(Dispatchers.IO) { source.probe() }
|
||||||
|
if (usable) probePassed = true
|
||||||
|
if (!usable) {
|
||||||
|
// Either there was nothing cached, or what was cached is not what the server
|
||||||
|
// has -- the file was replaced or truncated under it. Same clearing as a reset,
|
||||||
|
// then an ordinary cold open.
|
||||||
|
if (cached != null) {
|
||||||
|
dropLoadedTranscript()
|
||||||
|
lastSeq.set(0)
|
||||||
|
}
|
||||||
|
open(withContext(Dispatchers.IO) { source.fetchOpening() })
|
||||||
|
// Refilled from the server, so the tail is the server's by construction.
|
||||||
|
probePassed = true
|
||||||
|
}
|
||||||
|
} catch (e: ApiException) {
|
||||||
|
// Not fatal: the stream below still replays from zero, which is slow but complete.
|
||||||
|
// Saying so beats silently showing nothing.
|
||||||
|
//
|
||||||
|
// It is also where a probe that could not be *made* lands -- a phone with no route to
|
||||||
|
// the server. Whatever was cached stays on screen and [probePassed] stays false, so
|
||||||
|
// the stream loop asks again before it resumes from that cursor.
|
||||||
|
streamError = e.message
|
||||||
|
} finally {
|
||||||
|
// However that went, the stream is free to take it from here.
|
||||||
|
probing = false
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
// Then back where reading stopped. An anchor deeper than the newest page is exactly
|
// Then back where reading stopped. An anchor deeper than the newest page is exactly
|
||||||
// the one worth restoring -- somebody who read to the bottom has no anchor at all --
|
// the one worth restoring -- somebody who read to the bottom has no anchor at all --
|
||||||
// and the cost was already paid on the way down there.
|
// and the cost was already paid on the way down there.
|
||||||
@@ -794,8 +888,8 @@ fun SessionScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: ApiException) {
|
} catch (e: ApiException) {
|
||||||
// Not fatal: the stream below still replays from zero, which is
|
// A page of history that never arrived. The reader is left at the newest end rather
|
||||||
// slow but complete. Saying so beats silently showing nothing.
|
// than where they were, which is the state this screen opens in anyway.
|
||||||
streamError = e.message
|
streamError = e.message
|
||||||
}
|
}
|
||||||
// Whatever happened above, including a page that never arrived: an empty transcript is a
|
// Whatever happened above, including a page that never arrived: an empty transcript is a
|
||||||
@@ -818,6 +912,13 @@ fun SessionScreen(
|
|||||||
loadingHistory = false
|
loadingHistory = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Last, and off this thread: this session is what must not be evicted, so it is marked
|
||||||
|
// as visited before the budget is applied, and both are a walk of the cache directory
|
||||||
|
// that nothing on screen is waiting for.
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
source.cache.touch()
|
||||||
|
cache.evictToBudget(keep = summary.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only while the screen is actually on screen. Android stops the
|
// Only while the screen is actually on screen. Android stops the
|
||||||
@@ -828,16 +929,37 @@ fun SessionScreen(
|
|||||||
// that has been backgrounded is work nobody is watching. Stopping the
|
// that has been backgrounded is work nobody is watching. Stopping the
|
||||||
// stream deliberately makes the drop a close rather than an error (see
|
// stream deliberately makes the drop a close rather than an error (see
|
||||||
// EventStream.close), and resuming reconnects from the same cursor.
|
// EventStream.close), and resuming reconnects from the same cursor.
|
||||||
LaunchedEffect(summary.id, ready, lifecycleOwner) {
|
LaunchedEffect(summary.id, ready, epoch, lifecycleOwner) {
|
||||||
if (!ready) return@LaunchedEffect
|
if (!ready) return@LaunchedEffect
|
||||||
|
// The opening effect draws cached rows and lifts `ready` *before* it has checked that the
|
||||||
|
// cursor under them is still the server's, so `ready` is no longer the whole gate: this
|
||||||
|
// waits for that check to settle. Without it the two run at once, ask the same question
|
||||||
|
// twice, and race each other's answer -- two probes per warm open in the server's log.
|
||||||
|
snapshotFlow { probing }.first { !it }
|
||||||
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||||
try {
|
try {
|
||||||
while (true) {
|
while (true) {
|
||||||
val stream = EventStream(settings, summary.id)
|
|
||||||
activeStream.set(stream)
|
|
||||||
try {
|
try {
|
||||||
|
// A cached cursor whose probe never got an answer, because the server
|
||||||
|
// could not be reached when the screen opened. Resuming a stream from an
|
||||||
|
// unchecked cursor is the one thing this must not do, so it is asked
|
||||||
|
// again here, on the reconnect schedule, with the cached rows still on
|
||||||
|
// screen meanwhile. False covers both answers that mean "open cold":
|
||||||
|
// the file is not the one these rows came from, and there was nothing
|
||||||
|
// cached to check.
|
||||||
|
if (!probePassed) {
|
||||||
|
if (withContext(Dispatchers.IO) { source.probe() }) {
|
||||||
|
probePassed = true
|
||||||
|
} else {
|
||||||
|
dropLoadedTranscript()
|
||||||
|
lastSeq.set(0)
|
||||||
|
withContext(Dispatchers.IO) { source.fetchOpening() }
|
||||||
|
.forEach { apply(it) }
|
||||||
|
probePassed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
stream.run(
|
source.follow(
|
||||||
after = lastSeq.get(),
|
after = lastSeq.get(),
|
||||||
// Connected, measured rather than inferred: this is what
|
// Connected, measured rather than inferred: this is what
|
||||||
// takes a failure off the screen, and nothing else does.
|
// takes a failure off the screen, and nothing else does.
|
||||||
@@ -852,11 +974,10 @@ fun SessionScreen(
|
|||||||
// is what makes this the same as opening the
|
// is what makes this the same as opening the
|
||||||
// screen -- `apply` refills them, and scrolling
|
// screen -- `apply` refills them, and scrolling
|
||||||
// up pages the rest back in as it always does.
|
// up pages the rest back in as it always does.
|
||||||
items = listOf()
|
// The cache needs no telling: the window's first
|
||||||
replies.clear()
|
// seq is not the seq it was expecting, which is
|
||||||
held = listOf()
|
// what closes its live run and starts another.
|
||||||
oldestSeq = 0L
|
dropLoadedTranscript()
|
||||||
moreHistory = true
|
|
||||||
},
|
},
|
||||||
) { entry ->
|
) { entry ->
|
||||||
apply(entry)
|
apply(entry)
|
||||||
@@ -872,7 +993,7 @@ fun SessionScreen(
|
|||||||
// closing the app over. Reported on the screen either way.
|
// closing the app over. Reported on the screen either way.
|
||||||
streamError = e.message ?: e::class.simpleName
|
streamError = e.message ?: e::class.simpleName
|
||||||
} finally {
|
} finally {
|
||||||
stream.close()
|
source.close()
|
||||||
}
|
}
|
||||||
delay(RECONNECT_DELAY_MS)
|
delay(RECONNECT_DELAY_MS)
|
||||||
}
|
}
|
||||||
@@ -880,14 +1001,15 @@ fun SessionScreen(
|
|||||||
// Cancellation -- going below STARTED, or leaving the screen --
|
// Cancellation -- going below STARTED, or leaving the screen --
|
||||||
// cannot interrupt a blocking socket read. Closing is what
|
// cannot interrupt a blocking socket read. Closing is what
|
||||||
// unblocks it, and what marks the drop deliberate.
|
// unblocks it, and what marks the drop deliberate.
|
||||||
activeStream.getAndSet(null)?.close()
|
source.close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// The screen going away entirely, which the lifecycle scope above does
|
// The screen going away entirely, which the lifecycle scope above does
|
||||||
// not cover: a composable can leave the composition while the activity
|
// not cover: a composable can leave the composition while the activity
|
||||||
// stays started.
|
// stays started. Keyed on the epoch as well, so that Reload's replacement
|
||||||
DisposableEffect(summary.id) { onDispose { activeStream.get()?.close() } }
|
// source is the one a later disposal closes.
|
||||||
|
DisposableEffect(summary.id, epoch) { onDispose { source.close() } }
|
||||||
|
|
||||||
// Nothing gets announced about the session somebody is reading; see NotificationService.
|
// Nothing gets announced about the session somebody is reading; see NotificationService.
|
||||||
// RESUMED rather than STARTED because "looking at it" means the foreground -- a session left
|
// RESUMED rather than STARTED because "looking at it" means the foreground -- a session left
|
||||||
@@ -1954,10 +2076,32 @@ fun SessionScreen(
|
|||||||
UsageDialog(feed = usageFeed, onDismiss = { usageOpen = false })
|
UsageDialog(feed = usageFeed, onDismiss = { usageOpen = false })
|
||||||
}
|
}
|
||||||
if (settingsOpen) {
|
if (settingsOpen) {
|
||||||
|
// Measured when the dialog opens rather than kept up to date: what the reader is being
|
||||||
|
// told is what pressing the button now would discard, and null until the walk of the
|
||||||
|
// directory returns is what not knowing looks like.
|
||||||
|
var cachedBytes by remember(summary.id, epoch) { mutableStateOf<Long?>(null) }
|
||||||
|
LaunchedEffect(summary.id, epoch) {
|
||||||
|
cachedBytes = withContext(Dispatchers.IO) { source.cache.bytes() }
|
||||||
|
}
|
||||||
SessionSettingsDialog(
|
SessionSettingsDialog(
|
||||||
settings = settings,
|
settings = settings,
|
||||||
sessionId = summary.id,
|
sessionId = summary.id,
|
||||||
title = title,
|
title = title,
|
||||||
|
cachedBytes = cachedBytes,
|
||||||
|
// The purge finishes before the epoch moves, because the relaunched opening effect
|
||||||
|
// reads the same directory and would otherwise draw what is about to be deleted.
|
||||||
|
// Everything else here is the clearing a cold open needs; the epoch is what makes it
|
||||||
|
// one, by rebuilding the opening effect, the stream, and the anchor being put back.
|
||||||
|
onReload = {
|
||||||
|
settingsOpen = false
|
||||||
|
scope.launch {
|
||||||
|
withContext(Dispatchers.IO) { source.cache.purge() }
|
||||||
|
dropLoadedTranscript()
|
||||||
|
lastSeq.set(0)
|
||||||
|
ready = false
|
||||||
|
epoch++
|
||||||
|
}
|
||||||
|
},
|
||||||
// The header takes the new name at once and the dialog closes on it, because the
|
// The header takes the new name at once and the dialog closes on it, because the
|
||||||
// rename has already been accepted by the server -- see [title], which is this app's
|
// rename has already been accepted by the server -- see [title], which is this app's
|
||||||
// own datum. The list behind this refetches on the way out of the session anyway.
|
// own datum. The list behind this refetches on the way out of the session anyway.
|
||||||
|
|||||||
@@ -42,9 +42,11 @@ import kotlinx.coroutines.withContext
|
|||||||
* are changed *while* reading a turn -- "not this model, try that one" -- and a control belongs
|
* are changed *while* reading a turn -- "not this model, try that one" -- and a control belongs
|
||||||
* with the thing it acts on.
|
* with the thing it acts on.
|
||||||
*
|
*
|
||||||
* Nothing here is captioned. Each control is a labelled noun with a switch or a field beside it,
|
* Captions are for what a control costs rather than for what it is. Each control is a labelled noun
|
||||||
* and a paragraph under every one of them made the dialog longer than the conversation it covers.
|
* with a switch or a field beside it, and a paragraph under every one of them made the dialog
|
||||||
* Failures still get their words: those are what the reader cannot work out by looking.
|
* longer than the conversation it covers -- so Notifications has none, while Move and Reload do,
|
||||||
|
* because what those two take away is not visible from here. Failures get their words for the same
|
||||||
|
* reason: they are what the reader cannot work out by looking.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun SessionSettingsDialog(
|
fun SessionSettingsDialog(
|
||||||
@@ -55,6 +57,12 @@ fun SessionSettingsDialog(
|
|||||||
*/
|
*/
|
||||||
title: String,
|
title: String,
|
||||||
onRenamed: (String) -> Unit,
|
onRenamed: (String) -> Unit,
|
||||||
|
/**
|
||||||
|
* What this phone is holding of the conversation, or null while that is being measured -- see
|
||||||
|
* the Reload row below, which is what would discard it.
|
||||||
|
*/
|
||||||
|
cachedBytes: Long?,
|
||||||
|
onReload: () -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
/**
|
/**
|
||||||
* Copies what this session costs to draw. Built by the session screen, because everything it
|
* Copies what this session costs to draw. Built by the session screen, because everything it
|
||||||
@@ -255,6 +263,44 @@ fun SessionSettingsDialog(
|
|||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text("Transcript", modifier = Modifier.weight(1f))
|
||||||
|
// The size is what the button discards, and the unknown state is drawn
|
||||||
|
// rather than guessed: a spinner while the directory is being measured, and
|
||||||
|
// words when there is nothing there, because "nothing cached" and "0 B" read
|
||||||
|
// as different claims.
|
||||||
|
when {
|
||||||
|
cachedBytes == null ->
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.width(16.dp).height(16.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
)
|
||||||
|
else ->
|
||||||
|
Text(
|
||||||
|
humanSize(cachedBytes)?.let { "$it cached" } ?: "nothing cached",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
// Enabled whether or not anything is cached: "what I see disagrees with the
|
||||||
|
// machine" is a state an empty cache can be in too, and a control that comes
|
||||||
|
// and goes makes its own presence the signal.
|
||||||
|
TextButton(onClick = onReload) { Text("Reload") }
|
||||||
|
}
|
||||||
|
// Captioned, unlike the controls above it, for the same reason Move is: what it
|
||||||
|
// costs is not visible, and neither is the case it exists for.
|
||||||
|
Text(
|
||||||
|
"Reload throws away this phone's copy and fetches the transcript from the " +
|
||||||
|
"server again. Use it when what is shown here disagrees with the file " +
|
||||||
|
"on the machine.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
error?.let {
|
error?.let {
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
@@ -3,13 +3,16 @@ package com.example.aiapp
|
|||||||
/**
|
/**
|
||||||
* A byte count at the coarsest unit that still says something, so rows stay comparable.
|
* A byte count at the coarsest unit that still says something, so rows stay comparable.
|
||||||
*
|
*
|
||||||
* Null at zero and below, because the two screens that ask disagree about what nothing means and
|
* Null at zero and below, because the screens that ask disagree about what nothing means and only
|
||||||
* only the caller knows: a transcript of no bytes is a measurement that has not happened, and is
|
* the caller knows: a transcript of no bytes is a measurement that has not happened, and is left
|
||||||
* left off the row; a file of no bytes is a file with nothing in it, and the explorer says `0 B`
|
* off the row; a file of no bytes is a file with nothing in it, and the explorer says `0 B` rather
|
||||||
* rather than leaving a gap the reader would have to interpret.
|
* than leaving a gap the reader would have to interpret; a session with no cached transcript says
|
||||||
|
* "nothing cached", because a figure of none would read as a measurement.
|
||||||
*
|
*
|
||||||
* Its own file rather than the import screen's, where it started: two screens now say a size, and a
|
* Its own file rather than the import screen's, where it started: three screens now say a size, and
|
||||||
* second copy of these thresholds is how one list comes to call 4 kB what the other calls 4096 B.
|
* a second copy of these thresholds is how one list comes to call 4 kB what the other calls 4096 B.
|
||||||
|
* `ModelsScreen`'s `gigabytes` is deliberately not folded in -- it writes a download's size to two
|
||||||
|
* decimal places, which is a different question about a much larger number.
|
||||||
*/
|
*/
|
||||||
fun humanSize(bytes: Long): String? =
|
fun humanSize(bytes: Long): String? =
|
||||||
when {
|
when {
|
||||||
|
|||||||
@@ -0,0 +1,621 @@
|
|||||||
|
package com.example.aiapp
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import java.io.BufferedWriter
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileWriter
|
||||||
|
import java.io.IOException
|
||||||
|
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`.
|
||||||
|
*
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
class TranscriptCache(
|
||||||
|
private val root: File,
|
||||||
|
private val warn: (String) -> Unit = { Log.w("ai-app", it) },
|
||||||
|
) {
|
||||||
|
/** The cache for one session, whether or not anything has been stored for it yet. */
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
fun retainOnly(ids: Set<String>) =
|
||||||
|
guardIo(Unit, warn) {
|
||||||
|
sessionDirs().forEach { if (it.name !in ids) it.deleteRecursively() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
fun evictToBudget(keep: String, budget: Long = CACHE_BUDGET_BYTES) =
|
||||||
|
guardIo(Unit, warn) {
|
||||||
|
val dirs = sessionDirs().sortedBy { it.lastModified() }
|
||||||
|
var total = dirs.sumOf { sizeOf(it) }
|
||||||
|
for (dir in dirs) {
|
||||||
|
if (total <= budget) break
|
||||||
|
if (dir.name == keep) continue
|
||||||
|
val was = sizeOf(dir)
|
||||||
|
if (dir.deleteRecursively()) total -= was
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun purgeAll() = guardIo(Unit, warn) { root.deleteRecursively() }
|
||||||
|
|
||||||
|
private fun sessionDirs(): List<File> = root.listFiles()?.filter { it.isDirectory }.orEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
data class CachedTail(val seq: Long, val line: String)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One session's cached lines, as a directory of chunks.
|
||||||
|
*
|
||||||
|
* 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:
|
||||||
|
* ```
|
||||||
|
* <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.
|
||||||
|
*
|
||||||
|
* **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.
|
||||||
|
*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
class SessionCache(
|
||||||
|
private val dir: File,
|
||||||
|
private val warn: (String) -> Unit = { Log.w("ai-app", it) },
|
||||||
|
) {
|
||||||
|
/** Set by the first write that fails: a second would fail the same way, once per delta. */
|
||||||
|
private var disabled = false
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
private var writer: BufferedWriter? = null
|
||||||
|
private var openFile: File? = null
|
||||||
|
private var openEnd: Long = 0
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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`.
|
||||||
|
*/
|
||||||
|
@Synchronized
|
||||||
|
fun tail(): CachedTail? =
|
||||||
|
guard(null) {
|
||||||
|
val newest = suffix().lastOrNull() ?: return@guard null
|
||||||
|
var found: CachedTail? = null
|
||||||
|
eachLine(newest) { line ->
|
||||||
|
found = CachedTail(seqOf(line)!!, line)
|
||||||
|
false
|
||||||
|
}
|
||||||
|
found
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The newest [limit] lines of the suffix, oldest first -- the opening window. */
|
||||||
|
@Synchronized
|
||||||
|
fun newest(limit: Int): List<String> =
|
||||||
|
guard(emptyList()) {
|
||||||
|
val taken = ArrayDeque<String>()
|
||||||
|
for (chunk in suffix().asReversed()) {
|
||||||
|
if (taken.size >= limit) break
|
||||||
|
eachLine(chunk) { line ->
|
||||||
|
taken.addFirst(line)
|
||||||
|
taken.size < limit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
taken.toList()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* 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.
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
@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.
|
||||||
|
if (before > newest.end || before <= suffix.first().first) return@guard null
|
||||||
|
val taken = ArrayDeque<String>()
|
||||||
|
var counted = 0
|
||||||
|
var inRun = false
|
||||||
|
var wanting = true
|
||||||
|
for (chunk in suffix.asReversed()) {
|
||||||
|
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.
|
||||||
|
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.
|
||||||
|
if (counted >= limit && !(delta && inRun)) wanting = false
|
||||||
|
else {
|
||||||
|
if (!delta || !inRun) counted++
|
||||||
|
inRun = delta
|
||||||
|
}
|
||||||
|
} else if (taken.size >= limit) {
|
||||||
|
wanting = false
|
||||||
|
}
|
||||||
|
if (wanting) taken.addFirst(line)
|
||||||
|
wanting
|
||||||
|
}
|
||||||
|
}
|
||||||
|
taken.toList()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `end` of the nearest chunk at or below [before], which is the floor a fetched page is
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
@Synchronized
|
||||||
|
fun coveredUpTo(before: Long): Long? =
|
||||||
|
guard(null) { chunks().map { it.end }.filter { it <= before }.maxOrNull() }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores a fetched page covering `[first, end)`; false when it was not stored.
|
||||||
|
*
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
@Synchronized
|
||||||
|
fun storePage(lines: List<String>, first: Long, end: Long, rows: Boolean): Boolean =
|
||||||
|
guard(false) {
|
||||||
|
if (disabled || lines.isEmpty() || end <= first) return@guard false
|
||||||
|
if (chunks().any { first < it.end && it.first < end }) return@guard false
|
||||||
|
dir.mkdirs()
|
||||||
|
val kind = if (rows) "rows" else "raw"
|
||||||
|
File(dir, "$first-$end.$kind.jsonl").writeText(lines.joinToString("\n", postfix = "\n"))
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
@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.
|
||||||
|
writer.write(line)
|
||||||
|
writer.write("\n")
|
||||||
|
openEnd = seq + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flushes what [append] has buffered. Called on each `Status` event -- the boundaries of a
|
||||||
|
* turn, which is the granularity a crash may as well lose -- and when the stream closes.
|
||||||
|
*/
|
||||||
|
@Synchronized fun flush() = guard(Unit) { writer?.flush() }
|
||||||
|
|
||||||
|
/** What [purge] would discard, for the reload row in session settings. */
|
||||||
|
@Synchronized fun bytes(): Long = guard(0L) { sizeOf(dir) }
|
||||||
|
|
||||||
|
/** Marks this session as visited, which is what eviction ranks by. */
|
||||||
|
@Synchronized
|
||||||
|
fun touch() =
|
||||||
|
guard(Unit) { if (dir.isDirectory) dir.setLastModified(System.currentTimeMillis()) }
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun purge() =
|
||||||
|
guard(Unit) {
|
||||||
|
closeWriter()
|
||||||
|
dir.deleteRecursively()
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- chunks ------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private data class Chunk(val file: File, val first: Long, val end: Long, val open: Boolean) {
|
||||||
|
val rows: Boolean
|
||||||
|
get() = file.name.endsWith(".rows.jsonl")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
private fun chunks(): List<Chunk> {
|
||||||
|
writer?.flush()
|
||||||
|
return dir.listFiles()
|
||||||
|
.orEmpty()
|
||||||
|
.mapNotNull { file ->
|
||||||
|
val match = CHUNK_NAME.matchEntire(file.name) ?: return@mapNotNull null
|
||||||
|
val first = match.groupValues[1].toLongOrNull() ?: return@mapNotNull null
|
||||||
|
val open = match.groupValues[2] == "open"
|
||||||
|
val end = if (open) openEndOf(file, first) else match.groupValues[2].toLongOrNull()
|
||||||
|
// A chunk covering nothing is one that was created and never written to -- an
|
||||||
|
// append whose very first write failed. It says nothing, so it is not a chunk.
|
||||||
|
if (end == null || end <= first) null else Chunk(file, first, end, open)
|
||||||
|
}
|
||||||
|
.sortedBy { it.first }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The open chunk's end: its last line's seq plus one, or the in-memory end while this instance
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
private fun openEndOf(file: File, first: Long): Long {
|
||||||
|
if (openFile == file && openEnd > 0) return openEnd
|
||||||
|
repairTail(file)
|
||||||
|
var end = first
|
||||||
|
eachLineBackwards(file) { _, line ->
|
||||||
|
seqOf(line)?.let { end = it + 1 }
|
||||||
|
false
|
||||||
|
}
|
||||||
|
return end
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
private fun suffix(): List<Chunk> {
|
||||||
|
val all = chunks()
|
||||||
|
var index = all.size - 1
|
||||||
|
val newest = all.lastOrNull() ?: return emptyList()
|
||||||
|
if (newest.rows) throw Damaged(newest.file)
|
||||||
|
val run = ArrayDeque<Chunk>()
|
||||||
|
run.addFirst(newest)
|
||||||
|
while (index > 0 && all[index - 1].end == run.first().first) {
|
||||||
|
index--
|
||||||
|
run.addFirst(all[index])
|
||||||
|
}
|
||||||
|
return run.toList()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* 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
|
||||||
|
* session rather than serving what it can read.
|
||||||
|
*/
|
||||||
|
private fun eachLine(chunk: Chunk, take: (String) -> Boolean) {
|
||||||
|
eachLineBackwards(chunk.file) { _, line ->
|
||||||
|
if (seqOf(line) == null) throw Damaged(chunk.file)
|
||||||
|
take(line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- writing -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** The writer for the chunk [seq] belongs in, opening or rotating one as it has to. */
|
||||||
|
private fun writerFor(seq: Long): BufferedWriter? {
|
||||||
|
writer?.let { held ->
|
||||||
|
if (seq == openEnd) return held
|
||||||
|
if (seq < openEnd) return null
|
||||||
|
// A gap: what this instance has written covers up to `openEnd`, and that is the name
|
||||||
|
// the chunk gets before a new one starts at the arriving seq.
|
||||||
|
closeOpenChunk(openEnd)
|
||||||
|
}
|
||||||
|
dir.mkdirs()
|
||||||
|
// An open chunk left by an earlier instance, or by an earlier screen.
|
||||||
|
chunks()
|
||||||
|
.lastOrNull { it.open }
|
||||||
|
?.let { existing ->
|
||||||
|
if (seq < existing.end) return null
|
||||||
|
if (seq == existing.end) {
|
||||||
|
openFile = existing.file
|
||||||
|
openEnd = existing.end
|
||||||
|
return FileWriter(existing.file, true).buffered().also { writer = it }
|
||||||
|
}
|
||||||
|
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.
|
||||||
|
dir.listFiles().orEmpty().forEach {
|
||||||
|
if (CHUNK_NAME.matchEntire(it.name)?.groupValues?.get(2) == "open" && it.length() == 0L)
|
||||||
|
it.delete()
|
||||||
|
}
|
||||||
|
val file = File(dir, "$seq-open.raw.jsonl")
|
||||||
|
openFile = file
|
||||||
|
openEnd = seq
|
||||||
|
return FileWriter(file, false).buffered().also { writer = it }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Renames the open chunk to the range it turned out to cover, so it stops being open. */
|
||||||
|
private fun closeOpenChunk(end: Long) {
|
||||||
|
val file = openFile
|
||||||
|
closeWriter()
|
||||||
|
if (file == null) return
|
||||||
|
val first = CHUNK_NAME.matchEntire(file.name)?.groupValues?.get(1)?.toLongOrNull()
|
||||||
|
if (first != null) rename(file, first, end)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun rename(file: File, first: Long, end: Long) {
|
||||||
|
file.renameTo(File(dir, "$first-$end.raw.jsonl"))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun closeWriter() {
|
||||||
|
try {
|
||||||
|
writer?.close()
|
||||||
|
} catch (_: IOException) {
|
||||||
|
// Nothing left to do about it: the file is what it is, and the read path repairs a
|
||||||
|
// half-written tail.
|
||||||
|
}
|
||||||
|
writer = null
|
||||||
|
openFile = null
|
||||||
|
openEnd = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- failure -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** A chunk that cannot be read as what its name claims. */
|
||||||
|
private class Damaged(val file: File) : RuntimeException()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs [body], answering [ifBroken] when the directory cannot give a real answer.
|
||||||
|
*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
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.
|
||||||
|
guardIo(
|
||||||
|
ifBroken,
|
||||||
|
warn,
|
||||||
|
onFailure = {
|
||||||
|
disabled = true
|
||||||
|
closeWriter()
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
body()
|
||||||
|
} catch (e: Damaged) {
|
||||||
|
warn("transcript cache damaged at ${e.file}; discarding ${dir.name}")
|
||||||
|
closeWriter()
|
||||||
|
dir.deleteRecursively()
|
||||||
|
ifBroken
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `<first>-<end|open>.<rows|raw>.jsonl`; anything else in the directory is not ours. */
|
||||||
|
private val CHUNK_NAME = Regex("""^(\d+)-(\d+|open)\.(rows|raw)\.jsonl$""")
|
||||||
|
|
||||||
|
private val SEQ_IN_LINE = Regex(""""seq"\s*:\s*(\d+)""")
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
private fun seqOf(line: String): Long? = SEQ_IN_LINE.find(line)?.groupValues?.get(1)?.toLongOrNull()
|
||||||
|
|
||||||
|
/** Whether a line is one streamed piece of a reply, which is what makes a run of them one row. */
|
||||||
|
private fun isDelta(line: String): Boolean =
|
||||||
|
TYPE_IN_LINE.find(line)?.groupValues?.get(1) == "assistantText"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How much of a file is read at a time when walking it backwards. One block covers a page of a
|
||||||
|
* transcript comfortably, and the walk stops as soon as the caller has what it asked for.
|
||||||
|
*/
|
||||||
|
private const val READ_BLOCK = 64 * 1024
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calls [onLine] with each non-blank line of [file], **newest first**, along with the byte offset
|
||||||
|
* it starts at, until [onLine] answers false.
|
||||||
|
*
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
private fun eachLineBackwards(file: File, onLine: (offset: Long, line: String) -> Boolean) {
|
||||||
|
if (!file.isFile) return
|
||||||
|
RandomAccessFile(file, "r").use { handle ->
|
||||||
|
// Bytes below `unread` have not been looked at; `pending` is the oldest line so far, which
|
||||||
|
// is incomplete until a newline is found before it in an older block.
|
||||||
|
var unread = handle.length()
|
||||||
|
var pending = ByteArray(0)
|
||||||
|
while (unread > 0) {
|
||||||
|
val take = minOf(READ_BLOCK.toLong(), unread).toInt()
|
||||||
|
val start = unread - take
|
||||||
|
val block = ByteArray(take)
|
||||||
|
handle.seek(start)
|
||||||
|
handle.readFully(block)
|
||||||
|
val buffer = if (pending.isEmpty()) block else block + pending
|
||||||
|
var lineEnd = buffer.size
|
||||||
|
var at = buffer.size - 1
|
||||||
|
while (at >= 0) {
|
||||||
|
if (buffer[at] == NEWLINE) {
|
||||||
|
val line = String(buffer, at + 1, lineEnd - at - 1, Charsets.UTF_8)
|
||||||
|
if (line.isNotBlank() && !onLine(start + at + 1, line)) return
|
||||||
|
lineEnd = at
|
||||||
|
}
|
||||||
|
at--
|
||||||
|
}
|
||||||
|
pending = buffer.copyOfRange(0, lineEnd)
|
||||||
|
unread = start
|
||||||
|
}
|
||||||
|
// The first line of a file has no newline before it to be found.
|
||||||
|
val first = String(pending, Charsets.UTF_8)
|
||||||
|
if (first.isNotBlank()) onLine(0, first)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
private fun repairTail(file: File) {
|
||||||
|
var truncateTo = -1L
|
||||||
|
eachLineBackwards(file) { offset, line ->
|
||||||
|
if (seqOf(line) == null) truncateTo = offset
|
||||||
|
false
|
||||||
|
}
|
||||||
|
if (truncateTo >= 0) RandomAccessFile(file, "rw").use { it.setLength(truncateTo) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sizeOf(file: File): Long =
|
||||||
|
if (file.isDirectory) file.listFiles().orEmpty().sumOf { sizeOf(it) } else file.length()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
private fun <T> guardIo(
|
||||||
|
ifBroken: T,
|
||||||
|
warn: (String) -> Unit,
|
||||||
|
onFailure: () -> Unit = {},
|
||||||
|
body: () -> T,
|
||||||
|
): T =
|
||||||
|
try {
|
||||||
|
body()
|
||||||
|
} catch (e: IOException) {
|
||||||
|
warn("transcript cache unusable: ${e.message}")
|
||||||
|
onFailure()
|
||||||
|
ifBroken
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
warn("transcript cache unreadable: ${e.message}")
|
||||||
|
onFailure()
|
||||||
|
ifBroken
|
||||||
|
}
|
||||||
@@ -5,9 +5,14 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What the transcript renders: the event stream folded into displayable rows (see [foldEvent]). The
|
* What the transcript renders: the event stream folded into displayable rows (see [foldEvent]).
|
||||||
* stream is the only data source -- opening a session screen replays from seq 0, and a reconnect
|
*
|
||||||
* resumes from the last seq seen, so there is no separate history fetch to drift from it.
|
* Events are the only data source, and there is deliberately no second shape for history to drift
|
||||||
|
* from: a page fetched backwards, a live frame, and a line read out of this phone's own cache are
|
||||||
|
* all the same events through the same parser. Since 2026-09-04 the cache is where most of them
|
||||||
|
* come from on a session opened again -- see [TranscriptCache], which stores the server's lines
|
||||||
|
* rather than these rows for exactly that reason: a row is a rendering, and its shape changes
|
||||||
|
* whenever this file does.
|
||||||
*/
|
*/
|
||||||
@Immutable
|
@Immutable
|
||||||
sealed class TranscriptItem {
|
sealed class TranscriptItem {
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
package com.example.aiapp
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import java.io.File
|
||||||
|
import java.util.concurrent.atomic.AtomicReference
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the session screen gets a transcript from: this phone's copy first, the server for the
|
||||||
|
* rest.
|
||||||
|
*
|
||||||
|
* One seam rather than a cache the screen has to remember to consult. Everything it fetched before
|
||||||
|
* -- the opening window, the pages it scrolls back through, the span an anchor restore reaches for
|
||||||
|
* -- is asked of this, and everything the server sends is written into the cache on the way past,
|
||||||
|
* so the screen never learns which side answered. What it does learn, through [DebugStats], is how
|
||||||
|
* often each one did, which is how the saving is measured.
|
||||||
|
*
|
||||||
|
* See TRANSCRIPT_CACHE.md. The one rule worth keeping in mind here: the cache is never
|
||||||
|
* load-bearing. Every read has a network path beside it producing the same result, so a missing,
|
||||||
|
* evicted or damaged cache degrades to exactly what this screen did before it existed.
|
||||||
|
*/
|
||||||
|
class TranscriptSource(
|
||||||
|
private val settings: ServerSettings,
|
||||||
|
private val sessionId: String,
|
||||||
|
val cache: SessionCache,
|
||||||
|
) {
|
||||||
|
private val stream = AtomicReference<EventStream?>(null)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The cached opening window, or null when there is nothing usable to draw.
|
||||||
|
*
|
||||||
|
* Drawn *before* [probe] returns, which is the whole point of the feature: the rows are on
|
||||||
|
* screen while the check that they are still the server's rows is in flight, and a failed check
|
||||||
|
* replaces them exactly as a `reset` does.
|
||||||
|
*/
|
||||||
|
fun cachedOpening(limit: Int = OPENING_WINDOW): List<SeqEvent>? {
|
||||||
|
if (cache.tail() == null) return null
|
||||||
|
val lines = cache.newest(limit)
|
||||||
|
if (lines.isEmpty()) return null
|
||||||
|
return try {
|
||||||
|
lines.map { parseSeqEvent(it) }
|
||||||
|
} catch (e: org.json.JSONException) {
|
||||||
|
// Lines this build cannot read at all, which the cache's own checks cannot see: it
|
||||||
|
// reads a seq off a line, not an event. Nothing to serve, so a cold open.
|
||||||
|
cache.purge()
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the server's event at the cached cursor is still the cached one.
|
||||||
|
*
|
||||||
|
* The screen must not resume a stream from a cached seq unless it is the same conversation. A
|
||||||
|
* transcript is append-only in ordinary use, but the file can be replaced or truncated -- a
|
||||||
|
* sandbox re-seeded with the same ids, a backup restored, a directory deleted and the session
|
||||||
|
* re-imported -- and the server's catch-up on such a file would hand this phone a continuation
|
||||||
|
* of a *different* conversation, spliced onto the cached one with no seam. That is the worst
|
||||||
|
* thing this feature can do, and it is caught with one request of a few hundred bytes, in the
|
||||||
|
* slot the opening page's request used to be in.
|
||||||
|
*
|
||||||
|
* False purges the cache and means "open cold". A throw is the server not being askable, which
|
||||||
|
* is neither: the cached rows stay on screen, the failure goes on the stream banner, and the
|
||||||
|
* caller tries again on the stream's own reconnect schedule.
|
||||||
|
*
|
||||||
|
* What this cannot see is a line changed in the middle of the file with the tail intact. That
|
||||||
|
* is what the Reload button in session settings is for, and its caption says so.
|
||||||
|
*/
|
||||||
|
suspend fun probe(): Boolean {
|
||||||
|
val tail = cache.tail() ?: return false
|
||||||
|
// `before = seq + 1` is the newest event with seq <= the cursor, which is the event *at*
|
||||||
|
// the cursor when the server still has one there.
|
||||||
|
val answer = fetchTranscript(settings, sessionId, before = tail.seq + 1, limit = 1)
|
||||||
|
val matches =
|
||||||
|
answer.size == 1 &&
|
||||||
|
try {
|
||||||
|
answer[0].second == parseSeqEvent(tail.line)
|
||||||
|
} catch (e: org.json.JSONException) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
if (!matches) cache.purge()
|
||||||
|
return matches
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Today's opening fetch, kept as the start of the live run. Only called when the cache has
|
||||||
|
* nothing to open with, or when [probe] said what it had was not the server's.
|
||||||
|
*/
|
||||||
|
suspend fun fetchOpening(): List<SeqEvent> {
|
||||||
|
DebugStats.count("transcript page from server")
|
||||||
|
val page = fetchTranscript(settings, sessionId, limit = OPENING_WINDOW)
|
||||||
|
page.forEach { (line, entry) -> cache.append(line, entry.seq) }
|
||||||
|
cache.flush()
|
||||||
|
return page.map { it.second }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The page before [before]: from the cache when it holds it, otherwise from the server bounded
|
||||||
|
* by what the cache already has.
|
||||||
|
*
|
||||||
|
* The bound is what keeps the cache worth having. A coalesced page reaches back as far as its
|
||||||
|
* row count takes it -- a single reply is hundreds of lines -- so a page fetched after the
|
||||||
|
* reader has been away would run straight past the cached run and overlap it, and an
|
||||||
|
* overlapping page cannot be stored. Told where this phone's copy starts, the server stops
|
||||||
|
* there instead, the gap is closed with exactly the bytes it was wide, and the history behind
|
||||||
|
* it is served locally from then on.
|
||||||
|
*/
|
||||||
|
suspend fun page(before: Long, limit: Int, coalesce: Boolean): List<SeqEvent> {
|
||||||
|
cache.page(before, limit, rows = coalesce)?.let { lines ->
|
||||||
|
DebugStats.count("transcript page from cache")
|
||||||
|
return lines.map { parseSeqEvent(it) }
|
||||||
|
}
|
||||||
|
DebugStats.count("transcript page from server")
|
||||||
|
val page =
|
||||||
|
fetchTranscript(
|
||||||
|
settings,
|
||||||
|
sessionId,
|
||||||
|
before = before,
|
||||||
|
limit = limit,
|
||||||
|
coalesce = coalesce,
|
||||||
|
after = cache.coveredUpTo(before)?.minus(1),
|
||||||
|
)
|
||||||
|
if (page.isNotEmpty()) {
|
||||||
|
// `before` rather than the newest line's seq: a coalesced page covers everything up to
|
||||||
|
// the cursor it was asked with, and nothing in its lines says so.
|
||||||
|
cache.storePage(page.map { it.first }, page.first().second.seq, before, rows = coalesce)
|
||||||
|
}
|
||||||
|
return page.map { it.second }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [EventStream.run], with every frame written to the cache before [onEvent] sees it.
|
||||||
|
*
|
||||||
|
* Before, so that an event held back for a reader who is scrolled away is already on disk --
|
||||||
|
* what the cache holds is what the server sent, not what the screen has got round to drawing.
|
||||||
|
* Flushed on each status change, which is a turn's boundary and the granularity a crash may as
|
||||||
|
* well lose.
|
||||||
|
*/
|
||||||
|
fun follow(after: Long, onOpen: () -> Unit, onReset: () -> Unit, onEvent: (SeqEvent) -> Unit) {
|
||||||
|
val opened = EventStream(settings, sessionId)
|
||||||
|
stream.getAndSet(opened)?.close()
|
||||||
|
try {
|
||||||
|
opened.run(after, onOpen, onReset) { raw, entry ->
|
||||||
|
cache.append(raw, entry.seq)
|
||||||
|
if (entry.event is SessionEvent.Status) cache.flush()
|
||||||
|
onEvent(entry)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
cache.flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ends the stream, from any thread, and leaves the cache with everything it was given. */
|
||||||
|
fun close() {
|
||||||
|
stream.getAndSet(null)?.close()
|
||||||
|
cache.flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many events the screen opens with, cached or fetched.
|
||||||
|
*
|
||||||
|
* The server's own default for a page, named here because the cached opening has to be the same
|
||||||
|
* size as the fetched one -- a reader must not get a shorter first screen for having been here
|
||||||
|
* before.
|
||||||
|
*/
|
||||||
|
private const val OPENING_WINDOW = 80
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where this server's cached transcripts live.
|
||||||
|
*
|
||||||
|
* Under `cacheDir` because that is exactly what it is for: bytes the phone can regenerate from the
|
||||||
|
* server, which Android may delete under storage pressure without asking. Keyed by host and port
|
||||||
|
* because two servers can hold a session with the same id -- the sandbox and the real server, or a
|
||||||
|
* re-enrolment -- and a line from one shown against the other is the whole invariant broken. `v1`
|
||||||
|
* is the layout's version: a change to it bumps the segment, and a directory of another version is
|
||||||
|
* deleted the first time this is called.
|
||||||
|
*/
|
||||||
|
fun cacheRoot(context: Context, settings: ServerSettings): File {
|
||||||
|
val transcripts = File(context.cacheDir, "transcripts")
|
||||||
|
transcripts.listFiles()?.forEach { if (it.name != CACHE_VERSION) it.deleteRecursively() }
|
||||||
|
return File(transcripts, "$CACHE_VERSION/${settings.host}_${settings.port}")
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val CACHE_VERSION = "v1"
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
package com.example.aiapp
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
import org.junit.jupiter.api.io.TempDir
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The cache's file logic, which is the half of the transcript cache that can be wrong without
|
||||||
|
* anything on screen saying so: a page served short, a chunk served across a gap, or a run of lines
|
||||||
|
* whose recorded coverage does not match what is in it.
|
||||||
|
*
|
||||||
|
* Lines here are the shape the server writes -- `{"seq":N,"ts":T,"type":...}` -- because that is
|
||||||
|
* what the cache reads its two facts off. Nothing parses JSON on either side.
|
||||||
|
*/
|
||||||
|
class TranscriptCacheTest {
|
||||||
|
@field:TempDir lateinit var temp: File
|
||||||
|
|
||||||
|
private val said = mutableListOf<String>()
|
||||||
|
|
||||||
|
private fun cache() = TranscriptCache(File(temp, "v1/host_8443")) { said += it }
|
||||||
|
|
||||||
|
private fun session(id: String = "s") = cache().session(id)
|
||||||
|
|
||||||
|
private fun line(seq: Long, type: String = "toolStart") =
|
||||||
|
"""{"seq":$seq,"ts":1.5,"type":"$type","id":"x"}"""
|
||||||
|
|
||||||
|
private fun delta(seq: Long) = line(seq, "assistantText")
|
||||||
|
|
||||||
|
private fun dirOf(id: String = "s") = File(temp, "v1/host_8443/$id")
|
||||||
|
|
||||||
|
private fun names(id: String = "s") = dirOf(id).list().orEmpty().sorted()
|
||||||
|
|
||||||
|
private fun write(name: String, lines: List<String>, id: String = "s") {
|
||||||
|
dirOf(id).mkdirs()
|
||||||
|
File(dirOf(id), name).writeText(lines.joinToString("\n", postfix = "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun seqs(lines: List<String>?) = lines?.map {
|
||||||
|
Regex("\"seq\":(\\d+)").find(it)!!.groupValues[1].toLong()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun an_appended_run_is_one_open_chunk_and_its_newest_line_is_the_tail() {
|
||||||
|
val cache = session()
|
||||||
|
(1L..3L).forEach { cache.append(line(it), it) }
|
||||||
|
cache.flush()
|
||||||
|
|
||||||
|
assertEquals(listOf("1-open.raw.jsonl"), names())
|
||||||
|
assertEquals(CachedTail(3, line(3)), cache.tail())
|
||||||
|
assertEquals(listOf(line(2), line(3)), cache.newest(2))
|
||||||
|
// More than there is is what there is, which is a short opening window and not a failure.
|
||||||
|
assertEquals(3, cache.newest(80).size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun a_gap_in_the_stream_closes_the_open_chunk_under_the_end_it_turned_out_to_have() {
|
||||||
|
val cache = session()
|
||||||
|
(1L..3L).forEach { cache.append(line(it), it) }
|
||||||
|
// What a `reset` looks like from here: the next event is not the one after the last.
|
||||||
|
cache.append(line(90), 90)
|
||||||
|
cache.flush()
|
||||||
|
|
||||||
|
assertEquals(listOf("1-4.raw.jsonl", "90-open.raw.jsonl"), names())
|
||||||
|
// Nothing is served across the gap: the suffix is the newest chunk alone.
|
||||||
|
assertEquals(listOf(line(90)), cache.newest(80))
|
||||||
|
assertEquals(CachedTail(90, line(90)), cache.tail())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun an_event_already_covered_is_not_written_again() {
|
||||||
|
val cache = session()
|
||||||
|
(1L..3L).forEach { cache.append(line(it), it) }
|
||||||
|
cache.append(line(2), 2)
|
||||||
|
cache.flush()
|
||||||
|
|
||||||
|
assertEquals(listOf(1L, 2L, 3L), seqs(cache.newest(80)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun an_adjacent_page_extends_the_suffix_and_a_gap_stops_it() {
|
||||||
|
val cache = session()
|
||||||
|
(100L..102L).forEach { cache.append(line(it), it) }
|
||||||
|
cache.flush()
|
||||||
|
|
||||||
|
// Adjacent: its end is the open chunk's first.
|
||||||
|
assertTrue(cache.storePage((60L..99L).map { line(it) }, 60, 100, rows = true))
|
||||||
|
assertEquals(listOf(98L, 99L), seqs(cache.page(before = 100, limit = 2, rows = false)))
|
||||||
|
assertEquals(60L, seqs(cache.newest(80))?.first())
|
||||||
|
|
||||||
|
// Behind a gap: kept on disk, because paging usually closes the gap, but never served
|
||||||
|
// across it.
|
||||||
|
assertTrue(cache.storePage((1L..9L).map { line(it) }, 1, 10, rows = true))
|
||||||
|
assertNull(cache.page(before = 10, limit = 5, rows = false))
|
||||||
|
assertEquals(60L, seqs(cache.newest(200))?.first())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun a_page_that_overlaps_what_is_here_is_not_stored() {
|
||||||
|
val cache = session()
|
||||||
|
cache.append(line(100), 100)
|
||||||
|
cache.flush()
|
||||||
|
assertTrue(cache.storePage((60L..99L).map { line(it) }, 60, 100, rows = true))
|
||||||
|
|
||||||
|
assertFalse(cache.storePage((50L..79L).map { line(it) }, 50, 80, rows = true))
|
||||||
|
assertFalse(cache.storePage(emptyList(), 40, 60, rows = true))
|
||||||
|
assertEquals(listOf("100-open.raw.jsonl", "60-100.rows.jsonl"), names())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun a_miss_is_null_and_never_an_empty_page() {
|
||||||
|
val cache = session()
|
||||||
|
(100L..102L).forEach { cache.append(line(it), it) }
|
||||||
|
cache.flush()
|
||||||
|
|
||||||
|
// At or below where the run starts, so what the reader is scrolling into is the server's.
|
||||||
|
// An empty list here would be read as the start of the conversation and would stop the
|
||||||
|
// transcript scrolling back at all.
|
||||||
|
assertNull(cache.page(before = 100, limit = 40, rows = true))
|
||||||
|
assertNull(cache.page(before = 40, limit = 40, rows = true))
|
||||||
|
assertNull(session("never-visited").page(before = 100, limit = 40, rows = true))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun a_page_starts_from_anywhere_inside_the_run_not_only_at_a_boundary() {
|
||||||
|
val cache = session()
|
||||||
|
(1L..10L).forEach { cache.append(line(it), it) }
|
||||||
|
cache.flush()
|
||||||
|
|
||||||
|
// Where a warm open leaves the cursor: in the middle of the live run, because the screen
|
||||||
|
// drew the newest lines of it. A cache that could only answer at a chunk boundary would
|
||||||
|
// send this to the server -- and the page that came back would overlap the run and be
|
||||||
|
// thrown away, so the whole of the scroll back would be fetched again on every visit.
|
||||||
|
assertEquals(listOf(5L, 6L, 7L), seqs(cache.page(before = 8, limit = 3, rows = false)))
|
||||||
|
assertEquals((1L..7L).toList(), seqs(cache.page(before = 8, limit = 99, rows = false)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun a_page_counted_in_rows_folds_each_delta_run_into_one_and_cuts_only_between_rows() {
|
||||||
|
val cache = session()
|
||||||
|
// Two replies of three deltas each, split by a tool call: the same fixture as the
|
||||||
|
// server's `coalescing_counts_rows_and_joins_delta_runs`.
|
||||||
|
val lines =
|
||||||
|
listOf(delta(1), delta(2), delta(3), line(4), delta(5), delta(6), delta(7), line(8))
|
||||||
|
write("1-9.raw.jsonl", lines)
|
||||||
|
cache.append(line(9), 9)
|
||||||
|
cache.flush()
|
||||||
|
|
||||||
|
// Three rows: the tool call at 8, the run 5..7, and the tool call at 4. The cut lands
|
||||||
|
// between rows, so the older run is not started.
|
||||||
|
assertEquals(
|
||||||
|
listOf(4L, 5L, 6L, 7L, 8L),
|
||||||
|
seqs(cache.page(before = 9, limit = 3, rows = true)),
|
||||||
|
)
|
||||||
|
// One row is one whole run, however many deltas it is made of.
|
||||||
|
assertEquals(listOf(8L), seqs(cache.page(before = 9, limit = 1, rows = true)))
|
||||||
|
// A page of lines counts lines, which is what the anchor restore asks for.
|
||||||
|
assertEquals(listOf(7L, 8L), seqs(cache.page(before = 9, limit = 2, rows = false)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun a_row_page_crosses_a_chunk_boundary_and_stops_short_at_the_oldest_chunk() {
|
||||||
|
val cache = session()
|
||||||
|
write("5-9.raw.jsonl", listOf(delta(5), delta(6), line(7), delta(8)))
|
||||||
|
cache.append(delta(9), 9)
|
||||||
|
cache.append(line(10), 10)
|
||||||
|
cache.flush()
|
||||||
|
|
||||||
|
// A run straddling the boundary is one row, as it will be once folded.
|
||||||
|
assertEquals(listOf(8L, 9L, 10L), seqs(cache.page(before = 11, limit = 2, rows = true)))
|
||||||
|
// Asking for more rows than the suffix holds is a short page, not a failure and not a
|
||||||
|
// claim that the conversation starts here.
|
||||||
|
assertEquals((5L..10L).toList(), seqs(cache.page(before = 11, limit = 40, rows = true)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun the_floor_for_a_fetch_is_the_nearest_chunk_at_or_below_it() {
|
||||||
|
val cache = session()
|
||||||
|
write("1-10.rows.jsonl", (1L..9L).map { line(it) })
|
||||||
|
write("10-40.rows.jsonl", (10L..39L).map { line(it) })
|
||||||
|
cache.append(line(90), 90)
|
||||||
|
cache.flush()
|
||||||
|
|
||||||
|
// The run behind the gap, which is what makes the fetched page adjacent to it: a page
|
||||||
|
// fetched before 90 with a floor of 39 stops at 40 and closes the gap exactly.
|
||||||
|
assertEquals(40L, cache.coveredUpTo(90))
|
||||||
|
assertEquals(40L, cache.coveredUpTo(41))
|
||||||
|
assertEquals(10L, cache.coveredUpTo(10))
|
||||||
|
// Nothing at or below the oldest chunk's start, so the page is bounded only by its limit.
|
||||||
|
assertNull(cache.coveredUpTo(9))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun a_newest_chunk_that_is_not_raw_discards_the_session() {
|
||||||
|
val cache = session()
|
||||||
|
write("1-10.rows.jsonl", (1L..9L).map { line(it) })
|
||||||
|
|
||||||
|
// Only reachable by dying between closing one live run and opening the next, and there is
|
||||||
|
// no cursor to be read off a coalesced line -- so the open is a cold one.
|
||||||
|
assertNull(cache.tail())
|
||||||
|
assertFalse(dirOf().exists())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun a_half_written_last_line_is_dropped_and_the_file_repaired() {
|
||||||
|
val cache = session()
|
||||||
|
dirOf().mkdirs()
|
||||||
|
File(dirOf(), "1-open.raw.jsonl").writeText(line(1) + "\n" + line(2) + "\n" + """{"se""")
|
||||||
|
|
||||||
|
assertEquals(CachedTail(2, line(2)), cache.tail())
|
||||||
|
assertEquals(line(1) + "\n" + line(2) + "\n", File(dirOf(), "1-open.raw.jsonl").readText())
|
||||||
|
// And the run continues from where the good tail left off.
|
||||||
|
cache.append(line(3), 3)
|
||||||
|
cache.flush()
|
||||||
|
assertEquals(listOf(1L, 2L, 3L), seqs(cache.newest(80)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun damage_anywhere_else_discards_the_session_when_a_read_reaches_it() {
|
||||||
|
val cache = session()
|
||||||
|
write("1-open.raw.jsonl", listOf(line(1), "not ours", line(3)))
|
||||||
|
|
||||||
|
// Not seen by the tail, which reads the newest line and stops -- reading a chunk from its
|
||||||
|
// end is exactly not reading the rest of it, and that is what keeps a warm open cheap on
|
||||||
|
// a conversation of tens of megabytes.
|
||||||
|
assertEquals(CachedTail(3, line(3)), cache.tail())
|
||||||
|
// Reached by a read that walks past it, and there is no honest way to say what a chunk
|
||||||
|
// covers with a line of it unreadable -- so what is served is nothing, and the session
|
||||||
|
// opens cold from here on.
|
||||||
|
assertEquals(emptyList(), cache.newest(80))
|
||||||
|
assertFalse(dirOf().exists())
|
||||||
|
assertTrue(said.any { it.contains("damaged") })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun a_name_this_does_not_recognise_is_ignored() {
|
||||||
|
val cache = session()
|
||||||
|
write("notes.txt", listOf("hello"))
|
||||||
|
write("1-open.raw.jsonl", listOf(line(1)))
|
||||||
|
|
||||||
|
assertEquals(CachedTail(1, line(1)), cache.tail())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun a_chunk_larger_than_one_read_block_is_walked_across_the_boundaries() {
|
||||||
|
val cache = session()
|
||||||
|
// Well past the 64 kB block the backwards reader takes at a time, so a page has to be
|
||||||
|
// stitched across several of them -- including a line that straddles a boundary, which
|
||||||
|
// is the case nothing else here would notice going wrong.
|
||||||
|
val padding = "x".repeat(300)
|
||||||
|
val lines = (1L..500L).map { """{"seq":$it,"ts":1.5,"type":"toolStart","id":"$padding"}""" }
|
||||||
|
write("1-open.raw.jsonl", lines)
|
||||||
|
|
||||||
|
assertEquals(500L, cache.tail()!!.seq)
|
||||||
|
assertEquals(lines.takeLast(80), cache.newest(80))
|
||||||
|
assertEquals(lines.subList(0, 400), cache.page(before = 401, limit = 999, rows = false))
|
||||||
|
// And a non-ASCII line, whose bytes a naive split could cut through a character.
|
||||||
|
val accented = """{"seq":501,"ts":1.5,"type":"assistantText","delta":"héllo — ok"}"""
|
||||||
|
cache.append(accented, 501)
|
||||||
|
cache.flush()
|
||||||
|
assertEquals(accented, cache.tail()!!.line)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun eviction_takes_the_least_recently_touched_and_never_the_one_on_screen() {
|
||||||
|
val cache = cache()
|
||||||
|
listOf("old", "middle", "open").forEachIndexed { at, id ->
|
||||||
|
write("1-open.raw.jsonl", List(50) { line(it + 1L) }, id = id)
|
||||||
|
dirOf(id).setLastModified(1_000_000L + at * 1000L)
|
||||||
|
}
|
||||||
|
val each = dirOf("old").walkTopDown().filter { it.isFile }.sumOf { it.length() }
|
||||||
|
|
||||||
|
// Room for two of the three, so the oldest goes -- and the session being read never does,
|
||||||
|
// however long ago it was last touched.
|
||||||
|
cache.evictToBudget(keep = "open", budget = each * 2)
|
||||||
|
assertEquals(listOf("middle", "open"), File(temp, "v1/host_8443").list()!!.sorted())
|
||||||
|
|
||||||
|
cache.evictToBudget(keep = "open", budget = 0)
|
||||||
|
assertEquals(listOf("open"), File(temp, "v1/host_8443").list()!!.sorted())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun retaining_deletes_exactly_the_sessions_the_server_no_longer_lists() {
|
||||||
|
val cache = cache()
|
||||||
|
listOf("a", "b", "c").forEach { write("1-open.raw.jsonl", listOf(line(1)), id = it) }
|
||||||
|
|
||||||
|
cache.retainOnly(setOf("a", "c"))
|
||||||
|
assertEquals(listOf("a", "c"), File(temp, "v1/host_8443").list()!!.sorted())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun size_and_purge_are_the_two_halves_of_the_reload_button() {
|
||||||
|
val cache = session()
|
||||||
|
assertEquals(0L, cache.bytes())
|
||||||
|
(1L..5L).forEach { cache.append(line(it), it) }
|
||||||
|
cache.flush()
|
||||||
|
|
||||||
|
assertTrue(cache.bytes() > 0)
|
||||||
|
cache.purge()
|
||||||
|
assertEquals(0L, cache.bytes())
|
||||||
|
assertNull(cache.tail())
|
||||||
|
// And the session is usable again straight afterwards, which is what a reload does next.
|
||||||
|
cache.append(line(9), 9)
|
||||||
|
cache.flush()
|
||||||
|
assertEquals(listOf(9L), seqs(cache.newest(80)))
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
-1
@@ -23,7 +23,13 @@ tokio-stream = { version = "0.1", features = ["sync"] }
|
|||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
# `float_roundtrip` because this server hands out the same transcript line two ways -- the
|
||||||
|
# `/transcript` page and the SSE backlog both parse it out of the file and serialize it again --
|
||||||
|
# and serde_json's default float parser is not correctly rounded. Measured 2026-09-04: a `ts` of
|
||||||
|
# 1788546972.6030757 in the file came back as ...0755, so the two answers to "what is line 30"
|
||||||
|
# differed in the last bit while looking identical. What made that visible was the phone's
|
||||||
|
# transcript cache, which compares a line it already holds against the server's own answer.
|
||||||
|
serde_json = { version = "1", features = ["float_roundtrip"] }
|
||||||
# The config file's format. Not JSON, because this file is written and read
|
# The config file's format. Not JSON, because this file is written and read
|
||||||
# by hand and RON says a sum type as syntax. The two house rules both
|
# by hand and RON says a sum type as syntax. The two house rules both
|
||||||
# projects write it under live in wg-app-link; this is here for the error
|
# projects write it under live in wg-app-link; this is here for the error
|
||||||
|
|||||||
@@ -21,6 +21,9 @@
|
|||||||
//! GET /sessions/{id}/events?after=N SSE: backlog after N, then live
|
//! GET /sessions/{id}/events?after=N SSE: backlog after N, then live
|
||||||
//! (a backlog past CATCH_UP_LIMIT arrives as a
|
//! (a backlog past CATCH_UP_LIMIT arrives as a
|
||||||
//! `reset` frame plus the newest window)
|
//! `reset` frame plus the newest window)
|
||||||
|
//! GET /sessions/{id}/transcript a page of history: ?before=N (newest when absent),
|
||||||
|
//! ?limit=N, ?coalesce=true to count rows not deltas,
|
||||||
|
//! ?after=N to floor it at what the caller already holds
|
||||||
//! POST /sessions/{id}/message {text, attachmentIds?}
|
//! POST /sessions/{id}/message {text, attachmentIds?}
|
||||||
//! (starts the process first if it has exited)
|
//! (starts the process first if it has exited)
|
||||||
//! POST /sessions/{id}/unqueue {messageId} -- take back one not read yet
|
//! POST /sessions/{id}/unqueue {messageId} -- take back one not read yet
|
||||||
@@ -1647,6 +1650,11 @@ struct TranscriptQuery {
|
|||||||
/// counts events to reach a known seq. See `read_window`.
|
/// counts events to reach a known seq. See `read_window`.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
coalesce: bool,
|
coalesce: bool,
|
||||||
|
/// Return nothing at or below this seq; the page stops here instead of at `limit`.
|
||||||
|
/// The phone passes the end of what it already holds, so a page never overlaps it.
|
||||||
|
/// Named to match the SSE route's `after`, and exclusive in the same way.
|
||||||
|
#[serde(default)]
|
||||||
|
after: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_window() -> usize {
|
fn default_window() -> usize {
|
||||||
@@ -1668,6 +1676,7 @@ async fn transcript(
|
|||||||
let events = crate::session::transcript::read_window(
|
let events = crate::session::transcript::read_window(
|
||||||
session.transcript_path(),
|
session.transcript_path(),
|
||||||
query.before,
|
query.before,
|
||||||
|
query.after,
|
||||||
query.limit,
|
query.limit,
|
||||||
query.coalesce,
|
query.coalesce,
|
||||||
)
|
)
|
||||||
@@ -1679,6 +1688,7 @@ async fn transcript(
|
|||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
session = %id,
|
session = %id,
|
||||||
before = ?query.before,
|
before = ?query.before,
|
||||||
|
after = ?query.after,
|
||||||
limit = query.limit,
|
limit = query.limit,
|
||||||
got = events.len(),
|
got = events.len(),
|
||||||
oldest = ?events.first().map(|entry| entry.seq),
|
oldest = ?events.first().map(|entry| entry.seq),
|
||||||
|
|||||||
@@ -148,9 +148,18 @@ impl Transcript {
|
|||||||
/// `before` pages backwards for history somebody actually scrolls to. Only
|
/// `before` pages backwards for history somebody actually scrolls to. Only
|
||||||
/// the window is parsed; see [`Indexed`] for why that is the whole cost of
|
/// the window is parsed; see [`Indexed`] for why that is the whole cost of
|
||||||
/// this call.
|
/// this call.
|
||||||
|
///
|
||||||
|
/// `after` is a floor: nothing at or below it is returned, and the page
|
||||||
|
/// stops there rather than at `limit`. A phone holding a cached run of the
|
||||||
|
/// transcript passes the end of what it already has, so the page it gets
|
||||||
|
/// back is exactly the gap and never overlaps its copy -- an overlap it
|
||||||
|
/// cannot store, since a coalesced event cannot be cut at a seq inside its
|
||||||
|
/// own delta run. A run cut by this floor is emitted as the partial it is,
|
||||||
|
/// exactly as one cut by `limit` already is.
|
||||||
pub fn read_window(
|
pub fn read_window(
|
||||||
path: &Path,
|
path: &Path,
|
||||||
before: Option<u64>,
|
before: Option<u64>,
|
||||||
|
after: Option<u64>,
|
||||||
limit: usize,
|
limit: usize,
|
||||||
coalesce: bool,
|
coalesce: bool,
|
||||||
) -> Result<Vec<SeqEvent>> {
|
) -> Result<Vec<SeqEvent>> {
|
||||||
@@ -161,15 +170,21 @@ pub fn read_window(
|
|||||||
Some(before) => indexed.first_at_or_after(before)?,
|
Some(before) => indexed.first_at_or_after(before)?,
|
||||||
None => indexed.lines.len(),
|
None => indexed.lines.len(),
|
||||||
};
|
};
|
||||||
|
let start = match after {
|
||||||
|
Some(after) => indexed.first_at_or_after(after.saturating_add(1))?,
|
||||||
|
None => 0,
|
||||||
|
};
|
||||||
|
// A floor above the window is an empty page, not a walk backwards past it.
|
||||||
|
let start = start.min(end);
|
||||||
// Coalescing counts *rows*, not events, and would misread the newest window: a message still
|
// Coalescing counts *rows*, not events, and would misread the newest window: a message still
|
||||||
// streaming there would fold to one event whose seq is its first delta, and the phone resumes
|
// streaming there would fold to one event whose seq is its first delta, and the phone resumes
|
||||||
// its live stream from the newest seq it applied -- so the deltas the coalesced event hid
|
// its live stream from the newest seq it applied -- so the deltas the coalesced event hid
|
||||||
// would replay and double. Only settled history (`before` set) is safe, and it is the only
|
// would replay and double. Only settled history (`before` set) is safe, and it is the only
|
||||||
// place the phone asks for it. See `parse_coalesced`.
|
// place the phone asks for it. See `parse_coalesced`.
|
||||||
if coalesce && before.is_some() {
|
if coalesce && before.is_some() {
|
||||||
indexed.parse_coalesced(end, limit)
|
indexed.parse_coalesced(start, end, limit)
|
||||||
} else {
|
} else {
|
||||||
indexed.parse(end.saturating_sub(limit)..end)
|
indexed.parse(start.max(end.saturating_sub(limit))..end)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -350,8 +365,9 @@ impl<'a> Indexed<'a> {
|
|||||||
/// next page pages from, land where they always did. A run cut by the `limit` (its older
|
/// next page pages from, land where they always did. A run cut by the `limit` (its older
|
||||||
/// deltas beyond this page) is emitted as the partial it is; the next page carries the rest and
|
/// deltas beyond this page) is emitted as the partial it is; the next page carries the rest and
|
||||||
/// the phone's `healSplitMessage` welds the two, exactly as it does for a run cut by any page
|
/// the phone's `healSplitMessage` welds the two, exactly as it does for a run cut by any page
|
||||||
/// boundary.
|
/// boundary. `start` is the same kind of cut from the other end -- the floor `read_window`'s
|
||||||
fn parse_coalesced(&self, end: usize, limit: usize) -> Result<Vec<SeqEvent>> {
|
/// `after` computes -- and a run reaching it is partial in the same way.
|
||||||
|
fn parse_coalesced(&self, start: usize, end: usize, limit: usize) -> Result<Vec<SeqEvent>> {
|
||||||
// Newest first while walking back, reversed to transcript order at the end.
|
// Newest first while walking back, reversed to transcript order at the end.
|
||||||
let mut out: Vec<SeqEvent> = Vec::new();
|
let mut out: Vec<SeqEvent> = Vec::new();
|
||||||
// The run currently being gathered: its oldest seq/ts so far, and its deltas newest-first.
|
// The run currently being gathered: its oldest seq/ts so far, and its deltas newest-first.
|
||||||
@@ -369,7 +385,7 @@ impl<'a> Indexed<'a> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
let mut index = end;
|
let mut index = end;
|
||||||
while index > 0 {
|
while index > start {
|
||||||
// A row is counted when it lands in `out`; an open run is the row being gathered, so
|
// A row is counted when it lands in `out`; an open run is the row being gathered, so
|
||||||
// stopping while one is open would drop the deltas already read. Break only between
|
// stopping while one is open would drop the deltas already read. Break only between
|
||||||
// rows, and flush the last run after the loop.
|
// rows, and flush the last run after the loop.
|
||||||
@@ -528,7 +544,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// No cursor is the newest page, which is what opening a session asks for.
|
// No cursor is the newest page, which is what opening a session asks for.
|
||||||
let newest = read_window(&path, None, 3, false).expect("window");
|
let newest = read_window(&path, None, None, 3, false).expect("window");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
newest.iter().map(|entry| entry.seq).collect::<Vec<_>>(),
|
newest.iter().map(|entry| entry.seq).collect::<Vec<_>>(),
|
||||||
[8, 9, 10]
|
[8, 9, 10]
|
||||||
@@ -536,7 +552,7 @@ mod tests {
|
|||||||
|
|
||||||
// Then backwards from the oldest of those, exclusive: the page a phone
|
// Then backwards from the oldest of those, exclusive: the page a phone
|
||||||
// scrolling up asks for must not repeat the row it is scrolling from.
|
// scrolling up asks for must not repeat the row it is scrolling from.
|
||||||
let older = read_window(&path, Some(8), 3, false).expect("window");
|
let older = read_window(&path, Some(8), None, 3, false).expect("window");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
older.iter().map(|entry| entry.seq).collect::<Vec<_>>(),
|
older.iter().map(|entry| entry.seq).collect::<Vec<_>>(),
|
||||||
[5, 6, 7]
|
[5, 6, 7]
|
||||||
@@ -544,24 +560,108 @@ mod tests {
|
|||||||
|
|
||||||
// Asking for more than there is gives what there is, rather than failing.
|
// Asking for more than there is gives what there is, rather than failing.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
read_window(&path, None, 100, false).expect("window").len(),
|
read_window(&path, None, None, 100, false)
|
||||||
|
.expect("window")
|
||||||
|
.len(),
|
||||||
10
|
10
|
||||||
);
|
);
|
||||||
|
|
||||||
// Nothing before the first event, which is how the phone learns to stop
|
// Nothing before the first event, which is how the phone learns to stop
|
||||||
// paging. An empty answer here is the end of the history, not a fault.
|
// paging. An empty answer here is the end of the history, not a fault.
|
||||||
assert!(
|
assert!(
|
||||||
read_window(&path, Some(1), 3, false)
|
read_window(&path, Some(1), None, 3, false)
|
||||||
.expect("window")
|
.expect("window")
|
||||||
.is_empty()
|
.is_empty()
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
read_window(&dir.path().join("nope.jsonl"), None, 3, false)
|
read_window(&dir.path().join("nope.jsonl"), None, None, 3, false)
|
||||||
.expect("window")
|
.expect("window")
|
||||||
.is_empty()
|
.is_empty()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_floor_stops_a_page_at_what_the_caller_already_holds() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let path = dir.path().join("transcript.jsonl");
|
||||||
|
let mut transcript = Transcript::open(&path).expect("open");
|
||||||
|
for n in 1..=10 {
|
||||||
|
transcript
|
||||||
|
.append(text(&n.to_string()), 0.0)
|
||||||
|
.expect("append");
|
||||||
|
}
|
||||||
|
|
||||||
|
// The floor is exclusive, like the SSE route's `after`, and it -- not the
|
||||||
|
// limit -- is what the page stops at. This is the gap between a phone's
|
||||||
|
// cached run and the window on its screen, fetched exactly.
|
||||||
|
let page = read_window(&path, Some(9), Some(5), 100, false).expect("window");
|
||||||
|
assert_eq!(
|
||||||
|
page.iter().map(|entry| entry.seq).collect::<Vec<_>>(),
|
||||||
|
[6, 7, 8]
|
||||||
|
);
|
||||||
|
|
||||||
|
// A limit smaller than the gap still bites; the floor is a bound, not a
|
||||||
|
// replacement for one.
|
||||||
|
let page = read_window(&path, Some(9), Some(2), 3, false).expect("window");
|
||||||
|
assert_eq!(
|
||||||
|
page.iter().map(|entry| entry.seq).collect::<Vec<_>>(),
|
||||||
|
[6, 7, 8]
|
||||||
|
);
|
||||||
|
|
||||||
|
// A floor at or above the window is an empty page, not a walk past it.
|
||||||
|
assert!(
|
||||||
|
read_window(&path, Some(4), Some(9), 10, false)
|
||||||
|
.expect("window")
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_floor_inside_a_delta_run_leaves_the_partial_run_it_cuts() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let path = dir.path().join("transcript.jsonl");
|
||||||
|
let mut transcript = Transcript::open(&path).expect("open");
|
||||||
|
for d in ["a", "b", "c", "d"] {
|
||||||
|
transcript.append(text(d), 0.0).expect("append"); // seq 1..4
|
||||||
|
}
|
||||||
|
transcript
|
||||||
|
.append(
|
||||||
|
Event::ToolStart {
|
||||||
|
id: "t".into(),
|
||||||
|
tool: "Bash".into(),
|
||||||
|
input: serde_json::Value::Null,
|
||||||
|
},
|
||||||
|
0.0,
|
||||||
|
)
|
||||||
|
.expect("append"); // seq 5
|
||||||
|
|
||||||
|
// Cut inside the run: what comes back is the deltas above the floor, seq'd
|
||||||
|
// at the first of them -- the partial the phone's `healSplitMessage` welds
|
||||||
|
// onto the rest, the same as a run cut by the limit.
|
||||||
|
let rows = read_window(&path, Some(6), Some(2), 10, true).expect("window");
|
||||||
|
assert_eq!(rows.len(), 2);
|
||||||
|
assert!(matches!(
|
||||||
|
&rows[0],
|
||||||
|
SeqEvent { seq: 3, event: Event::AssistantText { delta }, .. } if delta == "cd"
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
&rows[1],
|
||||||
|
SeqEvent {
|
||||||
|
seq: 5,
|
||||||
|
event: Event::ToolStart { .. },
|
||||||
|
..
|
||||||
|
}
|
||||||
|
));
|
||||||
|
|
||||||
|
// And with no floor the whole run is one row, as before.
|
||||||
|
let rows = read_window(&path, Some(6), None, 10, true).expect("window");
|
||||||
|
assert_eq!(rows.len(), 2);
|
||||||
|
assert!(matches!(
|
||||||
|
&rows[0],
|
||||||
|
SeqEvent { seq: 1, event: Event::AssistantText { delta }, .. } if delta == "abcd"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn coalescing_counts_rows_and_joins_delta_runs() {
|
fn coalescing_counts_rows_and_joins_delta_runs() {
|
||||||
let dir = tempfile::tempdir().expect("tempdir");
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
@@ -588,7 +688,7 @@ mod tests {
|
|||||||
|
|
||||||
// Three rows asked for, three rows returned -- each delta run one event -- where a raw
|
// Three rows asked for, three rows returned -- each delta run one event -- where a raw
|
||||||
// window of three would have shown one and a half tokens of the newer reply.
|
// window of three would have shown one and a half tokens of the newer reply.
|
||||||
let rows = read_window(&path, Some(8), 3, true).expect("window");
|
let rows = read_window(&path, Some(8), None, 3, true).expect("window");
|
||||||
assert_eq!(rows.len(), 3);
|
assert_eq!(rows.len(), 3);
|
||||||
// A run keeps its oldest delta's seq, so the phone anchors and pages from where it always
|
// A run keeps its oldest delta's seq, so the phone anchors and pages from where it always
|
||||||
// did.
|
// did.
|
||||||
@@ -610,14 +710,43 @@ mod tests {
|
|||||||
));
|
));
|
||||||
|
|
||||||
// The next page pages from the oldest row's seq and returns the rest, no repeat, no gap.
|
// The next page pages from the oldest row's seq and returns the rest, no repeat, no gap.
|
||||||
let older = read_window(&path, Some(1), 3, true).expect("window");
|
let older = read_window(&path, Some(1), None, 3, true).expect("window");
|
||||||
assert!(older.is_empty());
|
assert!(older.is_empty());
|
||||||
|
|
||||||
// The newest window never coalesces even when asked: the live cursor depends on real seqs.
|
// The newest window never coalesces even when asked: the live cursor depends on real seqs.
|
||||||
let newest = read_window(&path, None, 2, true).expect("window");
|
let newest = read_window(&path, None, None, 2, true).expect("window");
|
||||||
assert_eq!(newest.iter().map(|e| e.seq).collect::<Vec<_>>(), [6, 7]);
|
assert_eq!(newest.iter().map(|e| e.seq).collect::<Vec<_>>(), [6, 7]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_line_read_back_is_the_line_that_was_written() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let path = dir.path().join("transcript.jsonl");
|
||||||
|
// A timestamp with enough digits to be lost: the clock produces these all day, and this
|
||||||
|
// one is real (2026-09-04). serde_json's default float parser is not correctly rounded,
|
||||||
|
// so it read this back as ...0755 and every reader got a line one bit different from the
|
||||||
|
// one in the file -- while the SSE stream, which serializes the same struct, had already
|
||||||
|
// sent the original. Two answers to "what is line 1", indistinguishable by eye.
|
||||||
|
//
|
||||||
|
// Nothing on screen showed it: a `ts` is drawn as a relative time. What found it was the
|
||||||
|
// phone's transcript cache, which keeps the line it was sent and checks it against the
|
||||||
|
// server's own answer before resuming a stream from it -- so the mismatch turned into a
|
||||||
|
// cache thrown away and a transcript downloaded again, silently and only sometimes. The
|
||||||
|
// `float_roundtrip` feature in Cargo.toml is the fix; this is what keeps it.
|
||||||
|
let mut transcript = Transcript::open(&path).expect("open");
|
||||||
|
transcript
|
||||||
|
.append(text("hello"), 1788546972.6030757)
|
||||||
|
.expect("append");
|
||||||
|
drop(transcript);
|
||||||
|
|
||||||
|
let written = std::fs::read_to_string(&path).expect("read");
|
||||||
|
let entry = read_window(&path, None, None, 10, false).expect("window");
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_string(&entry[0]).expect("serialize"),
|
||||||
|
written.trim()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_missing_file_reads_as_empty() {
|
fn a_missing_file_reads_as_empty() {
|
||||||
let dir = tempfile::tempdir().expect("tempdir");
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
|||||||
Reference in new issue
Block a user