Condense the documentation and thin the server's comments
The markdown had accumulated a lot that was stale rather than wrong. PLAN.md still described pi as the llama.cpp harness, a refcounted LlamaServerManager, and a providers-by-hosts cross-product, all of which were superseded or never built; it also carried a second copy of the HTTP table that routes.rs owns. EXPLORER.md and TRANSCRIPT_CACHE.md held implementation checklists for work that has since landed. AGENTS.md restated most of PLAN.md's design instead of being the working-notes layer it says it is. 3225 lines of markdown to 2180, with the stale sections gone rather than reworded. On the server, comments explaining what the code already says are out and the ones recording a constraint, a measurement or an incident are kept but cut to a few lines each: 5504 comment lines to 4586. Four doc comments in session/mod.rs, and one each in process.rs and usage.rs, had drifted onto the item above the one they describe -- functions were reordered without them, so `stop_session`'s doc sat on `set_session_cwd`, `stat_of`'s on `struct Stat`, and `UsageMonitor`'s on `type Cached`. Each is back on its own item. routes.rs's module table also claimed later phases would add `/hosts`, which setups replaced. cargo test (127 passed), clippy --all-targets and fmt are clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e3e02d55f7
commit
79682f03a7
24 files changed
+4572
-6821
No files matched your search
@@ -41,9 +41,8 @@ impl Transcript {
|
||||
/// the last line if one exists.
|
||||
pub fn open(path: &Path) -> Result<Self> {
|
||||
// One pass for all three answers. They are wanted at the same moment
|
||||
// by the same caller, and reading the file again for each doubled
|
||||
// the cost of starting every session -- which is paid per session,
|
||||
// at the point a restart is trying to be quick.
|
||||
// by the same caller, and reading the file again for each doubled the
|
||||
// cost of starting every session.
|
||||
let existing = read_after(path, 0)?;
|
||||
let last_seq = existing.last().map(|entry| entry.seq).unwrap_or(0);
|
||||
let last_status = existing.iter().rev().find_map(|entry| match entry.event {
|
||||
@@ -63,9 +62,9 @@ impl Transcript {
|
||||
next_seq: last_seq + 1,
|
||||
last_status,
|
||||
last_activity: existing.last().map(|entry| entry.ts),
|
||||
// Folded rather than read off the newest usage entry: a clear
|
||||
// or a compaction after it is what the answer is, and those
|
||||
// events carry no usage of their own.
|
||||
// Folded rather than read off the newest usage entry: a clear or
|
||||
// a compaction after it is what the answer is, and those events
|
||||
// carry no usage of their own.
|
||||
context_tokens: existing
|
||||
.iter()
|
||||
.fold(None, |current, entry| context_after(current, &entry.event)),
|
||||
@@ -74,53 +73,43 @@ impl Transcript {
|
||||
|
||||
/// The state the session was last reported to be in, as of opening.
|
||||
///
|
||||
/// Read from the file rather than assumed, because a server that has
|
||||
/// just restarted has been told nothing yet and this is the only thing
|
||||
/// it knows. Assuming idle claimed a session was waiting for you when
|
||||
/// it had exited hours earlier, and would now also claim it of one
|
||||
/// whose process is still mid-turn.
|
||||
/// Read from the file rather than assumed, because a server that has just
|
||||
/// restarted has been told nothing. Assuming idle claimed a session was
|
||||
/// waiting for you when it had exited hours earlier.
|
||||
///
|
||||
/// `None` for a transcript that never carried a status, which is a new
|
||||
/// session and genuinely has no prior state.
|
||||
/// `None` for a transcript that never carried a status.
|
||||
pub fn last_status(&self) -> Option<SessionStatus> {
|
||||
self.last_status
|
||||
}
|
||||
|
||||
/// When this session last did anything, as of opening.
|
||||
///
|
||||
/// Read from the file for the same reason [`Transcript::last_status`]
|
||||
/// is, and it is the same mistake in the other direction: a restarting
|
||||
/// server has been told nothing, and taking the clock instead said every
|
||||
/// session it relaunched had been active this second. On the phone that
|
||||
/// is every row reading "just now" and the list -- which is sorted by
|
||||
/// this -- coming back in an order that means nothing, with the
|
||||
/// conversation somebody was in the middle of buried among sessions
|
||||
/// untouched for days.
|
||||
/// Read from the file for the reason [`Transcript::last_status`] is, and it
|
||||
/// is the same mistake in the other direction: taking the clock instead
|
||||
/// said every session it relaunched had been active this second. On the
|
||||
/// phone that is every row reading "just now" and the list -- sorted by
|
||||
/// this -- in an order that means nothing.
|
||||
///
|
||||
/// `None` for a transcript with no lines in it, which is a session that
|
||||
/// genuinely has not done anything yet. Its caller answers that with
|
||||
/// when the session was created -- not with the clock, which would say
|
||||
/// a session nobody has ever sent anything to was active a moment ago,
|
||||
/// every time this server started.
|
||||
/// `None` for a transcript with no lines, which is a session that genuinely
|
||||
/// has not done anything. Its caller answers with when the session was
|
||||
/// created, not with the clock.
|
||||
pub fn last_activity(&self) -> Option<f64> {
|
||||
self.last_activity
|
||||
}
|
||||
|
||||
/// How much context the session was holding, as of opening.
|
||||
///
|
||||
/// `None` for a transcript nothing has been measured in -- a new
|
||||
/// session, one whose dialect never reported usage, or one whose last
|
||||
/// word on the subject was a clear. That is not zero, and it is why
|
||||
/// this is an option: a server that has just restarted has been told
|
||||
/// nothing, and answering zero would draw an empty context for a
|
||||
/// conversation that may be nearly full.
|
||||
/// `None` for a transcript nothing has been measured in. That is not zero:
|
||||
/// a server that has just restarted has been told nothing, and answering
|
||||
/// zero would draw an empty context for a conversation that may be nearly
|
||||
/// full.
|
||||
pub fn context_tokens(&self) -> Option<u64> {
|
||||
self.context_tokens
|
||||
}
|
||||
|
||||
/// Appends `event`, assigning it the next sequence number. Flushed per
|
||||
/// event: each line is tiny, and the transcript is the source of truth
|
||||
/// a crash must not lose the tail of.
|
||||
/// event: each line is tiny, and the transcript is the source of truth a
|
||||
/// crash must not lose the tail of.
|
||||
pub fn append(&mut self, event: Event, ts: f64) -> Result<SeqEvent> {
|
||||
let entry = SeqEvent {
|
||||
seq: self.next_seq,
|
||||
@@ -139,23 +128,19 @@ impl Transcript {
|
||||
|
||||
/// A window of the transcript ending just before `before`, newest-biased.
|
||||
///
|
||||
/// The screen opens on the end of a conversation, not the start of it, and
|
||||
/// the end is all it can show at once. Replaying the whole file to get
|
||||
/// there costs one network frame per event -- on an 863-event import that
|
||||
/// was several seconds of messages arriving oldest-first, which reads as
|
||||
/// the app loading top-down because that is exactly what it was doing.
|
||||
/// The screen opens on the end of a conversation, and the end is all it can
|
||||
/// show at once. Replaying the whole file to get there costs one network frame
|
||||
/// per event -- on an 863-event import that was several seconds of messages
|
||||
/// arriving oldest-first, which reads as the app loading top-down.
|
||||
///
|
||||
/// `before` pages backwards for history somebody actually scrolls to. Only
|
||||
/// the window is parsed; see [`Indexed`] for why that is the whole cost of
|
||||
/// this call.
|
||||
/// `before` pages backwards for history somebody actually scrolls to. Only the
|
||||
/// window is parsed; see [`Indexed`] for why that is the whole cost.
|
||||
///
|
||||
/// `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.
|
||||
/// `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 passes the end of
|
||||
/// what it already has, so the page 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.
|
||||
pub fn read_window(
|
||||
path: &Path,
|
||||
before: Option<u64>,
|
||||
@@ -176,11 +161,11 @@ pub fn read_window(
|
||||
};
|
||||
// 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
|
||||
// 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
|
||||
// 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`.
|
||||
// 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 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.
|
||||
if coalesce && before.is_some() {
|
||||
indexed.parse_coalesced(start, end, limit)
|
||||
} else {
|
||||
@@ -191,41 +176,34 @@ pub fn read_window(
|
||||
/// How far behind a reconnecting subscriber can be and still be handed the
|
||||
/// backlog one event at a time.
|
||||
///
|
||||
/// Past this it is served better by rebuilding its view from the newest
|
||||
/// window than by receiving everything it missed. The events are the same
|
||||
/// either way; what differs is that one arrives as a single window and the
|
||||
/// other as thousands of frames a screen renders one by one. Set well
|
||||
/// above a screenful (`transcript`'s page is 80) so an ordinary blip -- a
|
||||
/// phone asleep, a tunnel reconnecting, a backend restart -- still streams
|
||||
/// continuously, and only a genuine backlog changes mode.
|
||||
/// Past this it is served better by rebuilding from the newest window. The
|
||||
/// events are the same either way; what differs is that one arrives as a single
|
||||
/// window and the other as thousands of frames a screen renders one by one. Set
|
||||
/// well above a screenful so an ordinary blip still streams continuously.
|
||||
pub const CATCH_UP_LIMIT: usize = 200;
|
||||
|
||||
/// What a subscriber asking for "everything after my cursor" gets back.
|
||||
///
|
||||
/// Two answers rather than one list, because they mean different things to
|
||||
/// the screen holding the cursor: one continues what it already has, the
|
||||
/// other replaces it. Collapsing them into a list would leave the client
|
||||
/// splicing a window onto rows it has no way to know are no longer
|
||||
/// adjacent to it -- a seam that looks exactly like ordinary output.
|
||||
/// Two answers rather than one list, because they mean different things to the
|
||||
/// screen holding the cursor: one continues what it has, the other replaces it.
|
||||
/// Collapsing them would leave the client splicing a window onto rows it has no
|
||||
/// way to know are no longer adjacent -- a seam that looks like ordinary output.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum CatchUp {
|
||||
/// The events after the cursor, continuing what the subscriber holds.
|
||||
Continue(Vec<SeqEvent>),
|
||||
/// The subscriber was further behind than [`CATCH_UP_LIMIT`]: the
|
||||
/// newest window, replacing whatever it holds. Earlier history is
|
||||
/// still there to be paged backwards through, exactly as it is when a
|
||||
/// session is first opened.
|
||||
/// The subscriber was further behind than [`CATCH_UP_LIMIT`]: the newest
|
||||
/// window, replacing whatever it holds. Earlier history is still there to be
|
||||
/// paged backwards through.
|
||||
Restart(Vec<SeqEvent>),
|
||||
}
|
||||
|
||||
/// Everything after `after`, or the newest `limit` when that is more than
|
||||
/// `limit` events.
|
||||
/// Everything after `after`, or the newest `limit` when that is more.
|
||||
///
|
||||
/// The window is chosen before anything is parsed, which matters most in
|
||||
/// the case that looks least interesting: a subscriber with no cursor at
|
||||
/// all asks for the whole conversation and is going to be handed the last
|
||||
/// [`CATCH_UP_LIMIT`] events of it. Parsing the discarded prefix first is
|
||||
/// the whole file's worth of work to produce a screenful.
|
||||
/// The window is chosen before anything is parsed, which matters most in the
|
||||
/// case that looks least interesting: a subscriber with no cursor asks for the
|
||||
/// whole conversation and is handed the last [`CATCH_UP_LIMIT`] events of it,
|
||||
/// so parsing the discarded prefix is the whole file's work for a screenful.
|
||||
pub fn catch_up(path: &Path, after: u64, limit: usize) -> Result<CatchUp> {
|
||||
let Some(indexed) = Indexed::read(path)? else {
|
||||
return Ok(CatchUp::Continue(Vec::new()));
|
||||
@@ -249,26 +227,22 @@ pub fn read_after(path: &Path, after: u64) -> Result<Vec<SeqEvent>> {
|
||||
indexed.parse(start..indexed.lines.len())
|
||||
}
|
||||
|
||||
/// The transcript's lines located but not read, so that a reader can find
|
||||
/// the range it wants and parse only that.
|
||||
/// The transcript's lines located but not read, so a reader can find the range
|
||||
/// it wants and parse only that.
|
||||
///
|
||||
/// Both readers above want a *range* of the file -- everything after a
|
||||
/// cursor, or the window before one -- and both used to reach it by parsing
|
||||
/// every line and discarding the ones outside it. That is the cost that
|
||||
/// grows with the conversation rather than with the answer: measured on a
|
||||
/// 21 MB, 24,000-event transcript, one page took **500 ms of server time to
|
||||
/// return 600 KB**, and it took the same 500 ms whichever page was asked
|
||||
/// for, since the work was the file rather than the window. A phone paging
|
||||
/// back through history pays it per page, and every stream reconnect pays
|
||||
/// it again to discover there is nothing new.
|
||||
/// Both readers above want a *range* of the file, and both used to reach it by
|
||||
/// parsing every line and discarding the ones outside it -- the cost that grows
|
||||
/// with the conversation rather than with the answer. Measured on a 21 MB,
|
||||
/// 24,000-event transcript, one page took **500 ms of server time to return
|
||||
/// 600 KB**, and the same 500 ms whichever page was asked for. A phone paging
|
||||
/// back pays it per page, and every stream reconnect pays it again to discover
|
||||
/// there is nothing new.
|
||||
///
|
||||
/// Sequence numbers only ever increase -- the writer assigns them, one per
|
||||
/// appended line, continuing from the last on reopen -- so the boundary of
|
||||
/// a range is a bisection. This parses one line per halving, and the caller
|
||||
/// parses only what it is going to return. The file is still read whole,
|
||||
/// which is a deliberate stop: finding the tail without reading forwards
|
||||
/// means a chunked backwards reader, and locating a line is not what the
|
||||
/// half-second was going to.
|
||||
/// Sequence numbers only ever increase, so the boundary of a range is a
|
||||
/// bisection: this parses one line per halving, and the caller parses only what
|
||||
/// it returns. The file is still read whole, which is a deliberate stop --
|
||||
/// going further means a chunked backwards reader, and locating a line is not
|
||||
/// what the half-second was going to.
|
||||
struct Indexed<'a> {
|
||||
path: &'a Path,
|
||||
text: String,
|
||||
@@ -302,14 +276,13 @@ impl<'a> Indexed<'a> {
|
||||
Ok(Some(Self { path, text, lines }))
|
||||
}
|
||||
|
||||
/// The index of the first line numbered `seq` or higher, or the end
|
||||
/// when every line is older than that.
|
||||
/// The index of the first line numbered `seq` or higher, or the end when
|
||||
/// every line is older than that.
|
||||
///
|
||||
/// A bisection, which is only correct because the file is in sequence
|
||||
/// order; it is append-only and nothing else writes it. A line that
|
||||
/// cannot be read is reported here rather than silently treated as
|
||||
/// out of range, because the answer would be a window off by however
|
||||
/// much of the file the bad line hid.
|
||||
/// A bisection, which is only correct because the file is in sequence order.
|
||||
/// A line that cannot be read is reported here rather than silently treated
|
||||
/// as out of range, because the answer would be a window off by however much
|
||||
/// of the file the bad line hid.
|
||||
fn first_at_or_after(&self, seq: u64) -> Result<usize> {
|
||||
let (mut low, mut high) = (0, self.lines.len());
|
||||
while low < high {
|
||||
@@ -350,23 +323,20 @@ impl<'a> Indexed<'a> {
|
||||
.with_context(|| format!("bad transcript line in {}", self.path.display()))
|
||||
}
|
||||
|
||||
/// The newest `limit` *rows* ending at line `end`, with each run of consecutive streamed
|
||||
/// [`Event::AssistantText`] deltas concatenated into one.
|
||||
/// The newest `limit` *rows* ending at line `end`, with each run of
|
||||
/// consecutive [`Event::AssistantText`] deltas concatenated into one.
|
||||
///
|
||||
/// A reply is stored a token at a time -- hundreds of `AssistantText` events for one message --
|
||||
/// so a window counted in events is a fraction of a row for a reply and a whole row for a tool
|
||||
/// call, and the phone can neither predict how much a page will show nor fill a screen without
|
||||
/// folding a page's worth of near-duplicate events. Counted in rows, a page is a page: this
|
||||
/// walks back from `end`, joining each delta run into the single event the phone would fold it
|
||||
/// into anyway, and stops once `limit` of them are gathered.
|
||||
/// A reply is stored a token at a time, so a window counted in events is a
|
||||
/// fraction of a row for a reply and a whole row for a tool call, and the
|
||||
/// phone can neither predict how much a page will show nor fill a screen
|
||||
/// without folding a page of near-duplicate events. Counted in rows, a page
|
||||
/// is a page.
|
||||
///
|
||||
/// A run takes the seq and time of its *oldest* delta, matching the phone's own rule that a
|
||||
/// streamed message keeps the seq of its first delta -- so anchors, and the `before` cursor the
|
||||
/// 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
|
||||
/// the phone's `healSplitMessage` welds the two, exactly as it does for a run cut by any page
|
||||
/// boundary. `start` is the same kind of cut from the other end -- the floor `read_window`'s
|
||||
/// `after` computes -- and a run reaching it is partial in the same way.
|
||||
/// A run takes the seq and time of its *oldest* delta, matching the phone's
|
||||
/// own rule -- so anchors and the `before` cursor land where they always
|
||||
/// did. A run cut by the `limit` is emitted as the partial it is, and the
|
||||
/// phone's `healSplitMessage` welds it to the next page. `start` is the same
|
||||
/// kind of cut from the other end.
|
||||
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.
|
||||
let mut out: Vec<SeqEvent> = Vec::new();
|
||||
@@ -386,9 +356,9 @@ impl<'a> Indexed<'a> {
|
||||
};
|
||||
let mut index = end;
|
||||
while index > start {
|
||||
// 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
|
||||
// rows, and flush the last run after the loop.
|
||||
// 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 rows.
|
||||
if out.len() >= limit && run.is_none() {
|
||||
break;
|
||||
}
|
||||
@@ -487,9 +457,9 @@ mod tests {
|
||||
assert_eq!(events[0].seq, 6);
|
||||
assert_eq!(events[4].seq, 10);
|
||||
|
||||
// Exactly at the limit is still a continuation: the boundary
|
||||
// belongs to the cheaper answer, so a client is not reset for
|
||||
// being one event behind the threshold.
|
||||
// Exactly at the limit is still a continuation: the boundary belongs to
|
||||
// the cheaper answer, so a client is not reset for being one event
|
||||
// behind the threshold.
|
||||
assert!(matches!(
|
||||
catch_up(&path, 5, 5).expect("catch up"),
|
||||
CatchUp::Continue(_)
|
||||
@@ -501,8 +471,8 @@ mod tests {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("transcript.jsonl");
|
||||
|
||||
// Nothing recorded yet: no prior state to report, which is not the
|
||||
// same as reporting idle.
|
||||
// Nothing recorded yet: no prior state to report, which is not the same
|
||||
// as reporting idle.
|
||||
assert_eq!(Transcript::open(&path).expect("open").last_status(), None);
|
||||
|
||||
let mut transcript = Transcript::open(&path).expect("open");
|
||||
|
||||
Reference in new issue
Block a user