Files
ai-app/server/Cargo.toml
T
irisandClaude Opus 5 a802522039 Keep visited transcripts on the phone
Reopening a session downloaded the conversation again, every time, over
the tunnel. It now draws from a copy of what the server has already sent
and asks for one event to check that copy is still current.

Per session, under cacheDir, the server's own event lines in chunks named
for the range they cover -- so a coalesced page, whose lines do not say
what they cover, still records it. Only the contiguous run ending at the
newest chunk is served; a gap is closed by paging through it, bounded by
`after` on /transcript so the page stops where the phone's copy starts
and can therefore be kept. Nothing is derived and stored: rows are a
rendering, and a cache of them would need throwing away on every change
to the fold.

Nothing here is load-bearing. Missing, evicted, damaged or unwritable all
degrade to the cold open this screen did before, and the check before the
stream resumes -- one request, one event -- is what stops a replaced or
truncated file being spliced onto a copy of a different conversation.
What that check cannot see, a line changed mid-file with the tail intact,
is what Reload in session settings is for.

Measured on the emulator against ui-sandbox, on a 505-event session:
reopening it costs one request for one event, including scrolling the
whole conversation back; a cold open is two requests and 100 events. A
reset after falling 300 behind fetched the gap as four coalesced rows
rather than re-fetching 104 events and discarding them. Every chunk was
checked line by line against what the server says for the range its name
claims, across the reset and the gap-fill.

transcript-bench.sh, same viewport content and gestures, before and
after: p50 16.9ms both, p90 25.6 -> 23.2ms, p99 33.5 -> 36.7ms, and the
transcript's own draw accounting 0.33ms -> 0.32ms with place 0.31ms
either way. Within the emulator's noise, which is what a cache must be:
it changes what is fetched, not what is drawn.

Building it also found that the server handed out the same transcript
line two different ways. serde_json's default float parser is not
correctly rounded, so a ts written as ...0757 came back from /transcript
as ...0755 while the SSE stream sent the original -- invisible on screen,
since a ts is drawn as a relative time, and visible here only because the
cache compares a line it holds against the server's answer. Fixed with
float_roundtrip, with a test that fails the moment it is dropped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 15:00:25 -04:00

61 lines
2.9 KiB
TOML

[package]
name = "ai-server"
version = "0.1.0"
edition = "2024"
[[bin]]
name = "ai-server"
path = "src/main.rs"
[dependencies]
# The link both this and dev-updater need in order to be reached from a
# phone: wg binding, the pinned CA, QR enrollment, owner-only files, and
# the RON house rules. Extracted from the two copies that had drifted --
# see that repo's README for the evidence and the bug the extraction found.
wg-app-link = { path = "../wg-app-link/server" }
axum = { version = "0.8", features = ["json", "multipart"] }
axum-server = { version = "0.8", features = ["tls-rustls"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "sync", "time", "process", "io-util", "signal"] }
# `sync` for BroadcastStream: the notifications route turns the manager's
# broadcast channel straight into an SSE body, which is the one place here a
# broadcast receiver has to be a Stream rather than something to poll.
tokio-stream = { version = "0.1", features = ["sync"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
serde = { version = "1", features = ["derive"] }
# `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
# 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
# types the schema's own signatures name.
ron = "0.12.2"
clap = { version = "4", features = ["derive"] }
anyhow = "1"
thiserror = "2"
# Verifying a downloaded model against HuggingFace's published digest.
sha2 = "0.11"
# Naming a session directory, and an attachment inside one.
rand = "0.10"
# Decoding the images a phone attaches, and encoding them for a driver.
base64 = "0.23"
# Outbound HTTPS for the usage endpoint. A small blocking client fits an
# every-few-minutes poll better than pulling in reqwest's tower stack;
# rustls-backed like the rest of the TLS here.
ureq = { version = "3", features = ["json"] }
# Direct dependency only to pick the process-level CryptoProvider in main:
# ureq pulls rustls-with-ring, axum-server rustls-with-aws-lc-rs, and with
# both in the graph rustls refuses to auto-select one.
rustls = "0.23"
libc = "0.2.189"
[dev-dependencies]
tempfile = "3"
# ServiceExt::oneshot, to drive the auth middleware without a socket.
tower = { version = "0.5", features = ["util"] }