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
+4571
-6820
No files matched your search
+225
-378
@@ -1,256 +1,212 @@
|
|||||||
# The file explorer
|
# The file explorer
|
||||||
|
|
||||||
Asked for by Bryan on 2026-09-03: replace the session screen's debug
|
Asked for by Bryan on 2026-09-03 and built the same day: browse a machine's
|
||||||
button with a folder icon that opens a file and directory viewer for the
|
directories, open files with the existing syntax highlighting and line
|
||||||
machine the session runs on. Browse directories, open files with the
|
numbers, edit behind a pencil, create through a modal, work over ssh, and
|
||||||
existing syntax highlighting, line numbers, no wrapping; edit a file behind
|
open at the session's working directory.
|
||||||
a pencil icon; create files through a modal like the ones the app already
|
|
||||||
has; work over ssh; open at the session's working directory.
|
|
||||||
|
|
||||||
Built on 2026-09-03. This is the design, decision by decision with the
|
This is the design, decision by decision with the reason and what was
|
||||||
reason and what was rejected, so that when one changes it is changed here
|
rejected, so that when one changes it is changed here rather than re-argued.
|
||||||
rather than re-argued. The operational half -- how to run it, what to press,
|
The operational half — how to run it and what to produce on purpose — is in
|
||||||
what to produce on purpose -- is in AGENTS.md, where the rest of this
|
AGENTS.md. `server/src/files.rs` is the backend and `FilesScreen.kt` /
|
||||||
project's working notes are.
|
`FileViewer.kt` / `FileEditor.kt` / `FileLines.kt` are the app.
|
||||||
|
|
||||||
## What it is, in one paragraph
|
## What it is, in one paragraph
|
||||||
|
|
||||||
A machine's filesystem, seen from the phone through the backend. The
|
A machine's filesystem, seen from the phone through the backend. The explorer
|
||||||
explorer belongs to a **setup** (a machine), not to a session: a session
|
belongs to a **setup** (a machine), not to a session: a session only says
|
||||||
only says where to start. Every operation -- list, read, write, create --
|
where to start. Every operation — list, read, write, create — is one shell
|
||||||
is one shell script run through `Transport`, exactly the way the import
|
script run through `Transport`, exactly the way the import listing and the
|
||||||
listing and the usage fetch already work, so the local and the ssh case
|
usage fetch already work, so the local and the ssh case are one
|
||||||
are one implementation and a machine the backend cannot reach fails with
|
implementation and a machine the backend cannot reach fails with ssh's own
|
||||||
ssh's own message. The phone draws what came back: a listing, a file with
|
message. The phone draws what came back.
|
||||||
its lines coloured by the scanner in `Highlighter.kt`, or an editor over
|
|
||||||
the same text.
|
|
||||||
|
|
||||||
## Decisions
|
## Decisions
|
||||||
|
|
||||||
### 1. Keyed on the machine, opened from the session
|
### 1. Keyed on the machine, opened from the session
|
||||||
|
|
||||||
Routes live under `/setups/{id}/…`, beside `importable`, because a
|
Routes live under `/setups/{id}/…`, beside `importable`, because a filesystem
|
||||||
filesystem is a property of a machine. The session screen's folder button
|
is a property of a machine. The session screen's folder button opens the
|
||||||
opens the explorer with the session's setup and its `cwd` as the starting
|
explorer with the session's setup and its `cwd`; a session with no `cwd`
|
||||||
directory; a session with no `cwd` opens at the machine's home, which the
|
opens at the machine's home, which the **machine** resolves (`cd` with no
|
||||||
machine resolves (`cd` with no argument and `pwd -P`), never a path the
|
argument and `pwd -P`), never a path the phone guessed. Nothing in the
|
||||||
phone guessed. Nothing in the explorer knows what a session is, so a later
|
explorer knows what a session is, so a later entry point from the setups tab
|
||||||
entry point from the setups tab is one more caller and no new code.
|
is one more caller and no new code.
|
||||||
|
|
||||||
Rejected: routes under `/sessions/{id}/`. The session would be a detour to
|
Rejected: routes under `/sessions/{id}/`. The session would be a detour to
|
||||||
find the setup, and "browse this machine" from anywhere but a session would
|
find the setup, and "browse this machine" from anywhere else would need a
|
||||||
need a session to exist first.
|
session to exist first.
|
||||||
|
|
||||||
### 2. One shell script per operation, over `Transport`, on both transports
|
### 2. One shell script per operation, over `Transport`, on both transports
|
||||||
|
|
||||||
Each operation is a small POSIX shell script handed to `sh -c script sh
|
Each operation is a small POSIX script handed to `sh -c script sh "$path" …`
|
||||||
"$path" …` through `Transport::capture` (or the stdin-carrying variant
|
through `Transport::capture` (or `capture_with_input`). The path and every
|
||||||
below). The path and every other value cross as **positional arguments**,
|
other value cross as **positional arguments**, never interpolated into the
|
||||||
never interpolated into the script -- the same rule `import::find` follows
|
script — the same rule `import::find` follows and the same reason
|
||||||
with `"$1"`, and the same reason `ssh::quote` exists: a path is
|
`ssh::quote` exists: a path is attacker-adjacent input in a server whose job
|
||||||
attacker-adjacent input in a server whose job is running commands. A `~`
|
is running commands. `PATH_PRELUDE` is the one line that gives a leading `~`
|
||||||
prefix is handled by the same `quote_path`/`expand_home` pair every other
|
its meaning, since a shell expands a tilde in text and not in an argument.
|
||||||
path goes through; nothing new is invented for it.
|
|
||||||
|
|
||||||
The scripts assume GNU coreutils and findutils (`find -printf`, `stat -c`,
|
The scripts assume GNU coreutils and findutils (`find -printf`, `stat -c`,
|
||||||
`sha256sum`, `chmod --reference`). That is already what `import.rs`
|
`sha256sum`, `chmod --reference`) — already what `import.rs` assumes, and
|
||||||
assumes (`stat -c`, `/proc`), and both machines that exist are Linux. A
|
both machines that exist are Linux. A machine without them fails with that
|
||||||
machine without them fails with that tool's own message, which names what
|
tool's own message, which names what is missing.
|
||||||
is missing.
|
|
||||||
|
|
||||||
Rejected: `std::fs` for the local transport and scripts for ssh. Two
|
Rejected: `std::fs` for the local transport and scripts for ssh. Two
|
||||||
implementations of "list a directory" drift -- the ordering of entries,
|
implementations of "list a directory" drift — the ordering of entries, what a
|
||||||
what a symlink reports, how a permission error reads -- and the local one
|
symlink reports, how a permission error reads — and the local one is the one
|
||||||
is the one that gets tested, so the remote one ships broken. The transport
|
that gets tested, so the remote one ships broken. The cost is an `sh` process
|
||||||
design exists so that a driver never learns which machine it got; the
|
per operation locally, which is under a millisecond.
|
||||||
explorer is held to the same rule. The cost is a `sh` process per
|
|
||||||
operation locally, which is under a millisecond.
|
|
||||||
|
|
||||||
Rejected: a Rust SSH or SFTP library. PLAN.md rule 23 -- the system `ssh`
|
Rejected: a Rust SSH or SFTP library. The system `ssh` inherits
|
||||||
inherits `~/.ssh/config`, agents and jump hosts, and there is one place to
|
`~/.ssh/config`, agents and jump hosts, and there is one place to configure a
|
||||||
configure a connection. SFTP would need a second one.
|
connection; SFTP would need a second.
|
||||||
|
|
||||||
### 3. The token can now name a path, and that is written down
|
### 3. The token can now name a path, and that is written down
|
||||||
|
|
||||||
AGENTS.md says of the import route: "the phone picks an **id**, never a
|
Elsewhere the phone picks an **id** and the server resolves which file it
|
||||||
path: the server resolves which file that is, so an enrolled token cannot
|
names, so an enrolled token cannot become "read me an arbitrary file". The
|
||||||
become 'read me an arbitrary file'." The explorer's whole purpose is the
|
explorer's whole purpose is the path, so it takes one. Recorded in PLAN.md's
|
||||||
path, so it takes one. This is recorded in PLAN.md's Security section as a
|
Security section in these terms: the token already gates spawning a
|
||||||
change to the threat model paragraph, in these terms: the token already
|
bypass-permissions agent in any directory on any machine a setup names, and
|
||||||
gates spawning a bypass-permissions agent in any directory on any machine
|
that agent can already read and write every file its user can. The explorer
|
||||||
a setup names, and that agent can already read and write every file its
|
is a shorter path to authority the token already holds, not new authority.
|
||||||
user can. The explorer is a shorter path to authority the token already
|
The import rule stands where it is, because there a path was unnecessary and
|
||||||
holds, not new authority. The import route's rule stands where it is,
|
refusing it cost nothing.
|
||||||
because there a path was unnecessary and refusing it cost nothing.
|
|
||||||
|
|
||||||
What is *not* changed: no route accepts a command. Listing, reading and
|
What is *not* changed: **no route accepts a command.** Listing, reading and
|
||||||
writing are fixed scripts; the phone chooses only the path and the bytes.
|
writing are fixed scripts; the phone chooses only the path and the bytes.
|
||||||
|
|
||||||
### 4. Paths are absolute or `~`-prefixed, and the machine answers with the real one
|
### 4. Paths are absolute or `~`-prefixed, and the machine answers with the real one
|
||||||
|
|
||||||
Same rule as `POST /sessions/{id}/cwd`: a relative path is refused with
|
Same rule as `POST /sessions/{id}/cwd`, with the same wording, because where
|
||||||
the same wording, because where it would be depends on where nothing the
|
a relative path would be depends on something the reader cannot see. Every
|
||||||
reader can see. Every listing answers with `pwd -P` of the directory it
|
listing answers with `pwd -P` of the directory it listed, so the phone
|
||||||
listed, so the phone navigates on a resolved absolute path -- the parent
|
navigates on a resolved absolute path — the parent is a string operation on
|
||||||
of `/home/bob/repos/ai-app` is a string operation on that, and a `~` the
|
that, and a `~` the session was spawned with is shown as what it turned out
|
||||||
session was spawned with is shown as what it turned out to be. The phone
|
to be. The phone never resolves `..` itself.
|
||||||
never resolves `..` itself.
|
|
||||||
|
|
||||||
### 5. A read is capped and typed, and every state it can be in has a word
|
### 5. A read is capped and typed, and every state it can be in has a word
|
||||||
|
|
||||||
`GET /setups/{id}/file` answers with one of:
|
`GET /setups/{id}/file` answers with one of `text` (content, size, mtime,
|
||||||
|
sha256), `binary` (not UTF-8; size reported, nothing shown), `tooBig` (over
|
||||||
- `text` -- the content, with its size, mtime and sha256.
|
`FILE_LIMIT`, 1 MiB; size reported so the reader knows what they are looking
|
||||||
- `binary` -- the content is not UTF-8. Size reported, nothing shown.
|
at), or the machine's own error.
|
||||||
- `tooBig` -- over `FILE_LIMIT` (1 MiB to start; see "Numbers to
|
|
||||||
measure"). Size reported so the reader knows what they are looking at.
|
|
||||||
- an error -- no such file, permission denied, machine unreachable --
|
|
||||||
carrying the machine's message.
|
|
||||||
|
|
||||||
Four outcomes rather than content-or-error, because a binary file drawn as
|
Four outcomes rather than content-or-error, because a binary file drawn as
|
||||||
text and a big file cut off silently are both wrong in ways the reader
|
text and a big file cut off silently are both wrong in ways the reader cannot
|
||||||
cannot see, and "couldn't read it" must not look like "it is empty". An
|
see, and "couldn't read it" must not look like "it is empty". An empty file
|
||||||
empty file is `text` with empty content and is drawn as one empty line
|
is `text` with empty content, drawn as one empty line numbered 1, which is
|
||||||
numbered 1, which is what it is.
|
what it is.
|
||||||
|
|
||||||
Not in the first cut: showing images (the phone has `isImageRef` and a
|
|
||||||
viewer already; the route would serve bytes). Listed under "later".
|
|
||||||
|
|
||||||
### 6. A write is conditional on what the reader saw
|
### 6. A write is conditional on what the reader saw
|
||||||
|
|
||||||
`PUT /setups/{id}/file` carries the sha256 the read reported. The script
|
`PUT /setups/{id}/file` carries the sha256 the read reported. The script
|
||||||
compares it against the file as it is now and refuses with a distinct exit
|
compares it against the file as it is now and exits distinctly if it differs;
|
||||||
code if it differs; the server answers **409** with "changed on the machine
|
the server answers **409**. Agents edit files while people read them; this is
|
||||||
since you opened it". Agents edit files while people read them; this is
|
the common case, not the exotic one, and silently overwriting an agent's edit
|
||||||
the common case, not the exotic one, and silently overwriting an agent's
|
with a stale copy is the worst available outcome. The phone offers three ways
|
||||||
edit with a stale copy is the worst available outcome. The phone offers
|
out and says what each costs: **Overwrite** (theirs is lost), **Reload**
|
||||||
three ways out and says what each costs: **Overwrite** (theirs is lost),
|
(yours is lost), **Cancel** (keep editing).
|
||||||
**Reload** (yours is lost), **Cancel** (keep editing, decide later).
|
|
||||||
|
|
||||||
The write is `cat > "$1.ai-app-tmp" && chmod --reference="$1"
|
The write is `cat > "$1.ai-app-tmp" && chmod --reference="$1" … && mv -f`,
|
||||||
"$1.ai-app-tmp" && mv -f -- "$1.ai-app-tmp" "$1"`, with the bytes on
|
with the bytes on stdin: a temp file and a rename, so a connection dropped
|
||||||
stdin. A temp file and a rename, so a connection dropped mid-write leaves
|
mid-write leaves the old file whole rather than truncated, and
|
||||||
the old file whole rather than a truncated one; `chmod --reference` keeps
|
`chmod --reference` keeps the mode a fresh file would lose (an executable
|
||||||
the mode, which a fresh file would otherwise lose (an executable script
|
script would stop being one). What this trades away is the inode, so a hard
|
||||||
would stop being one). What this trades away: the inode changes, so a hard
|
link elsewhere stops being the same file — accepted; editors do the same. The
|
||||||
link elsewhere stops being the same file. Accepted; editors do the same.
|
check-then-write is not atomic against a writer landing between the two, a
|
||||||
The check-then-write is not atomic against a writer landing between the
|
window of microseconds on the same machine; accepted, and noted at the
|
||||||
two -- a window of microseconds on the same machine -- and that is accepted
|
script. The response carries the new size, mtime and sha256, so the editor's
|
||||||
too, and noted at the script.
|
|
||||||
|
|
||||||
The response carries the new size, mtime and sha256, so the editor's
|
|
||||||
precondition is fresh without a second read.
|
precondition is fresh without a second read.
|
||||||
|
|
||||||
### 7. Create refuses to overwrite
|
### 7. Create refuses to overwrite
|
||||||
|
|
||||||
`POST /setups/{id}/file {path}` runs under `set -C` (noclobber) and
|
`POST /setups/{id}/file` runs under `set -C` (noclobber) and `: > "$1"`, so a
|
||||||
`: > "$1"`, so a name that exists fails with the shell's own message rather
|
name that exists fails with the shell's own message rather than truncating
|
||||||
than truncating somebody's file. `POST /setups/{id}/dir {path}` is `mkdir
|
somebody's file; `POST /setups/{id}/dir` is `mkdir --` with the same
|
||||||
--` with the same property. The modal names one thing in the current
|
property. The modal names one thing in the current directory and has a switch
|
||||||
directory and has a switch for "directory"; a created file opens straight
|
for "directory"; a created file opens straight into edit mode, because an
|
||||||
into edit mode, because an empty file is not something to look at.
|
empty file is not something to look at.
|
||||||
|
|
||||||
Rejected: create-with-content in one request. The editor is the place
|
Rejected: create-with-content in one request. The editor is where content is
|
||||||
content is typed, and a modal with a text area is a second editor.
|
typed, and a modal with a text area is a second editor.
|
||||||
|
|
||||||
### 8. The viewer is a list of lines, coloured once
|
### 8. The viewer is a list of lines, coloured once
|
||||||
|
|
||||||
The file is scanned once, off the main thread, by `scan` in
|
The file is scanned once, **off the main thread**, by `scan` in
|
||||||
`Highlighter.kt` with `rulesOf(language)`; the spans are bucketed per line
|
`Highlighter.kt`; the spans are bucketed per line in one pass and each line's
|
||||||
in one pass, and each line's `AnnotatedString` is built when that line is
|
`AnnotatedString` is built when that line is composed. A `LazyColumn` of
|
||||||
composed. A `LazyColumn` of lines, not one `Text`: text layout is linear
|
lines, not one `Text`: text layout is linear in the text, so a 20,000-line
|
||||||
in the text, and a 20,000-line file in one `Text` measures all of it to
|
file in one `Text` measures all of it to draw a screenful.
|
||||||
draw a screenful. Lines are drawn with `softWrap = false` inside one
|
|
||||||
shared `horizontalScroll` state, so the whole file scrolls sideways as a
|
|
||||||
block and a line never wraps.
|
|
||||||
|
|
||||||
**Sharing that state is not enough on its own, and this is where it was
|
**Every row is given the same width**, and that is what makes the shared
|
||||||
wrong.** `horizontalScroll` is a node per row, and each one coerces the
|
horizontal scroll work. `horizontalScroll` is a node per row, and each one
|
||||||
shared offset into *its own* range -- content width less viewport -- so
|
coerces the shared offset into *its own* range — content width less viewport
|
||||||
with rows at their natural widths a short line's range is zero and it does
|
— so with rows at their natural widths a short line's range is zero and it
|
||||||
not move at all while the long line beside it does. Each row also writes
|
does not move at all while the long line beside it does. Each row also writes
|
||||||
`maxValue` as it measures, so how far the file could be dragged was decided
|
`maxValue` as it measures, so how far the file could be dragged was decided
|
||||||
by whichever row measured last, and changed as the list scrolled. Both go
|
by whichever row measured last and changed as the list scrolled. The width is
|
||||||
away once **every row is given the same width**: the longest line in
|
the longest line in columns times one character's advance, which is
|
||||||
columns times one character's advance, which is arithmetic rather than
|
arithmetic rather than twenty thousand measurements because the face is
|
||||||
twenty thousand measurements because the face is monospace. A tab counts as
|
monospace. A tab counts as eight columns and deliberately upwards —
|
||||||
eight columns and deliberately upwards -- over-estimating leaves a little
|
over-estimating leaves a little empty space past the longest line,
|
||||||
empty space past the longest line, under-estimating puts the end of that
|
under-estimating puts the end of that line out of reach — and the width is
|
||||||
line out of reach -- and the width is capped well under what `Constraints`
|
capped well under what `Constraints` can carry, so a minified file is a
|
||||||
can carry, so a minified file is a scroll that stops early rather than a
|
scroll that stops early rather than a crash. Reported by Iris on 2026-09-04
|
||||||
crash. Reported by Iris on 2026-09-04 as "it seems to affect different rows
|
as "it seems to affect different rows differently", which is precisely what a
|
||||||
differently", which is precisely what a per-row range looks like.
|
per-row range looks like.
|
||||||
|
|
||||||
**The stretch at the ends is one effect too**, shared by every row and
|
**The stretch at the ends is one effect too**, shared by every row and
|
||||||
rendered once on the box around the list -- `horizontalScroll` makes its
|
rendered once on the box around the list — `horizontalScroll` makes its own
|
||||||
own per node otherwise, so only the line under the finger bent and the
|
per node otherwise, so only the line under the finger bent while the rest of
|
||||||
rest of the file sat still beside it. That is the same complaint one layer
|
the file sat still. It cannot be seen from this VM: the emulator's
|
||||||
further out, and it is only fixable now that every row agrees where the
|
screenshots come back with no stretch in them at all, for any scrollable, so
|
||||||
end is. It cannot be seen from this VM: the emulator's screenshots come
|
that one is checked on the phone.
|
||||||
back with no stretch in them at all, for any scrollable, so this one is
|
|
||||||
checked on the phone.
|
|
||||||
|
|
||||||
**The numbers sit outside that box**, so they neither travel with the text
|
**The numbers sit outside that box**, so they neither travel with the text
|
||||||
nor bend with it. The rows leave a spacer where the numbers go and a
|
nor bend with it. The rows leave a spacer where the numbers go and a
|
||||||
`SubcomposeLayout` beside the list draws them. That is the one arrangement
|
`SubcomposeLayout` beside the list draws them. That is the one arrangement
|
||||||
that keeps them level: which numbers exist *and* where each goes both come
|
that keeps them level: which numbers exist *and* where each goes both come
|
||||||
from the list's own `layoutInfo`, read in the measure block, and
|
from the list's own `layoutInfo`, read in the measure block, and
|
||||||
subcomposition happens during measurement -- so it composes from the answer
|
subcomposition happens during measurement — so it composes from the answer
|
||||||
the list has just produced rather than from one it read a frame ago. A
|
the list has just produced rather than one it read a frame ago. A column
|
||||||
column translated by the scroll position could not, since the translation
|
translated by the scroll position could not, since the translation would be
|
||||||
would be current while the set of numbers was a composition behind, and
|
current while the set of numbers was a composition behind, and during a fling
|
||||||
during a fling the numbers would slide against their lines. Checked at
|
the numbers would slide against their lines. Checked at about 1kHz through a
|
||||||
about 1kHz through a fling: 23,520 row observations over 552 frames, every
|
fling: 23,520 row observations over 552 frames, every one with its number at
|
||||||
one of them with its number at exactly its own top.
|
exactly its own top. A consequence worth having: the numbers are outside the
|
||||||
|
`SelectionContainer`, so copying part of a file gives the code rather than
|
||||||
|
the code with a number in front of every line.
|
||||||
|
|
||||||
A consequence worth having: the numbers are no longer inside the
|
The gutter is right-aligned, its width taken from the digit count of the line
|
||||||
`SelectionContainer`, so selecting part of a file and copying it gives the
|
count in the same monospace style, so a 9-line file and a 12,000-line file
|
||||||
code rather than the code with a number in front of every line.
|
each get exactly the width they need and nothing is measured by hand. Because
|
||||||
|
nothing wraps, a logical line is one visual line and the gutter cannot drift
|
||||||
Line numbers are a gutter in each row, right-aligned, with the gutter
|
from the text it numbers. Numbers take `onSurfaceVariant`; the text takes the
|
||||||
width taken from the digit count of the line count in the same monospace
|
|
||||||
style -- so a 9-line file and a 12,000-line file each get exactly the
|
|
||||||
width they need and nothing is measured by hand. Because nothing wraps, a
|
|
||||||
logical line is one visual line, and the gutter cannot drift from the text
|
|
||||||
it numbers. Gutter numbers take `onSurfaceVariant`; the text takes the
|
|
||||||
scanner's palette on `rawSurface`, the surface every verbatim thing in the
|
scanner's palette on `rawSurface`, the surface every verbatim thing in the
|
||||||
app already sits on.
|
app already sits on.
|
||||||
|
|
||||||
The language comes from the file's extension through the same table
|
The language comes from the file's extension through the same table
|
||||||
`fenceLanguage` reads (`FENCE_LANGUAGES` already keys on `kt`, `rs`,
|
`fenceLanguage` reads — one table, not two, so a language added for fences is
|
||||||
`py`, …). One function, `fileLanguage(name)`, takes the part after the
|
added for files. A file with no entry is drawn plain.
|
||||||
last dot and asks that table; it is one table, not two, so a language
|
|
||||||
added for fences is added for files. A file with no entry is drawn plain,
|
|
||||||
for the reason the table's comment gives.
|
|
||||||
|
|
||||||
Selection: the lines sit inside one `SelectionContainer`, as the
|
|
||||||
transcript does, so a selection can run across lines.
|
|
||||||
|
|
||||||
### 9. The editor is the legacy text field with a highlighting transformation
|
### 9. The editor is the legacy text field with a highlighting transformation
|
||||||
|
|
||||||
Edit mode swaps the viewer for a `BasicTextField(TextFieldValue)` in the
|
Edit mode swaps the viewer for a `BasicTextField(TextFieldValue)` in the same
|
||||||
same monospace style, inside the same horizontal scroll so it does not
|
monospace style, inside the same horizontal scroll so it does not wrap, with
|
||||||
wrap, with a `VisualTransformation` that returns the text unchanged and
|
a `VisualTransformation` that returns the text unchanged and the scanner's
|
||||||
the scanner's spans as styles (`OffsetMapping.Identity`, since no
|
spans as styles (`OffsetMapping.Identity`, since no character moves). This is
|
||||||
character moves). This is the one Compose API that colours a field's text
|
the one Compose API that colours a field's text without replacing the field;
|
||||||
without replacing the field; the newer `TextFieldState` API has no hook
|
the newer `TextFieldState` API has no hook for styles. The gutter is one
|
||||||
for styles. The gutter is one `Text` of `1\n2\n…` in the same style beside
|
`Text` of `1\n2\n…` beside the field, aligned for the same reason as the
|
||||||
the field, aligned for the same reason as the viewer: no wrap, one line
|
viewer.
|
||||||
each.
|
|
||||||
|
|
||||||
Save is a glyph in the header, **disabled** until the text differs from
|
Save is a glyph in the header, **disabled** until the text differs from what
|
||||||
what was loaded (never hidden -- a control that comes and goes makes its
|
was loaded — never hidden, since a control that comes and goes makes its own
|
||||||
own absence the signal), and a `GlyphSpinner` while the write is out.
|
absence the signal. Back with unsaved changes asks, and says the edits will
|
||||||
Back with unsaved changes asks; the question says the edits will be lost.
|
be lost. The explorer draws over the session, which deliberately has no
|
||||||
The keyboard: the explorer draws over the session, which deliberately has
|
`imePadding`, so the explorer's own box adds it.
|
||||||
no `imePadding` (see `SessionScreen`'s layout note), so the explorer's own
|
|
||||||
box adds it.
|
|
||||||
|
|
||||||
Re-scanning on every keystroke is the cost to watch. For a file under
|
|
||||||
`FILE_LIMIT` it is expected to be a few milliseconds (the scanner replaced
|
|
||||||
a library that took 174ms on 200 lines; ours has not been measured on a
|
|
||||||
1 MiB file). Measure before deciding whether edit mode needs a size below
|
|
||||||
which highlighting is on -- see "Numbers to measure".
|
|
||||||
|
|
||||||
### 10. The explorer draws over the session, and back closes it first
|
### 10. The explorer draws over the session, and back closes it first
|
||||||
|
|
||||||
@@ -258,190 +214,85 @@ which highlighting is on -- see "Numbers to measure".
|
|||||||
`FilesScreen` is composed **on top of** the session in the same `Box`, and
|
`FilesScreen` is composed **on top of** the session in the same `Box`, and
|
||||||
the session stays composed under it: its event stream keeps flowing, its
|
the session stays composed under it: its event stream keeps flowing, its
|
||||||
scroll position and draft stay where they were, and returning from a file
|
scroll position and draft stay where they were, and returning from a file
|
||||||
costs nothing. Back -- the button and the platform gesture --
|
costs nothing. Back — the button and the platform gesture — clears `files`
|
||||||
clears `files` when it is set and goes to the list otherwise. Inside the
|
when set and goes to the list otherwise. Inside the explorer the same back
|
||||||
explorer the same back steps one level: editor → viewer (with the unsaved
|
steps one level: editor → viewer (with the unsaved question) → listing →
|
||||||
question), viewer → listing, listing → parent directory it came from, and
|
parent directory, and only from the starting directory does it close. "Back
|
||||||
only from the starting directory does it close. "Back returns; it does not
|
returns; it does not exit."
|
||||||
exit."
|
|
||||||
|
|
||||||
Rejected: a `Screen.Files` beside `Screen.Session`. Every route back from
|
Rejected: a `Screen.Files` beside `Screen.Session`. Every route back from a
|
||||||
a leaf screen goes to Main today, and a session disposed and re-created on
|
leaf screen goes to Main today, and a session disposed and re-created on each
|
||||||
each return refetches its transcript over the tunnel -- exactly the flip
|
return refetches its transcript over the tunnel — exactly the flip between
|
||||||
between "what did it change" and "what is it saying" this feature is for.
|
"what did it change" and "what is it saying" this feature is for. The image
|
||||||
The image viewer already made the same choice for the same reason.
|
viewer already made the same choice for the same reason.
|
||||||
|
|
||||||
### 11. The listing is drawn as it came, sorted at display time
|
### 11. The listing is drawn as it came, sorted at display time
|
||||||
|
|
||||||
Entries carry name, kind (`directory`, `file`, `other`), size, mtime, and
|
Entries carry name, kind (`directory`, `file`, `other`), size, mtime, and
|
||||||
whether the entry is a symlink (with the kind being the *target's*, from
|
whether the entry is a symlink — with the kind being the *target's*, from
|
||||||
`find -printf '%Y'`, so a link to a directory navigates). Sorted on the
|
`find -printf '%Y'`, so a link to a directory navigates. Sorted on the phone,
|
||||||
phone, stably: directories first, then case-insensitive name. Dotfiles are
|
stably: directories first, then case-insensitive name. Dotfiles are shown; in
|
||||||
shown -- in a repository they are half of what matters. A row is the
|
a repository they are half of what matters. Each directory's entries are kept
|
||||||
glyph, the name, and the size for a file; tapping a directory descends,
|
for as long as the explorer is open, keyed by path, so returning to one does
|
||||||
tapping a file opens it. Each directory's entries are kept for as long as
|
not refetch it; the header's refresh glyph refetches the current one on
|
||||||
the explorer is open, keyed by path, so returning to one does not refetch
|
purpose, and a create refetches the directory it created into, since that is
|
||||||
it; the header's refresh glyph refetches the current one on purpose, and a
|
what the operation changed.
|
||||||
create refetches the directory it created into, since that is what the
|
|
||||||
operation changed.
|
|
||||||
|
|
||||||
An empty directory says "Nothing here". A listing that failed says why,
|
An empty directory says "Nothing here". A listing that failed says why, in
|
||||||
in the machine's words, where the rows would be -- never an empty list.
|
the machine's words, where the rows would be — never an empty list.
|
||||||
|
|
||||||
Entries are separated by `\0` in the script's output and by `\t` within a
|
Entries are separated by `\0` in the script's output and by `\t` within a
|
||||||
line (`find -printf '%y\t%Y\t%s\t%T@\t%f\0'`), so a filename with a
|
line, so a filename with a newline or a tab in it survives; `parse_entries`
|
||||||
newline or a tab in it survives; `parse_entries` is a unit test with
|
is a unit test with exactly those names in it.
|
||||||
exactly those names in it.
|
|
||||||
|
|
||||||
### 12. Icons
|
### 12. Icons
|
||||||
|
|
||||||
Added to `NerdIcons.kt` **and** `build-icon-font.sh`, then the script
|
Added to `NerdIcons.kt` **and** `build-icon-font.sh`, then the script rerun
|
||||||
rerun and its output committed (it needs network):
|
and its output committed: `md-folder` U+F024B (the header button and
|
||||||
|
directory rows), `md-plus` U+F0415, `md-pencil` U+F03EB,
|
||||||
|
`md-content_save` U+F0193, `md-file_outline` U+F0224. The folder and the plus
|
||||||
|
are the same codepoints dev-updater uses and must not drift from it, as the
|
||||||
|
cog and the refresh arrow already must not. All five were looked up in Nerd
|
||||||
|
Fonts' own `glyphnames.json` rather than copied from memory, which is the
|
||||||
|
check that a codepoint means the glyph its comment names.
|
||||||
|
|
||||||
- `md-folder` U+F024B -- the header button, and directory rows. The same
|
**The folder button sits between the usage chart and the cog**, so the header
|
||||||
codepoint dev-updater uses, and it must not drift from it, as the cog
|
reads widest scope to narrowest and the cog stays at the end where every
|
||||||
and the refresh arrow already must not.
|
other screen keeps it. Asked for in that order by Iris on 2026-09-03.
|
||||||
- `md-plus` U+F0415 -- create. Also dev-updater's.
|
|
||||||
- `md-pencil` U+F03EB -- edit.
|
|
||||||
- `md-content_save` U+F0193 -- save.
|
|
||||||
- `md-file_outline` U+F0224 -- file rows.
|
|
||||||
|
|
||||||
All five were looked up in Nerd Fonts' own `glyphnames.json` rather than
|
### 13. The render report moved, and the benches moved with it
|
||||||
copied from memory, which is the check that a codepoint means the glyph its
|
|
||||||
comment names.
|
|
||||||
|
|
||||||
**Where the folder button sits**: between the usage chart and the cog, so
|
The speedometer went; the report is a "Copy render timings" row in
|
||||||
the header reads widest scope to narrowest and the cog stays at the end
|
`SessionSettingsDialog`, where the session's other about-the-session controls
|
||||||
where every other screen in this app keeps it. Asked for in that order by
|
already are. **Moving it is where the no-coordinate-taps rule got enforced**
|
||||||
Iris on 2026-09-03.
|
(Bryan, 2026-09-03) — see AGENTS.md's "Driving the UI".
|
||||||
|
|
||||||
### 13. The render report moves, and the benches move with it
|
|
||||||
|
|
||||||
The speedometer goes. The report it copies is the standard measurement
|
|
||||||
`transcript-bench.sh` and `stream-bench.sh` read from logcat, so it stays
|
|
||||||
reachable: a "Copy render timings" row in `SessionSettingsDialog`, which
|
|
||||||
is where the session's other about-the-session controls already are.
|
|
||||||
|
|
||||||
**No script that drives the UI taps by coordinate, and moving this
|
|
||||||
button is where that rule gets enforced** (Bryan, 2026-09-03). Both bench
|
|
||||||
scripts press the button today as `ui-trace record --do 'tap 723 205'`, a
|
|
||||||
position measured once by hand. Anything that moves the header -- this
|
|
||||||
change, a font size, a density, another emulator -- makes that tap land on
|
|
||||||
whatever now sits there, and the script then reports a number that was
|
|
||||||
never measured, which reads exactly like a result. A control is found by
|
|
||||||
the name it already carries for assistive technology (`GlyphButton`'s
|
|
||||||
`label`, a row's text) and pressed at the bounds the screen reports at
|
|
||||||
that moment.
|
|
||||||
|
|
||||||
That belongs in the tool, not in each script: `ui-trace` in
|
|
||||||
`~/repos/emulator-tools` gains a tap-by-label action (`tap 'Session
|
|
||||||
settings'`, resolving the element's box from the same uiautomator tree
|
|
||||||
`elements` already reads, at the moment of the gesture), and both benches
|
|
||||||
move onto it in the same commit as the button -- cog, then "Copy render
|
|
||||||
timings" -- so the measurement is never unavailable and never wrong
|
|
||||||
quietly. `grep -n "tap [0-9]" app/*.sh` is the check that no coordinate
|
|
||||||
tap is left, and it goes in the emulator-tools README beside the action.
|
|
||||||
Once the action exists, this rule applies to every script that presses
|
|
||||||
something on an Android screen, not only these two.
|
|
||||||
|
|
||||||
## HTTP surface
|
## HTTP surface
|
||||||
|
|
||||||
Added to the table in `routes.rs`'s module doc:
|
In `routes.rs`'s module doc with the rest. Bodies use `deny_unknown_fields`
|
||||||
|
like every other body here; paths in the query string are URL-encoded by
|
||||||
```text
|
`Api.kt`'s existing helper.
|
||||||
GET /setups/{id}/dir?path=P entries of directory P, and P resolved
|
|
||||||
GET /setups/{id}/file?path=P content of file P, or why not
|
|
||||||
PUT /setups/{id}/file {path, content, ifSha256} -> new size/mtime/sha256
|
|
||||||
(409 when the file no longer matches ifSha256)
|
|
||||||
POST /setups/{id}/file {path} create empty; refused if it exists
|
|
||||||
POST /setups/{id}/dir {path} create; refused if it exists
|
|
||||||
```
|
|
||||||
|
|
||||||
Bodies use `deny_unknown_fields` like every other body here. Paths in the
|
|
||||||
query string are URL-encoded by `Api.kt`'s existing helper.
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
GET dir -> {"path":"/home/bob/repos/ai-app",
|
GET dir -> {"path":"/home/bob/repos/ai-app",
|
||||||
"entries":[{"name":"app","kind":"directory","size":4096,"modified":1756900000,"link":false},
|
"entries":[{"name":"app","kind":"directory","size":4096,"modified":1756900000,"link":false}]}
|
||||||
{"name":"README.md","kind":"file","size":1234,"modified":1756900000,"link":false}]}
|
|
||||||
GET file -> {"path":"/…/x.rs","kind":"text","size":1234,"modified":…,"sha256":"…","content":"…"}
|
GET file -> {"path":"/…/x.rs","kind":"text","size":1234,"modified":…,"sha256":"…","content":"…"}
|
||||||
| {"path":"/…/a.png","kind":"binary","size":45678,"modified":…}
|
| {"path":"/…/a.png","kind":"binary","size":45678,"modified":…}
|
||||||
| {"path":"/…/big.log","kind":"tooBig","size":12345678,"modified":…}
|
| {"path":"/…/big.log","kind":"tooBig","size":12345678,"modified":…}
|
||||||
PUT file -> {"size":1240,"modified":…,"sha256":"…"}
|
PUT file -> {"size":1240,"modified":…,"sha256":"…"}
|
||||||
```
|
```
|
||||||
|
|
||||||
Errors: `BadRequest` with the machine's message for a path that is not
|
Errors: `BadRequest` with the machine's message for a path that is not there,
|
||||||
there, not allowed or not absolute; the existing 409 variant for the
|
not allowed or not absolute; 409 for the precondition; `Internal` only for
|
||||||
precondition; `Internal` only for the server's own faults. The message is
|
the server's own faults. The message is what the phone shows, in place, so it
|
||||||
what the phone shows, in place, so it is written to be read there.
|
is written to be read there.
|
||||||
|
|
||||||
## Server work (`server/src/files.rs`)
|
|
||||||
|
|
||||||
One module, with the same shape as `setups.rs`: the scripts as constants,
|
|
||||||
one `pub async fn` per operation taking `&Transport`, and the parsing as
|
|
||||||
pure functions with tests.
|
|
||||||
|
|
||||||
1. `Transport::capture_with_input(launch, stdin)` -- `capture` with bytes
|
|
||||||
on stdin. `ship_attachment` in `routes.rs` builds this by hand today
|
|
||||||
(an `ssh::command`, a `File` on stdin, `output().await`); it moves onto
|
|
||||||
the new helper in the same change, so there is one description of
|
|
||||||
"run this there with this on stdin" rather than two.
|
|
||||||
2. `list(transport, path) -> Listing`: `cd -- "$1" && pwd -P && find .
|
|
||||||
-mindepth 1 -maxdepth 1 -printf '%y\t%Y\t%s\t%T@\t%f\0'`. First line is
|
|
||||||
the resolved path; the rest is entries. `parse_entries` tested with
|
|
||||||
names containing a tab, a newline, a leading dash and a `'`.
|
|
||||||
3. `read(transport, path) -> Read`: `stat -c '%s %Y' -- "$1"`, refuse
|
|
||||||
above `FILE_LIMIT` before `cat` so a 2 GB log never crosses the
|
|
||||||
tunnel, then `sha256sum -- "$1"` and `cat -- "$1"`, header lines then
|
|
||||||
bytes; the server splits at the header and decides `text`/`binary` by
|
|
||||||
`String::from_utf8`.
|
|
||||||
4. `write(transport, path, expected_sha256, bytes) -> Written`: the
|
|
||||||
script in decision 6, with a distinct exit code for the precondition
|
|
||||||
(`exit 3`) that the route maps to 409; anything else is the machine's
|
|
||||||
stderr.
|
|
||||||
5. `create_file`, `create_dir`: decision 7.
|
|
||||||
6. Routes in `routes.rs`, each resolving the setup with `setup_by_id` and
|
|
||||||
`Transport::for_setup` as `set_cwd` does. The path check (absolute or
|
|
||||||
`~`) is one function shared with `set_cwd`, which has it inline today.
|
|
||||||
7. Tests: the parsers; the quoting (a path that tries to close the quote
|
|
||||||
ends up as one absurd argument -- `ssh.rs` has the pattern); and an
|
|
||||||
integration test running each script through `Transport::Here`
|
|
||||||
against a `tempfile` tree, which is cheap because `sh` is there
|
|
||||||
wherever `cargo test` runs. The precondition test writes the file
|
|
||||||
between the read and the write and asserts the 409 path.
|
|
||||||
8. PLAN.md: the Security paragraph from decision 3, and an "Explorer"
|
|
||||||
section pointing here. AGENTS.md: the layout bullet for `files.rs`.
|
|
||||||
|
|
||||||
## App work
|
|
||||||
|
|
||||||
1. `Api.kt`: `fetchDir`, `fetchFile`, `writeFile`, `createFile`,
|
|
||||||
`createDir`, and the three data classes (`DirEntry`, `FileContent`
|
|
||||||
as a sealed class with the four kinds, `Written`).
|
|
||||||
2. `NerdIcons.kt` + `build-icon-font.sh`: decision 12.
|
|
||||||
3. `Languages.kt` (or `CodeFence.kt`, wherever `FENCE_LANGUAGES` sits):
|
|
||||||
`fileLanguage(name)`.
|
|
||||||
4. `FileLines.kt`: the pure half of the viewer -- spans bucketed per line,
|
|
||||||
`lineOf(index) -> AnnotatedString` -- so it has a JVM unit test beside
|
|
||||||
`HighlighterTest`, the app's one existing test suite, covering a block
|
|
||||||
comment that spans lines and a file with no trailing newline.
|
|
||||||
5. `FilesScreen.kt`: the listing, the navigation stack, the per-directory
|
|
||||||
cache, the create dialog (modelled on `AddSetupDialog`: fields, a busy
|
|
||||||
state, the failure shown inside the dialog beside the button that
|
|
||||||
caused it), and the header. `LoadState` for the listing.
|
|
||||||
6. `FileViewer.kt`: decision 8. `FileEditor.kt`: decision 9, including
|
|
||||||
the conflict dialog.
|
|
||||||
7. `AppRoot.kt`: decision 10. `SessionScreen.kt`: the folder glyph where
|
|
||||||
the speedometer was, `onFiles(setup, cwd)` out to the root.
|
|
||||||
8. `SessionSettingsDialog.kt`: the render-report row. In
|
|
||||||
`~/repos/emulator-tools`, `ui-trace`'s tap-by-label action; then the
|
|
||||||
two bench scripts onto it, with no coordinate tap left in `app/*.sh`.
|
|
||||||
|
|
||||||
## What the measurements said (2026-09-04)
|
## What the measurements said (2026-09-04)
|
||||||
|
|
||||||
Taken on the emulator in a **debug** build, which runs Compose at a
|
Taken on the emulator in a **debug** build, which runs Compose at a fraction
|
||||||
fraction of release speed and renders in software -- so these rank
|
of release speed and renders in software — so these rank correctly against
|
||||||
correctly against each other and are pessimistic in absolute terms.
|
each other and are pessimistic in absolute terms. Generated Rust, through the
|
||||||
Generated Rust, through the app's own render report.
|
app's own render report.
|
||||||
|
|
||||||
| file | lines | scan + cut | scan per keystroke | worst frame record |
|
| file | lines | scan + cut | scan per keystroke | worst frame record |
|
||||||
|--------|--------|------------|--------------------|--------------------|
|
|--------|--------|------------|--------------------|--------------------|
|
||||||
@@ -451,32 +302,29 @@ Generated Rust, through the app's own render report.
|
|||||||
|
|
||||||
Three things followed.
|
Three things followed.
|
||||||
|
|
||||||
**The viewer's scan had to leave the main thread.** Decision 8 said "off
|
**The viewer's scan had to leave the main thread.** Decision 8 said "off the
|
||||||
the main thread" and the first version did it in a `remember` inside the
|
main thread" and the first version did it in a `remember` inside the
|
||||||
composition, which is not that: 460ms of frozen screen at the size the
|
composition, which is not that: 460ms of frozen screen at the size the server
|
||||||
server is willing to send, long enough that the accessibility tree cannot
|
is willing to send, long enough that the accessibility tree cannot be read —
|
||||||
be read -- which is exactly what "the app has stopped" looks like from
|
which is exactly what "the app has stopped" looks like from outside.
|
||||||
outside. It now runs on `Dispatchers.Default` with a spinner where the file
|
|
||||||
will be.
|
|
||||||
|
|
||||||
**`FILE_LIMIT` at 1 MiB is right for reading.** Time to first line for a
|
**`FILE_LIMIT` at 1 MiB is right for reading.** Time to first line for a
|
||||||
1 MiB file, tap to text on screen, was **2.4s** against the sandbox --
|
1 MiB file, tap to text on screen, was **2.4s** against the sandbox — 1.2s of
|
||||||
1.2s of which is that server's deliberate `--delay`, and 460ms the scan.
|
which is that server's deliberate `--delay`, and 460ms the scan. The transfer
|
||||||
The transfer is not what dominates, so the route gains nothing from
|
is not what dominates, so the route gains nothing from streaming.
|
||||||
streaming.
|
|
||||||
|
|
||||||
**Edit mode needed a cap, and not the one that was expected.** The plan
|
**Edit mode needed a cap, and not the one that was expected.** The plan
|
||||||
expected to be deciding a size below which highlighting stays on. That is
|
expected to be deciding a size below which highlighting stays on. That is not
|
||||||
not the cost that matters: highlighting 128 kB costs 40ms a keystroke,
|
the cost that matters: highlighting 128 kB costs 40ms a keystroke, which is
|
||||||
which is survivable, while laying the same text out in one
|
survivable, while laying the same text out in one `BasicTextField` costs two
|
||||||
`BasicTextField` costs two seconds -- characters typed into it were
|
seconds — characters typed into it were dropped, and a 1 MiB file stopped the
|
||||||
dropped, and a 1 MiB file stopped the app responding altogether. Since
|
app responding altogether. Since every arrangement of a single text field
|
||||||
every arrangement of a single text field pays that, switching highlighting
|
pays that, switching highlighting off would have saved nothing. So
|
||||||
off would have saved nothing. So `EDIT_LIMIT` is **32 kB**, the largest
|
`EDIT_LIMIT` is **32 kB**, the largest size measured as usable, and above it
|
||||||
size measured as usable, and above it the pencil is disabled with the
|
the pencil is disabled with the reason said in words beside it — a disabled
|
||||||
reason said in words beside it -- a disabled control teaches what the thing
|
control teaches what the thing can do but cannot say why it is off, and a
|
||||||
can do but cannot say why it is off, and a reader who cannot edit a file
|
reader who cannot edit a file they can plainly read would otherwise conclude
|
||||||
they can plainly read would otherwise conclude the app is broken.
|
the app is broken.
|
||||||
|
|
||||||
Reading is unaffected: the viewer opens and scrolls the 1 MiB file fine,
|
Reading is unaffected: the viewer opens and scrolls the 1 MiB file fine,
|
||||||
because it is a `LazyColumn` of lines rather than one text object. That
|
because it is a `LazyColumn` of lines rather than one text object. That
|
||||||
@@ -484,21 +332,20 @@ difference is the whole of decision 8.
|
|||||||
|
|
||||||
## Later, deliberately not now
|
## Later, deliberately not now
|
||||||
|
|
||||||
- Delete, rename and move. Destructive controls belong here eventually,
|
- Delete, rename and move. Destructive controls belong here eventually, shown
|
||||||
shown and confirmed rather than hidden, but none of them is needed to
|
and confirmed rather than hidden, but none is needed to read or change a
|
||||||
read or change a file.
|
file.
|
||||||
- Images in the viewer, through the existing `SessionImageViewer`.
|
- Images in the viewer, through the existing `SessionImageViewer`.
|
||||||
- Following an agent's edits live: a file open in the viewer refreshing
|
- Following an agent's edits live: a file open in the viewer refreshing when
|
||||||
when a `Write`/`Edit` tool call on the same path lands in the
|
a `Write`/`Edit` tool call on the same path lands in the transcript. The
|
||||||
transcript. The transcript already knows the path.
|
transcript already knows the path.
|
||||||
- Remembering the last directory per session.
|
- Remembering the last directory per session.
|
||||||
- Uploading from the phone into a directory. Attachments already do the
|
- Uploading from the phone into a directory. Attachments already do the
|
||||||
upload half; this would be the same route with a chosen destination.
|
upload half.
|
||||||
- Search within a file, and find-in-files.
|
- Search within a file, and find-in-files.
|
||||||
- **A line-by-line editor**, which is the way past `EDIT_LIMIT`. The
|
- **A line-by-line editor**, which is the way past `EDIT_LIMIT`. The viewer
|
||||||
viewer already draws a file as rows and stays fast on a megabyte; an
|
already draws a file as rows and stays fast on a megabyte; an editor built
|
||||||
editor built the same way -- a field per line, or a field over the lines
|
the same way — a field per line, or a field over the lines on screen —
|
||||||
on screen -- would not pay Compose's cost of laying out one enormous
|
would not pay Compose's cost of laying out one enormous text. It is a good
|
||||||
text. It is a good deal more than this feature needed, and 32 kB covers
|
deal more than this feature needed, and 32 kB covers the config files,
|
||||||
the config files, notes and ordinary source files anybody edits from a
|
notes and ordinary source files anybody edits from a phone.
|
||||||
phone.
|
|
||||||
+281
-366
@@ -1,419 +1,343 @@
|
|||||||
# The transcript cache
|
# The transcript cache
|
||||||
|
|
||||||
Asked for by Iris on 2026-09-04: keep the transcripts of recently visited
|
Asked for by Iris on 2026-09-04 and built the same day: keep the transcripts
|
||||||
sessions on the phone, so reopening one does not download it again. It has
|
of recently visited sessions on the phone, so reopening one does not download
|
||||||
to save data over the tunnel, it must not disturb a reply that is streaming
|
it again. It has to save data over the tunnel, must not disturb a reply that
|
||||||
when the screen is reopened, it must never skip an event, and session
|
is streaming when the screen is reopened, must never skip an event, and needs
|
||||||
settings needs a manual reload for when the file on the machine has
|
a manual reload for when the file on the machine has changed under it.
|
||||||
changed under it.
|
|
||||||
|
|
||||||
Built 2026-09-04. Like EXPLORER.md this records each decision with its
|
Like EXPLORER.md this records each decision with its reason and what was
|
||||||
reason and what was rejected, so that when one changes it is changed here
|
rejected, so that when one changes it is changed here rather than re-argued.
|
||||||
rather than re-argued -- three of them changed during the building, and
|
"What building it changed" at the foot says which of them moved while it was
|
||||||
"What building it changed" at the foot says which and why. What it is *not*
|
being built. How to exercise it, and what has bitten, are in AGENTS.md.
|
||||||
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
|
## What it is, in one paragraph
|
||||||
|
|
||||||
A per-session file on the phone holding the exact JSON lines the server has
|
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
|
already sent, in transcript order, with a record of which sequence numbers
|
||||||
each run of lines covers. Everything the session screen fetches today --
|
each run of lines covers. Everything the session screen fetches — the opening
|
||||||
the opening window, the pages it scrolls back through, the span an anchor
|
window, the pages it scrolls back through, the span an anchor restore reaches
|
||||||
restore reaches for -- is asked of the cache first and of the server only
|
for — is asked of the cache first and of the server only for what the cache
|
||||||
for what the cache does not hold, and everything that arrives from the
|
does not hold, and everything that arrives from the server is written into
|
||||||
server is written into it. The live stream then resumes from the newest
|
it. The live stream then resumes from the newest cached event, exactly as it
|
||||||
cached event, exactly as it resumes today from the newest event on screen,
|
resumes from the newest event on screen, so the server sends only what
|
||||||
so the server sends only what happened since. One tiny request checks that
|
happened since. One tiny request checks that the cached tail is still what
|
||||||
the cached tail is still what the server has before the stream is opened
|
the server has before the stream is opened from it, and a button in session
|
||||||
from it, and a button in session settings throws the cache away and
|
settings throws the cache away and rebuilds the screen as a cold open for the
|
||||||
rebuilds the screen as a cold open for the cases that check cannot see.
|
cases that check cannot see.
|
||||||
|
|
||||||
## The invariants
|
## The invariants
|
||||||
|
|
||||||
Everything below is in service of four rules. When a decision looks
|
When a decision below looks arbitrary, it is one of these forcing it.
|
||||||
arbitrary, it is one of these forcing it.
|
|
||||||
|
|
||||||
1. **What is on screen is what the server's transcript says, in order,
|
1. **What is on screen is what the server's transcript says, in order, with
|
||||||
with nothing missing, for every sequence number the screen claims to
|
nothing missing, for every sequence number the screen claims to show.**
|
||||||
show.** The cache is a copy of server output and is never inferred,
|
The cache is a copy of server output and is never inferred, folded, or
|
||||||
folded, or edited on the phone. Where the copy cannot be shown to be
|
edited on the phone. Where the copy cannot be shown to be current, it is
|
||||||
current, it is thrown away, not patched.
|
thrown away, not patched.
|
||||||
2. **A cached line is never ahead of the live cursor, and the live cursor
|
2. **A cached line is never ahead of the live cursor, and the live cursor is
|
||||||
is never ahead of the cache.** The stream resumes from the newest cached
|
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
|
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
|
its next delta and folds into the same row.
|
||||||
phone merely lost the tunnel for a second.
|
|
||||||
3. **The cache is never load-bearing.** A missing, evicted, corrupt or
|
3. **The cache is never load-bearing.** A missing, evicted, corrupt or
|
||||||
unwritable cache degrades to today's behaviour -- a cold open -- and
|
unwritable cache degrades to a cold open, never to a blank or wrong
|
||||||
never to a blank or wrong screen. Every path that reads it has a
|
screen. Every path that reads it has a network path beside it producing
|
||||||
network path beside it that produces the same result.
|
the same result.
|
||||||
4. **Data crosses the tunnel once.** A line already on the phone is not
|
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
|
fetched again unless the reader asks (the reload button) or the check in
|
||||||
check in decision 3 says it must be.
|
decision 3 says it must be.
|
||||||
|
|
||||||
## Decisions
|
## Decisions
|
||||||
|
|
||||||
### 1. Raw server lines, on the phone, keyed by server and session
|
### 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
|
The cache stores the server's own JSON, one event per line, byte-for-byte as
|
||||||
as it arrived: the elements of the `/transcript` array and the `data:`
|
it arrived: the elements of the `/transcript` array and the `data:` payload
|
||||||
payload of each SSE frame. Reading the cache means running the same
|
of each SSE frame. Reading the cache runs the same `parseSeqEvent` the
|
||||||
`parseSeqEvent` the network path runs, so a cached transcript and a fetched
|
network path runs, so a cached transcript and a fetched one cannot draw
|
||||||
one cannot draw differently, and an event type this build does not know
|
differently, and an event type this build does not know
|
||||||
(`SessionEvent.Unknown`) survives on disk for the build that will.
|
(`SessionEvent.Unknown`) survives on disk for the build that will.
|
||||||
|
|
||||||
It lives under `context.cacheDir` -- `<cacheDir>/transcripts/v1/<host>_<port>/<sessionId>/`
|
It lives under `context.cacheDir`, which is exactly what that directory is
|
||||||
-- because it is exactly what that directory is for: bytes the phone can
|
for: bytes the phone can regenerate from the server, which Android may delete
|
||||||
regenerate from the server, which Android may delete under storage
|
under storage pressure without asking. Keyed by the server's host and port,
|
||||||
pressure without asking. Keyed by the server's host and port because two
|
because two servers can hold a session with the same id (the sandbox and the
|
||||||
servers can hold a session with the same id (the sandbox and the real
|
real server, or a re-enrolment) and a line from one shown against the other
|
||||||
server, or a re-enrolment), and a line from one shown against the other
|
is invariant 1 broken. The `v1` segment is the format version: any change to
|
||||||
is invariant 1 broken. `ServerSettings` has both fields; the key is
|
the layout below bumps it, and a directory of another version is deleted on
|
||||||
`"${settings.host}_${settings.port}"` with `:` never appearing in it.
|
first use.
|
||||||
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
|
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,
|
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.
|
and a JSONL file per contiguous run answers both by reading from its end. A
|
||||||
A database would be a new dependency for an index the file layout already
|
database would be a new dependency for an index the file layout provides.
|
||||||
provides.
|
|
||||||
|
|
||||||
Rejected: caching folded `TranscriptItem` rows instead of events. Rows are
|
Rejected: caching folded `TranscriptItem` rows instead of events. Rows are a
|
||||||
a *rendering* of events, and their shape changes when the fold changes;
|
*rendering* of events, and their shape changes when the fold changes; the
|
||||||
the cache would need invalidating on every app update that touched
|
cache would need invalidating on every app update that touched `foldEvent`,
|
||||||
`foldEvent`, and would still have to keep raw seqs for the stream cursor.
|
and would still have to keep raw seqs for the stream cursor. Events are the
|
||||||
Events are the server's contract and the only thing that is stable.
|
server's contract and the only thing that is stable.
|
||||||
|
|
||||||
### 2. Chunks with explicit coverage; one contiguous run behind the cursor
|
### 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
|
A page from the server is a set of lines *and a claim about what they cover*,
|
||||||
cover*, and the two are not the same thing. A coalesced page
|
and the two are not the same thing. A coalesced page joins each run of
|
||||||
(`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,
|
||||||
`assistantText` deltas into one event carrying the seq of its *oldest*
|
so a page whose newest event has seq 1,200 may in fact cover every line up to
|
||||||
delta, so a page whose newest event has seq 1,200 may in fact cover every
|
the `before` it was asked with, say 1,650. Nothing in the lines themselves
|
||||||
line up to the `before` it was asked with, say 1,650. Nothing in the lines
|
says so. So each stored chunk records its coverage as a half-open range
|
||||||
themselves says so. So each stored chunk records its coverage as a
|
`[first, end)`, where `end` is the `before` the request was made with — or,
|
||||||
half-open range `[first, end)`, where `first` is the seq of its oldest
|
for a raw chunk, its newest seq plus one.
|
||||||
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:
|
Chunks are files named by their coverage:
|
||||||
|
|
||||||
<first>-<end>.rows.jsonl a coalesced page; end is the `before` it was fetched with
|
<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>-<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
|
<first>-open.raw.jsonl the live run: appended to by the stream
|
||||||
|
|
||||||
Two chunks are **adjacent** when one's `end` equals the other's `first`.
|
Two chunks are **adjacent** when one's `end` equals the other's `first`. The
|
||||||
The cache serves only the contiguous run of adjacent chunks that ends at
|
cache serves only the contiguous run of adjacent chunks that ends at the
|
||||||
the newest raw chunk (the **suffix**); chunks behind a gap are kept on
|
newest raw chunk (the **suffix**); chunks behind a gap are kept on disk,
|
||||||
disk, because the gap is usually filled (decision 4), but are never served
|
because the gap is usually filled (decision 4), but are never served across
|
||||||
across the gap.
|
it.
|
||||||
|
|
||||||
**The newest chunk is always raw.** That is what makes the stream cursor
|
**The newest chunk is always raw.** That is what makes the stream cursor and
|
||||||
and the check in decision 3 well defined: a raw chunk's last line is a real
|
the probe well defined: a raw chunk's last line is a real event at a real
|
||||||
event at a real seq, and the server never coalesces the newest window
|
seq, and the server never coalesces the newest window. It holds by
|
||||||
("the live cursor depends on real seqs", `read_window`). It holds by
|
construction — the opening window is fetched with no `before`, stream frames
|
||||||
construction -- the opening window is fetched with no `before`, stream
|
are raw, and a `reset` window is raw — and is *checked* on read: a `.rows`
|
||||||
frames are raw, and a `reset` window is raw -- and is *checked* on read:
|
chunk found newest (which can only happen if the app died between closing one
|
||||||
if the newest chunk on disk is a `.rows` chunk (which can only happen if
|
live run and appending to the next) purges the session's cache.
|
||||||
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
|
There is at most one open chunk. A stream event whose seq is not the open
|
||||||
not the open chunk's `end` -- which is what a `reset` looks like from
|
chunk's `end` — which is what a `reset` looks like from here — closes it by
|
||||||
here, see decision 6 -- the open chunk is closed by renaming it with its
|
renaming it with its real end and starts a new one. An event whose seq is
|
||||||
real end, and a new open chunk starts at the arriving seq. An event whose
|
below the open chunk's `end` is already covered and is not written; the SSE
|
||||||
seq is below the open chunk's `end` is already covered and is not written
|
contract is `seq > after`, so that is a guard rather than a path.
|
||||||
(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
|
Rejected: one file per session, rewritten to prepend older pages. A 20 MB
|
||||||
20 MB transcript would be rewritten on every page scrolled back to. The
|
transcript would be rewritten on every page scrolled back to. The chunk
|
||||||
chunk directory costs a directory listing per open instead.
|
directory costs a directory listing per open instead.
|
||||||
|
|
||||||
Rejected: trimming chunks to resolve overlaps. A coalesced event cannot be
|
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
|
split at a seq inside its run, so an overlap between a coalesced page and an
|
||||||
an existing chunk has no clean cut. The cache therefore **never stores a
|
existing chunk has no clean cut. The cache therefore **never stores a page
|
||||||
page that overlaps an existing chunk**; decision 4 makes sure such a page
|
that overlaps an existing chunk**; decision 4 makes sure such a page is never
|
||||||
is never fetched in the first place, and if one arrives anyway (a server
|
fetched, and one that arrives anyway is used for display and not stored.
|
||||||
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
|
### 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
|
The transcript file is append-only in ordinary use, but it can be replaced or
|
||||||
event at that seq is the one in the cache. The transcript file on the
|
truncated — a sandbox re-seeded with the same ids, a backup restored, a
|
||||||
machine is append-only in ordinary use, but it can be replaced or
|
session deleted and re-imported — and `catch_up` on such a file would hand
|
||||||
truncated -- a sandbox re-seeded with the same ids, a backup restored, a
|
the phone a continuation of a *different* conversation, spliced onto the
|
||||||
directory deleted and the session re-imported under the same name -- and
|
cached one with no seam. That is the worst thing this feature can do, and it
|
||||||
`catch_up` on such a file would hand the phone a continuation of a
|
is caught with one request.
|
||||||
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`,
|
**The probe** is `GET /sessions/{id}/transcript?before=<cursor+1>&limit=1`,
|
||||||
where `cursor` is the seq of the cache's newest line. `read_window` with
|
where `cursor` is the seq of the cache's newest line. `read_window` with that
|
||||||
that `before` returns the single newest event with seq ≤ cursor, which is
|
`before` returns the single newest event with seq ≤ cursor, which is the
|
||||||
the event *at* the cursor when it exists. The probe passes when that
|
event *at* the cursor when it exists. It passes when that response, parsed
|
||||||
response, parsed with `parseSeqEvent`, is `==` to the cached line parsed
|
with `parseSeqEvent`, is `==` to the cached line parsed the same way — over
|
||||||
the same way -- data-class equality over seq, ts, and the whole event. It
|
seq, ts, and the whole event. It fails when the response is empty, is a
|
||||||
fails when the response is empty, is a different seq, or differs in any
|
different seq, or differs in any field.
|
||||||
field.
|
|
||||||
|
|
||||||
That equality rested on an assumption this plan stated and did not check:
|
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
|
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
|
not**, and the server was fixed — see AGENTS.md's entry on `float_roundtrip`.
|
||||||
`ts` of `1788546972.6030757` written to the transcript came back from
|
Comparing everything *except* `ts` was the other option and was rejected: a
|
||||||
`/transcript` as `...0755`, while the SSE stream -- serializing the same
|
re-seeded fixture is identical in content and differs only in when it
|
||||||
struct -- sent the original. Measured on the emulator 2026-09-04: 23 of 330
|
happened, which is exactly the case the probe exists for.
|
||||||
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
|
A failed probe **purges the session's cache and proceeds as a cold open**. A
|
||||||
them, silently and only sometimes. That is a defect in the server
|
probe that cannot be made leaves the cached transcript on screen, shows the
|
||||||
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
|
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
|
retried on the stream loop's schedule; the stream is never opened until a
|
||||||
is never opened until a probe has passed once for this screen instance.
|
probe has passed once for this screen instance.
|
||||||
|
|
||||||
What the probe does *not* catch: a line changed in the middle of the file
|
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
|
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
|
happens to be identical. Those are what the reload button is for, and the
|
||||||
button's caption says so.
|
button's caption says so.
|
||||||
|
|
||||||
Cost: one request of a few hundred bytes, one round trip, in the slot
|
Cost: one request of a few hundred bytes, in the slot where the opening
|
||||||
where the opening page's request is today -- so the round trips before
|
page's request would be — so the round trips before the stream is live are
|
||||||
the stream is live are unchanged at two, and the bytes fall from a page to
|
unchanged at two, and the bytes fall from a page to a line. The cached rows
|
||||||
a line. The cached rows are drawn *before* the probe returns, which is the
|
are drawn *before* the probe returns, which is the whole point; a failed
|
||||||
whole point of the feature; a failed probe replaces them, the same
|
probe replaces them, with the same appearance as a `reset`.
|
||||||
appearance as a `reset`.
|
|
||||||
|
|
||||||
Rejected: a server-side check on the stream (`events?after=N&ts=T`,
|
Rejected: a server-side check on the stream, answered with a distinct frame
|
||||||
answered with a distinct frame when the event at N is not what the phone
|
when the event at N is not what the phone thinks. Strictly better coverage —
|
||||||
thinks). Strictly better coverage -- it would run on every reconnect, not
|
it would run on every reconnect — and no extra round trip. Not chosen because
|
||||||
only on open -- and no extra round trip. Not chosen because it puts a
|
it puts a cache's validation into a protocol that otherwise knows nothing
|
||||||
cache's validation into a protocol that otherwise knows nothing about
|
about caching, and because the reset frame already has to keep meaning "you
|
||||||
caching, and because the reset frame already has to keep meaning "you are
|
are behind, your history is fine". Worth revisiting if the probe's round trip
|
||||||
behind, your history is fine" (decision 6), so a second frame would be
|
is ever measured as the thing making reopen slow.
|
||||||
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
|
Rejected: trusting the cache and relying on the reload button. Invariant 1 is
|
||||||
button. Invariant 1 is not something a button restores after the fact.
|
not something a button restores after the fact.
|
||||||
|
|
||||||
Rejected: fetching the newest page as today and using it to validate the
|
Rejected: fetching the newest page as before and using it to validate the
|
||||||
overlap. Zero saving on the opening page, which is the request paid on
|
overlap. Zero saving on the opening page, which is the request paid on every
|
||||||
every open.
|
open.
|
||||||
|
|
||||||
### 4. Pages ask the server only for the gap: `after` on `/transcript`
|
### 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
|
After a reader has been away, the cache holds `[a, b)` and the screen holds
|
||||||
holds the newest window `[W, …)` with a gap between `b` and `W`. Paging
|
the newest window `[W, …)` with a gap between `b` and `W`. Paging back from
|
||||||
back from `W` asks the server for a coalesced page before `W`, and that
|
`W` asks for a coalesced page before `W`, and that page may reach back past
|
||||||
page may reach back past `b` -- a single reply is hundreds of lines, so
|
`b` — a single reply is hundreds of lines, so forty rows can be thousands of
|
||||||
forty rows can be thousands of seqs -- producing exactly the overlap
|
seqs — producing exactly the overlap decision 2 refuses to store. Left like
|
||||||
decision 2 refuses to store. Left like that, every cached chunk would be
|
that, every cached chunk would be dropped in turn as the reader paged back
|
||||||
overlapped and dropped in turn as the reader paged back through the gap,
|
through the gap, and the cache would save nothing for the sessions it exists
|
||||||
and the cache would save nothing for the sessions it exists for.
|
for.
|
||||||
|
|
||||||
So the transcript route gains a lower bound. `TranscriptQuery` in
|
So the transcript route takes a lower bound, `after`, named to match the SSE
|
||||||
`server/src/routes.rs` gets
|
route's (exclusive, `seq > after`). `read_window` starts the walk at
|
||||||
|
`first_at_or_after(after + 1)` instead of at `end - limit`. A delta run cut
|
||||||
|
at the 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.
|
||||||
|
|
||||||
/// Return nothing at or below this seq; the page stops here instead of at `limit`.
|
The phone passes `after = b - 1` where `b` is the `end` of the nearest chunk
|
||||||
/// The phone passes the end of what it already holds, so a page never overlaps it.
|
whose `end ≤ before`, and nothing when there is none. A page that comes back
|
||||||
#[serde(default)]
|
with `first == b` is adjacent, and the suffix now runs through the old
|
||||||
after: Option<u64>,
|
chunks: the gap is closed with exactly the bytes it was wide, and the history
|
||||||
|
behind it is served locally from then on.
|
||||||
|
|
||||||
named to match the SSE route's `after` (exclusive, `seq > after`).
|
Rejected: fetching the gap raw in one request, which is what the anchor
|
||||||
`read_window(path, before, after, limit, coalesce)` in
|
restore does. Exact, but a gap of ten thousand lines is several megabytes
|
||||||
`server/src/session/transcript.rs` computes
|
downloaded to save re-downloading history the reader may never scroll to.
|
||||||
`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
|
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
|
`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
|
session revisited — 200 raw events is one reply — so this would empty the
|
||||||
the cache for exactly the sessions that are opened most.
|
cache for exactly the sessions that are opened most.
|
||||||
|
|
||||||
### 5. A page is served locally in rows, mirroring the server's count
|
### 5. A page is served locally in rows, mirroring the server's count
|
||||||
|
|
||||||
`loadOlderPage` asks for `HISTORY_PAGE` (40) **rows** when
|
`loadOlderPage` asks for `HISTORY_PAGE` (40) **rows** when coalescing and for
|
||||||
`coalesce = true`, and for a number of **events** otherwise (the anchor
|
a number of **events** otherwise (the anchor restore). Served from the cache,
|
||||||
restore). Served from the cache, the events branch is the `limit` lines
|
the events branch is the `limit` lines before `before`. The rows branch walks
|
||||||
before `before`. The rows branch walks back from the line before `before`
|
back counting rows the way `parse_coalesced` does — every event that is not
|
||||||
counting rows the way `parse_coalesced` does: every event that is not an
|
an `assistantText` is a row, and each maximal run of `assistantText` lines is
|
||||||
`assistantText` is a row, and each maximal run of `assistantText` lines is
|
one row — stopping only between rows. It does not join the deltas; the fold
|
||||||
one row; it stops only between rows, once `limit` rows are complete, and
|
does that, and the joined row keeps the seq of its first delta either way, so
|
||||||
returns the raw lines oldest-first. It does not join the deltas -- the
|
anchors and the next `before` land where they do on the network path.
|
||||||
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
|
A cached page is allowed to be **short**: a walk that reaches the suffix's
|
||||||
at some `first`, and a walk that reaches it returns what it found. The
|
oldest chunk returns what it found. The caller already treats a short page as
|
||||||
caller already treats a short page as a page; only an *empty* page means
|
a page; only an *empty* page means "start of the conversation", and the cache
|
||||||
"start of the conversation" (`moreHistory = false`), and the cache never
|
never returns one — it returns `null` (a miss) and the network is asked.
|
||||||
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
|
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
|
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
|
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
|
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*
|
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
|
of a chunk. Under the narrower rule every warm open sent its first backwards
|
||||||
its first backwards page to the server, and that page -- reaching back past
|
page to the server, and that page overlapped what the phone already held and
|
||||||
the run the phone already held -- overlapped it and could not be stored, so
|
could not be stored, so the same history was fetched again on every visit.
|
||||||
the same history was fetched again on every visit. The feature would have
|
The feature would have saved the opening window and nothing else.
|
||||||
saved the opening window and nothing else.
|
|
||||||
|
|
||||||
The row rule is a copy of the server's, and copies drift. It is short
|
The row rule is a copy of the server's, and copies drift. It is short, it is
|
||||||
(one comparison), it is pure, and it goes under a JVM unit test with the
|
pure, and it is under a JVM unit test with the same fixture as the server's
|
||||||
same fixture as the server's `coalescing_counts_rows_and_joins_delta_runs`
|
`coalescing_counts_rows_and_joins_delta_runs` — a run cut by the limit, a
|
||||||
-- the three cases are a run cut by the limit, a `usageDelta` inside a run
|
`usageDelta` inside a run (the server flushes the run there, so it is two
|
||||||
(the server flushes the run there, so it is two rows), and a page that is
|
rows), and a page that is all one run.
|
||||||
all one run.
|
|
||||||
|
|
||||||
### 6. What a `reset` means for the cache: behind, not wrong
|
### 6. What a `reset` means for the cache: behind, not wrong
|
||||||
|
|
||||||
The server sends `reset` when the cursor is more than `CATCH_UP_LIMIT`
|
The server sends `reset` when the cursor is more than `CATCH_UP_LIMIT` events
|
||||||
events behind, then the newest 200 raw events. The screen already drops
|
behind, then the newest 200 raw events. For the cache that means **the
|
||||||
everything and rebuilds from that window. For the cache, a reset means
|
history is intact and there is a gap**: the probe passed, the file is
|
||||||
**the history is intact and there is a gap**: the probe passed, the file
|
append-only, and the window's first seq is above the open chunk's end. The
|
||||||
is append-only, and the window's first seq is above the open chunk's end.
|
store learns this from the first window event's seq and needs no signal from
|
||||||
The store learns this from the first window event's seq (decision 2:
|
the screen; the gap is filled by paging.
|
||||||
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
|
The reset handler also clears `queued` and `waitingCommands`, which it did
|
||||||
must: `queued` and `waitingCommands`. Both are folded from events, and a
|
not originally. Both are folded from events, and a `messageQueued` whose
|
||||||
`messageQueued` whose resolving `userMessage` fell in the gap would
|
resolving `userMessage` fell in the gap would otherwise draw a waiting bubble
|
||||||
otherwise draw a waiting bubble for a message the session has long since
|
for a message the session has long since read. That was a latent bug made
|
||||||
read. This is a latent bug today, made likely by the cache because a
|
likely by the cache, because a cached tail is older than a fetched one.
|
||||||
cached tail is older than a fetched one. `contextTokens` needs no change:
|
`contextTokens` needs no clearing: `UsageDelta.context` is absolute, so the
|
||||||
`UsageDelta.context` is absolute, so the window's first one corrects it.
|
window's first one corrects it.
|
||||||
|
|
||||||
### 7. Session state that is not the transcript comes from the list, not the cache
|
### 7. Session state that is not the transcript comes from the list, not the cache
|
||||||
|
|
||||||
`apply` derives `status`, `model`, `permissionMode` and `compactingSince`
|
`apply` derives `status`, `model`, `permissionMode` and `compactingSince`
|
||||||
from `Status` and `Settings` events. Replayed from a fetched page those are
|
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
|
current; replayed from the cache they are as old as the last visit, while the
|
||||||
`summary.status`, `summary.model` and `summary.permissionMode` -- the row
|
list row the reader just tapped was fetched moments ago. So the cache replay
|
||||||
the reader just tapped -- were fetched moments ago. So the cache replay
|
runs through `apply` for the transcript's sake and then **reassigns those
|
||||||
runs through `apply` for the transcript's sake (queued bubbles, context,
|
four from `summary`**, which is the newer of the two measurements; the
|
||||||
rows) and then **reassigns those four from `summary`**, which is the newer
|
stream's catch-up then makes them current. Without this a session that
|
||||||
of the two measurements; the stream's catch-up then makes them current.
|
finished an hour ago would open saying "working" until the stream connected,
|
||||||
Without this a session that finished an hour ago would open saying
|
which is a status row lying for a round trip.
|
||||||
"working" until the stream connected, which is a status row lying for a
|
|
||||||
round trip.
|
|
||||||
|
|
||||||
### 8. Reload, in session settings
|
### 8. Reload, in session settings
|
||||||
|
|
||||||
`SessionSettingsDialog` gains a row under the working directory:
|
A row under the working directory showing what the button discards:
|
||||||
|
|
||||||
[ Transcript ] 2.3 MB cached [ Reload ]
|
[ Transcript ] 2.3 MB cached [ Reload ]
|
||||||
|
|
||||||
The size is what the button discards, and it is the unknown state made
|
The size is the unknown state made visible — `null` while the directory is
|
||||||
visible: `null` while the directory is being measured (spinner, as the
|
being measured (spinner, as the notifications switch does), "nothing cached"
|
||||||
notifications switch does), "nothing cached" when the directory is absent
|
when the directory is absent or empty, else the size. The caption is in the
|
||||||
or empty, else the size. A caption in the style of Move's, because the
|
style of Move's, because the button costs something the reader cannot see:
|
||||||
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."*
|
||||||
|
|
||||||
Reload throws away this phone's copy and fetches the transcript from the
|
Pressing it purges the session's cache directory, closes the dialog, and
|
||||||
server again. Use it when what is shown here disagrees with the file on
|
rebuilds the screen as a cold open, with the reader put back where they were.
|
||||||
the machine.
|
The mechanism is an `epoch` counter in the key of the opening effect and the
|
||||||
|
stream effect; incrementing it cancels both and relaunches them. `savedAnchor`
|
||||||
|
is keyed on the epoch too, so the restore reads the anchor saved at the
|
||||||
|
reader's *current* position. 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.
|
||||||
|
|
||||||
Pressing it: purge the session's cache directory, close the dialog, and
|
Nothing is announced on success — the transcript shows the opening spinner
|
||||||
rebuild the screen as a cold open -- the same sequence as `reset` plus a
|
and then the rows, which is what the screen already says about a reload. A
|
||||||
fresh opening fetch, with the reader put back where they were. The
|
failure is the opening fetch's, and lands on the stream banner.
|
||||||
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
|
Rejected: a global "clear transcript cache" in the app's settings. Not asked
|
||||||
and then the rows, which is what the screen already says about a reload.
|
for; eviction bounds the total, and the per-session button is where the
|
||||||
A failure is the opening fetch's, and lands on the stream banner where
|
reader is when they notice a problem. Easy to add as one more caller of
|
||||||
that failure lands today.
|
`purgeAll`.
|
||||||
|
|
||||||
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
|
### 9. Budget, eviction, pruning
|
||||||
|
|
||||||
The cache is bounded three ways, each with its path out written beside
|
Bounded three ways, each with its path out written beside the path in:
|
||||||
the path in:
|
|
||||||
|
|
||||||
- **Budget.** `CACHE_BUDGET_BYTES = 256 MB` across all sessions of one
|
- **Budget.** `CACHE_BUDGET_BYTES` is 256 MB across all sessions of one
|
||||||
server. Each open touches the session directory's mtime; after the
|
server. Each open touches the session directory's mtime; after the opening
|
||||||
opening replay, on `Dispatchers.IO`, the store sums the server's
|
replay, on `Dispatchers.IO`, the store sums the server's directories and
|
||||||
directories and deletes least-recently-touched session directories
|
deletes least-recently-touched ones (never the one on screen) until under
|
||||||
(never the one on screen) until under budget. 256 MB is a dozen of the
|
budget. 256 MB is a dozen of the largest transcripts seen in this VM
|
||||||
largest transcripts seen in this VM (21 MB for 24,000 events) and a
|
(21 MB for 24,000 events) and a small fraction of a phone; it is a number
|
||||||
small fraction of a phone; it is a number to revisit against real use,
|
to revisit against real use, not a measurement.
|
||||||
not a measurement.
|
- **Deleted sessions.** The list screen's delete purges after `deleteSession`
|
||||||
- **Deleted sessions.** `SessionListScreen`'s delete calls
|
succeeds, and every successful list fetch calls `retainOnly(ids)`, so a
|
||||||
`cache.session(id).purge()` after `deleteSession` succeeds, and every
|
session deleted from another device is pruned on the next visit to the
|
||||||
successful list fetch calls `cache.retainOnly(ids)` for that server, so
|
list. `Drafts.kt` chose not to prune because its residue is bytes; here it
|
||||||
a session deleted from another device or from the backend is pruned on
|
is megabytes.
|
||||||
the next visit to the list. `Drafts.kt` chose not to prune because its
|
- **Android.** `cacheDir` may be emptied at any moment, including while a
|
||||||
residue is bytes; here it is megabytes, so the pass is worth having.
|
screen is open. Every read tolerates a missing directory and every write
|
||||||
- **Android.** `cacheDir` may be emptied under pressure at any moment,
|
failure is swallowed once.
|
||||||
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
|
### 10. The cache never breaks the screen
|
||||||
|
|
||||||
Every store operation that touches the disk catches `IOException` and
|
Every store operation that touches the disk catches `IOException` and answers
|
||||||
answers as if the cache were empty: `null` from a read, no-op from a
|
as if the cache were empty: `null` from a read, no-op from a write, logged
|
||||||
write, with the failure logged once at `Log.w("ai-app", …)`. After a
|
once. After a write failure the instance stops writing, so a full disk costs
|
||||||
write failure the `SessionCache` instance sets `disabled = true` and
|
one log line rather than one per delta. A line at the end of an open chunk
|
||||||
writes nothing more, so a full disk costs one log line rather than one
|
that does not parse — the app died mid-write — is dropped and the file
|
||||||
per delta. A line at the end of an open chunk that does not parse -- the
|
truncated to the last good line before anything is served from it; a line
|
||||||
app died mid-write -- is dropped and the file truncated to the last
|
that does not parse anywhere else purges the session's cache, since that file
|
||||||
good line before anything is served from it; a line that does not parse
|
was not written by this code. None of this is reported on screen: none of it
|
||||||
anywhere else purges the session's cache (that file was not written by
|
changes what the screen shows, and the reader has nothing to do about it.
|
||||||
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
|
## Layout on disk
|
||||||
|
|
||||||
@@ -424,12 +348,12 @@ the screen shows, and the reader has nothing to do about it.
|
|||||||
1-1650.rows.jsonl coalesced page: covers seqs 1..1649
|
1-1650.rows.jsonl coalesced page: covers seqs 1..1649
|
||||||
1650-2001.rows.jsonl
|
1650-2001.rows.jsonl
|
||||||
2001-2400.raw.jsonl a closed live run
|
2001-2400.raw.jsonl a closed live run
|
||||||
2600-open.raw.jsonl the live run; end = last line's seq + 1
|
2600-open.raw.jsonl the live run
|
||||||
|
|
||||||
Here 2400..2599 is a gap: the reader was away for two hundred events and
|
Here 2400..2599 is a gap: the reader was away for two hundred events and the
|
||||||
the stream reset. The suffix is the single chunk `2600-open`; the first
|
stream reset. The suffix is the single chunk `2600-open`; the first backwards
|
||||||
backwards page asks the server for `before=2600&after=2399&coalesce=true`,
|
page asks the server for `before=2600&after=2399&coalesce=true`, and once a
|
||||||
and once a page comes back with `first == 2400` the suffix runs to seq 1.
|
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
|
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
|
server sent it. No header, no index: coverage is in the name, order is the
|
||||||
@@ -437,76 +361,67 @@ file's, and the seq is in every line.
|
|||||||
|
|
||||||
## What building it changed
|
## What building it changed
|
||||||
|
|
||||||
Each of these contradicted something written above, and each was found by
|
Each of these contradicted the plan, and each was found by running it rather
|
||||||
running it rather than by reading it. The decisions themselves are amended
|
than by reading it. The decisions above are amended in place; this is what
|
||||||
in place; this is the list of what moved, so that a reader who remembers the
|
moved, so a reader who remembers the first version knows what to re-read.
|
||||||
first version knows what to re-read.
|
|
||||||
|
|
||||||
- **The probe's equality had a false premise** -- decision 3. The server did
|
- **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.
|
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 cached page starts anywhere inside the run** (decision 5). Requiring a
|
||||||
a chunk boundary would have made the cache save the opening window and
|
chunk boundary would have made the cache save the opening window and
|
||||||
nothing else.
|
nothing else.
|
||||||
- **The opening window is stored by `append`, not by `storePage`.** The
|
- **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
|
sketch had `storePage` grow a special case for "this page is the new open
|
||||||
open chunk", decided by an implicit condition that a raw history page also
|
chunk", decided by an implicit condition a raw history page also satisfies.
|
||||||
satisfies. Appending each line instead is the mechanism that already
|
Appending each line instead is the mechanism that already exists, and the
|
||||||
exists, and the open chunk stays the one thing that grows.
|
open chunk stays the one thing that grows.
|
||||||
- **Chunks are read backwards, in blocks, and never whole.** Every question
|
- **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
|
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
|
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
|
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
|
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.
|
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
|
- **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` 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
|
`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,
|
question a second time and raced its own answer. Two probes per warm open,
|
||||||
visible in the server's log.
|
visible in the server's log.
|
||||||
- **`SessionCache` is synchronized.** The stream appends live events from
|
- **`SessionCache` is synchronized.** The stream appends live events from one
|
||||||
one IO thread while a reader scrolling back reads pages from another; the
|
IO thread while a reader scrolling back reads pages from another; the open
|
||||||
open chunk's name, its end and its writer must never be seen
|
chunk's name, its end and its writer must never be seen half-rotated.
|
||||||
half-rotated.
|
|
||||||
|
|
||||||
## What it cost, measured
|
## What it cost, measured
|
||||||
|
|
||||||
On the emulator against `app/ui-sandbox.sh`, 2026-09-04, on a session of
|
On the emulator against `app/ui-sandbox.sh`, 2026-09-04, on a session of 505
|
||||||
505 events (three short exchanges and two 300-delta replies):
|
events (three short exchanges and two 300-delta replies):
|
||||||
|
|
||||||
- **Reopening it: one request, for one event.** The probe, and nothing else
|
- **Reopening it: one request, for one event.** The probe, and nothing else —
|
||||||
-- including scrolling the whole conversation back to its first line. A
|
including scrolling the whole conversation back to its first line. A cold
|
||||||
cold open of the same session is two requests and 100 events.
|
open of the same session is two requests and 100 events.
|
||||||
- **A reset after falling 300 events behind costs the gap and no more.**
|
- **A reset after falling 300 events behind costs the gap and no more.** The
|
||||||
The window arrived at seq 306, the phone held up to 202, and the first
|
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
|
backwards page asked `before=306&after=201` and came back with **four
|
||||||
coalesced rows** covering 202..305 -- against the 104 raw events an
|
coalesced rows** covering 202..305 — against the 104 raw events an
|
||||||
unbounded page would have re-fetched and then thrown away.
|
unbounded page would have re-fetched and thrown away.
|
||||||
- **Every chunk is exactly what the server says for the range its name
|
- **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
|
claims**, checked line by line against `/transcript` for each chunk's own
|
||||||
`before`/`after`/`coalesce`, across a reset and a gap-fill.
|
`before`/`after`/`coalesce`, across a reset and a gap-fill.
|
||||||
- **Nothing about drawing changed**, which is what a cache must not do:
|
- **Nothing about drawing changed**, which is what a cache must not do:
|
||||||
`transcript-bench.sh` before and after, same viewport content and the same
|
`transcript-bench.sh` before and after, same viewport content and gestures,
|
||||||
gestures, reported p50 16.9ms both times and the transcript's own draw
|
p50 16.9ms both times and the transcript's own draw accounting at 0.33ms
|
||||||
accounting at 0.33ms against 0.32ms.
|
against 0.32ms.
|
||||||
|
|
||||||
Still to measure, in real use rather than here: the size the cache reaches
|
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
|
against `CACHE_BUDGET_BYTES`, and whether the probe's round trip is ever what
|
||||||
what a reader waits on.
|
a reader waits on.
|
||||||
|
|
||||||
## Open questions
|
## Open questions
|
||||||
|
|
||||||
- **The probe on every reconnect, not only on open?** Decision 3 probes
|
- **The probe on every reconnect, not only on open?** A file replaced *while*
|
||||||
once per screen instance. A file replaced *while* the screen is open is
|
the screen is open is not made worse than it was, but the server-side check
|
||||||
today's behaviour and not made worse, but the server-side check it
|
decision 3 rejects would close it. Decide after measuring how often the
|
||||||
rejects would close it. Decide after measuring how often the probe's
|
probe's round trip is what the reader waits on.
|
||||||
round trip is what the reader waits on.
|
- **Images.** `SessionImage` fetches bytes from the files route on draw; they
|
||||||
- **A reset arriving during an anchor restore** was an open worry when this
|
are not part of this cache and are re-downloaded per view. A separate,
|
||||||
was written, and was measured and closed on 2026-09-04 (see "The reconnect
|
simpler cache (a directory of refs, no ordering) if the measurement above
|
||||||
loop does not reproduce") before this landed. The cache makes the restore
|
says the images are where the data goes.
|
||||||
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.
|
|
||||||
+8
-10
@@ -1,17 +1,15 @@
|
|||||||
//! Bearer-token auth for the entire HTTP surface.
|
//! Bearer-token auth for the entire HTTP surface.
|
||||||
//!
|
//!
|
||||||
//! This server's API *is* remote code execution, so the token gates every
|
//! This server's API *is* remote code execution, so the token gates every route
|
||||||
//! route with zero unauthenticated endpoints -- the middleware is applied
|
//! with zero unauthenticated endpoints -- the middleware is applied once around
|
||||||
//! once around the whole router (including the fallback) in `main.rs`,
|
//! the whole router (including the fallback) in `main.rs`, never per-route, so a
|
||||||
//! never per-route, so a new route can't forget it. See PLAN.md's security
|
//! new route can't forget it. See PLAN.md's security section for the threat
|
||||||
//! section for the threat model; the short version is that the token gates
|
//! model.
|
||||||
//! LAN/tunnel-reachable RCE and is rotatable, and WireGuard makes it
|
|
||||||
//! defense in depth rather than the sole gate.
|
|
||||||
//!
|
//!
|
||||||
//! Nothing in this module -- and nothing anywhere else -- may log the
|
//! Nothing in this module -- and nothing anywhere else -- may log the
|
||||||
//! Authorization header or the token; the test below holds a tripwire
|
//! Authorization header or the token; the test below is a tripwire against a
|
||||||
//! against a logging change silently starting to. It is one test covering
|
//! logging change silently starting to. It is one test covering both gating and
|
||||||
//! both gating and logging on purpose -- see the note in it.
|
//! logging on purpose -- see the note in it.
|
||||||
|
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|||||||
+114
-171
@@ -1,20 +1,17 @@
|
|||||||
//! The server's persistent state: the enrolled token hashes and the
|
//! The server's persistent state: the enrolled token hashes and the sessions
|
||||||
//! sessions that exist.
|
//! that exist.
|
||||||
//!
|
//!
|
||||||
//! Written whole and atomically (temp file + rename) rather than appended
|
//! Written whole and atomically (temp file + rename) rather than appended to:
|
||||||
//! to: it is small, and a half-written config would take the server down on
|
//! it is small, and a half-written config would take the server down on next
|
||||||
//! next start with no obvious way to recover from a phone. Every mutation
|
//! start with no obvious way to recover from a phone. Every mutation funnels
|
||||||
//! funnels through `SessionManager` (the registry pattern), so in-memory
|
//! through `SessionManager`, so in-memory and on-disk state can't come apart.
|
||||||
//! and on-disk state can't come apart.
|
|
||||||
//!
|
//!
|
||||||
//! The file is RON, in the shape [`wg_app_link::format`] describes -- the
|
//! The file is RON, in the shape [`wg_app_link::format`] describes -- the same
|
||||||
//! same format, and the same two house rules, as the sibling dev-updater
|
//! two house rules as dev-updater's config, because both are read and written
|
||||||
//! project's config, because both are written and read by hand, and both
|
//! by hand.
|
||||||
//! now read and write them through the one module.
|
|
||||||
//!
|
//!
|
||||||
//! Transcripts do NOT live here -- each session's events are an append-only
|
//! Transcripts do NOT live here: each session's events are an append-only JSONL
|
||||||
//! JSONL file in its own directory (see `session::transcript`); this file
|
//! file in its own directory.
|
||||||
//! holds only the metadata needed to list and respawn sessions.
|
|
||||||
|
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
@@ -27,24 +24,20 @@ use wg_app_link::format;
|
|||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase", default)]
|
#[serde(rename_all = "camelCase", default)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
/// Enrolled device tokens, hashes only -- a leaked config doesn't leak
|
/// Enrolled device tokens, hashes only -- a leaked config doesn't leak the
|
||||||
/// the credential. A list (of one, today) so per-device tokens with
|
/// credential. A list (of one, today) so per-device tokens with individual
|
||||||
/// individual revocation are a config entry later, not a migration.
|
/// revocation are a config entry later, not a migration.
|
||||||
pub tokens: Vec<TokenEntry>,
|
pub tokens: Vec<TokenEntry>,
|
||||||
/// Every machine this server can run something on, and what each of
|
|
||||||
/// them can run. See [`SetupConfig`].
|
|
||||||
pub setups: Vec<SetupConfig>,
|
pub setups: Vec<SetupConfig>,
|
||||||
pub sessions: Vec<SessionConfig>,
|
pub sessions: Vec<SessionConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A machine, and the things it can run.
|
/// A machine, and the things it can run.
|
||||||
///
|
///
|
||||||
/// This is the unit a session is spawned against: pick a setup, then one
|
/// This is the unit a session is spawned against. Grouping providers under the
|
||||||
/// of its providers. Grouping them this way is what stops the spawn
|
/// machine they exist on is what stops the spawn screen offering combinations
|
||||||
/// screen offering combinations that cannot work -- a provider only
|
/// that cannot work; the previous model let any provider be paired with any
|
||||||
/// exists on a machine where that program is installed, and the previous
|
/// host and offered the whole cross-product.
|
||||||
/// model, which let any provider be paired with any host, offered the
|
|
||||||
/// whole cross-product including the impossible parts of it.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct SetupConfig {
|
pub struct SetupConfig {
|
||||||
@@ -53,15 +46,12 @@ pub struct SetupConfig {
|
|||||||
/// renaming a machine on the phone does not orphan its sessions --
|
/// renaming a machine on the phone does not orphan its sessions --
|
||||||
/// which is the whole reason the two are separate fields.
|
/// which is the whole reason the two are separate fields.
|
||||||
pub id: String,
|
pub id: String,
|
||||||
/// The label a person reads and may edit.
|
|
||||||
pub name: String,
|
pub name: String,
|
||||||
/// How to reach it, absent for this machine. A setup with no `ssh` is
|
/// How to reach it, absent for this machine.
|
||||||
/// where the server itself runs.
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub ssh: Option<SshConfig>,
|
pub ssh: Option<SshConfig>,
|
||||||
/// What can be spawned here. Names are unique within a setup, and only
|
/// What can be spawned here. Names are unique within a setup, and only
|
||||||
/// within it: two machines may each have a `claude-cli`, which is the
|
/// within it: two machines may each have a `claude-cli`, which is the point.
|
||||||
/// point.
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub providers: Vec<ProviderConfig>,
|
pub providers: Vec<ProviderConfig>,
|
||||||
}
|
}
|
||||||
@@ -88,12 +78,10 @@ pub struct ProviderConfig {
|
|||||||
pub models: Vec<String>,
|
pub models: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How to reach a setup that isn't this machine, with the system `ssh`
|
/// How to reach a setup that isn't this machine, with the system `ssh` client
|
||||||
/// client -- so `~/.ssh/config`, agents, and jump hosts all keep working,
|
/// -- so `~/.ssh/config`, agents and jump hosts all keep working, and there is
|
||||||
/// and there is one place to configure connections (PLAN.md, rule 23).
|
/// one place to configure connections. A remote session is the identical
|
||||||
///
|
/// command with `ssh host …` in front, and nothing downstream knows.
|
||||||
/// A remote session is the identical command with `ssh host …` in front,
|
|
||||||
/// and nothing downstream of the spawn knows the difference.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct SshConfig {
|
pub struct SshConfig {
|
||||||
@@ -107,59 +95,49 @@ pub struct SshConfig {
|
|||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
pub options: Vec<String>,
|
pub options: Vec<String>,
|
||||||
/// Where a file attached from the phone is put on this machine so the
|
/// Where a file attached from the phone is put on this machine so the
|
||||||
/// session can read it. Absent means the session's own working
|
/// session can read it. Absent means the session's own working directory,
|
||||||
/// directory, or the login home for a session that has none. A `~`
|
/// or the login home for a session that has none. A `~` prefix is the
|
||||||
/// prefix is the remote home.
|
/// remote home.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub attachments_dir: Option<PathBuf>,
|
pub attachments_dir: Option<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Which translator runs a session. A new one is a new driver behind the
|
/// Which translator runs a session. A new one is a new driver behind the same
|
||||||
/// same trait -- never a branch in shared code.
|
/// trait -- never a branch in shared code.
|
||||||
///
|
///
|
||||||
/// Snake case, which is both Rust's and RON's: this is written into a
|
/// Snake case, which is both Rust's and RON's: this is written into a config a
|
||||||
/// config a person edits by hand, and a hyphen is not a RON identifier, so
|
/// person edits by hand, and a hyphen is not a RON identifier, so kebab case
|
||||||
/// kebab case cost the file a `kind: r#claude-cli` escape to say a name
|
/// cost the file a `kind: r#claude-cli` escape. The same string is what the
|
||||||
/// nobody would type that way. The same string is what the phone compares
|
/// phone compares against, so the two move together.
|
||||||
/// against (`SpawnScreen.kt`), so the two move together.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum DriverKind {
|
pub enum DriverKind {
|
||||||
/// The phase-1 fake: echoes messages back as streamed events. Proves
|
/// The fake driver: echoes messages back as streamed events, proving the
|
||||||
/// the pipe (spawn, SSE, transcript cursors, questions) with no AI
|
/// pipe with no AI involved. Always available as a built-in provider.
|
||||||
/// involved, and stays useful as a connectivity check that costs no
|
|
||||||
/// tokens. Always available as a built-in provider.
|
|
||||||
Echo,
|
Echo,
|
||||||
/// A GGUF model served by llama.cpp's `llama-server` (see
|
/// A GGUF model served by llama.cpp's `llama-server`. The model is one this
|
||||||
/// `session::llama`). The model itself is one this machine has
|
/// machine has downloaded; the provider's command is the server binary.
|
||||||
/// downloaded; the provider's command is the server binary.
|
|
||||||
LlamaCpp,
|
LlamaCpp,
|
||||||
/// The Claude Code CLI over stream-json (see `session::claude`).
|
/// The Claude Code CLI over stream-json. Named for the CLI specifically:
|
||||||
/// Named for the CLI specifically: bare "claude" would suggest the
|
/// bare "claude" would suggest the credit-billed API, which this is not.
|
||||||
/// credit-billed API, which this is not.
|
|
||||||
ClaudeCli,
|
ClaudeCli,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DriverKind {
|
impl DriverKind {
|
||||||
/// The longest edge, in pixels, an image should have when it reaches
|
/// The longest edge, in pixels, an image should have when it reaches this
|
||||||
/// this kind of session -- `None` where nothing here has a limit worth
|
/// kind of session -- `None` where nothing here has a limit worth enforcing.
|
||||||
/// enforcing.
|
|
||||||
///
|
///
|
||||||
/// Reported to the phone rather than applied here, so the bytes are made
|
/// Reported to the phone rather than applied here, so the bytes are made
|
||||||
/// small before they cross the tunnel instead of after: a modern phone
|
/// small before they cross the tunnel instead of after: a modern phone photo
|
||||||
/// photo is several megabytes and twelve megapixels, and every one of
|
/// is several megabytes, and every one of them was being uploaded over
|
||||||
/// those bytes was being uploaded over WireGuard only to be rejected at
|
/// WireGuard only to be rejected at the other end. What decides the number
|
||||||
/// the other end. What decides the number is the provider, which is why
|
/// is the provider, which is why it lives beside the kind rather than in the
|
||||||
/// it lives beside the kind rather than in the app -- a phone that knew
|
/// app.
|
||||||
/// each provider's limits would be a second place to update when one
|
|
||||||
/// changes.
|
|
||||||
///
|
///
|
||||||
/// 1568 for the Claude CLI because that is the longest edge the API
|
/// 1568 for the Claude CLI because that is the longest edge the API itself
|
||||||
/// itself resizes to; anything larger is charged the same and spends the
|
/// resizes to; anything larger is charged the same and spends the upload for
|
||||||
/// upload for nothing, and far larger is refused outright, which is what
|
/// nothing, and far larger is refused outright -- which is what "sending an
|
||||||
/// "sending an image is broken" turned out to be. The others take images
|
/// image is broken" turned out to be.
|
||||||
/// through no path that cares, so they get no limit rather than a made-up
|
|
||||||
/// one.
|
|
||||||
pub fn max_image_edge(self) -> Option<u32> {
|
pub fn max_image_edge(self) -> Option<u32> {
|
||||||
match self {
|
match self {
|
||||||
DriverKind::ClaudeCli => Some(1568),
|
DriverKind::ClaudeCli => Some(1568),
|
||||||
@@ -167,21 +145,17 @@ impl DriverKind {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether the conversation exists outside this app, so that deleting
|
/// Whether the conversation exists outside this app, so that deleting the
|
||||||
/// the session here does not end it.
|
/// session here does not end it.
|
||||||
///
|
///
|
||||||
/// The Claude Code CLI owns its own transcript under
|
/// The Claude Code CLI owns its own transcript and is resumable from it
|
||||||
/// `~/.claude/projects/` and is resumable from it whatever started
|
/// whatever started it, so a session this app spawned is every bit as
|
||||||
/// it -- so a session this app spawned is every bit as recoverable as
|
/// recoverable as one it imported. Echo has nothing to keep, and a llama
|
||||||
/// one it imported, and the difference between those two is only how
|
/// session's conversation is folded out of *this* app's transcript.
|
||||||
/// it got here. Echo has nothing to keep, and a llama session's
|
|
||||||
/// conversation is folded out of *this* app's transcript, so for both
|
|
||||||
/// of those a delete is the end of it.
|
|
||||||
///
|
///
|
||||||
/// Asked before warning somebody that a deletion cannot be undone,
|
/// Asked before warning somebody that a deletion cannot be undone, which is
|
||||||
/// which is the one sentence that has to be true: said of a session
|
/// the one sentence that has to be true: said of a session that can in fact
|
||||||
/// that can in fact be brought back, it spends the credibility the
|
/// be brought back, it spends the credibility the warning needs.
|
||||||
/// warning needs on the sessions where it is real.
|
|
||||||
pub fn keeps_own_transcript(self) -> bool {
|
pub fn keeps_own_transcript(self) -> bool {
|
||||||
match self {
|
match self {
|
||||||
Self::ClaudeCli => true,
|
Self::ClaudeCli => true,
|
||||||
@@ -193,11 +167,9 @@ impl DriverKind {
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct TokenEntry {
|
pub struct TokenEntry {
|
||||||
/// Which device this token belongs to, for the human rotating it.
|
|
||||||
pub name: String,
|
pub name: String,
|
||||||
/// Hex SHA-256 of the token. A plain hash is enough: the token is 256
|
/// Hex SHA-256 of the token. A plain hash is enough: the token is 256 bits
|
||||||
/// bits from the OS CSPRNG, so there is nothing to dictionary-attack
|
/// from the OS CSPRNG, so there is nothing to dictionary-attack.
|
||||||
/// and no stretching needed.
|
|
||||||
pub sha256: String,
|
pub sha256: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,73 +178,56 @@ pub struct TokenEntry {
|
|||||||
pub struct SessionConfig {
|
pub struct SessionConfig {
|
||||||
/// Stable identifier; names the session's directory and its routes.
|
/// Stable identifier; names the session's directory and its routes.
|
||||||
pub id: String,
|
pub id: String,
|
||||||
/// Id of the [`SetupConfig`] this session runs on -- the id, not the
|
/// Id of the [`SetupConfig`] this session runs on -- the id, not the label,
|
||||||
/// label, so the machine can be renamed without losing its sessions.
|
/// so the machine can be renamed without losing its sessions.
|
||||||
pub setup: String,
|
pub setup: String,
|
||||||
/// Name of the provider within that setup. Both stored by name rather
|
/// Name of the provider within that setup. Both stored by name rather than
|
||||||
/// than resolved, so an edited setup (a new command path, another
|
/// resolved, so an edited setup takes effect on the next relaunch; a session
|
||||||
/// model) takes effect on the next relaunch; a session whose setup or
|
/// whose setup or provider is gone reports as exited and can still be
|
||||||
/// provider is gone reports as exited and can still be deleted.
|
/// deleted.
|
||||||
pub provider: String,
|
pub provider: String,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub model: Option<String>,
|
pub model: Option<String>,
|
||||||
/// Working directory the session's process runs in.
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub cwd: Option<PathBuf>,
|
pub cwd: Option<PathBuf>,
|
||||||
/// Claude permission mode chosen at spawn. Meaningless for other
|
/// Claude permission mode chosen at spawn. Kept as a string because it is
|
||||||
/// kinds, and kept as a string because it is passed straight to the
|
/// passed straight to `--permission-mode` rather than interpreted here, so
|
||||||
/// CLI's `--permission-mode` rather than interpreted here -- so the
|
/// the CLI stays the one authority on which modes exist.
|
||||||
/// CLI stays the one authority on which modes exist, and a new one
|
|
||||||
/// needs no change on this side.
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub permission_mode: Option<String>,
|
pub permission_mode: Option<String>,
|
||||||
/// Settings the driver interprets, chosen at spawn.
|
/// Settings the driver interprets, chosen at spawn.
|
||||||
///
|
///
|
||||||
/// Deliberately untyped here: what a temperature or a context size
|
/// Deliberately untyped: what a temperature or a context size means is the
|
||||||
/// means is the driver's business, and giving this schema a field per
|
/// driver's business, and a field per driver is how a shared model starts
|
||||||
/// driver is how a shared model starts carrying one dialect's
|
/// carrying one dialect's vocabulary. `permission_mode` above predates this
|
||||||
/// vocabulary. `permission_mode` above predates this and should fold
|
/// and should fold into it. BTreeMap so the file's order is stable.
|
||||||
/// into it. A map rather than a list so the phone can send exactly
|
|
||||||
/// what a person changed, and BTreeMap so the file's order is stable
|
|
||||||
/// across writes.
|
|
||||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||||
pub params: BTreeMap<String, String>,
|
pub params: BTreeMap<String, String>,
|
||||||
/// Whether a phone should be told when this session wants attention.
|
/// Whether a phone should be told when this session wants attention.
|
||||||
///
|
///
|
||||||
/// Stored here rather than on the phone because it is a fact about the
|
/// Stored here rather than on the phone because it is a fact about the
|
||||||
/// session: one that runs unattended overnight should be quiet on
|
/// session: one that runs unattended overnight should be quiet on every
|
||||||
/// every device, and answering that question again on each new phone
|
/// device.
|
||||||
/// is how two devices come to disagree about which sessions matter.
|
|
||||||
///
|
///
|
||||||
/// Defaults to on, and on for a config written before this field
|
/// Defaults to on. Silent-unless-asked makes the feature invisible to
|
||||||
/// existed. The alternative -- silent unless asked -- makes the
|
/// anyone who does not go looking, and a notification nobody wanted is
|
||||||
/// feature invisible to anyone who does not go looking for it, and a
|
/// turned off in one tap where one that never arrived is not diagnosable.
|
||||||
/// notification nobody wanted is turned off in one tap where one that
|
|
||||||
/// never arrived is not diagnosable at all.
|
|
||||||
#[serde(default = "notify_default")]
|
#[serde(default = "notify_default")]
|
||||||
pub notify: bool,
|
pub notify: bool,
|
||||||
/// Whether this session's process is stopped when the server exits,
|
/// Whether this session's process is stopped when the server exits, instead
|
||||||
/// instead of being left running for the next start to adopt.
|
/// of being left running for the next start to adopt.
|
||||||
///
|
///
|
||||||
/// A fact about the session rather than about the run that spawned it,
|
/// A fact about the session rather than about the run that spawned it, which
|
||||||
/// which is why it is persisted: whichever server is running when the
|
/// is why it is persisted: whichever server is running when the time comes
|
||||||
/// time comes is the one that has to act on it, and a session nobody
|
/// is the one that has to act on it.
|
||||||
/// meant to keep should not depend on the same server still being up
|
|
||||||
/// to clean it away.
|
|
||||||
///
|
///
|
||||||
/// Written by a server started with `--throwaway-sessions`, which is
|
/// Written by a server started with `--throwaway-sessions`, the default in a
|
||||||
/// the default in a debug build. A session spawned while testing is
|
/// debug build. Under the ordinary rule a test session's `claude` outlives
|
||||||
/// one nobody means to keep, and under the ordinary rule its `claude`
|
/// every server that ever knew about it -- twelve accumulated on this
|
||||||
/// outlives every server that ever knew about it -- twelve of them
|
/// machine in a day. Absent means false.
|
||||||
/// accumulated on this machine in a day, each holding a conversation
|
|
||||||
/// open.
|
|
||||||
///
|
|
||||||
/// Absent means false: every session written before this existed, and
|
|
||||||
/// every one spawned by a release build.
|
|
||||||
#[serde(default, skip_serializing_if = "not_set")]
|
#[serde(default, skip_serializing_if = "not_set")]
|
||||||
pub throwaway: bool,
|
pub throwaway: bool,
|
||||||
/// Epoch seconds when the session was spawned.
|
|
||||||
pub created: f64,
|
pub created: f64,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,29 +236,25 @@ fn notify_default() -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Keeps the ordinary case out of the file entirely -- see
|
/// Keeps the ordinary case out of the file entirely -- see
|
||||||
/// [`SessionConfig::throwaway`], which is false for every session a
|
/// [`SessionConfig::throwaway`].
|
||||||
/// production build writes.
|
|
||||||
fn not_set(flag: &bool) -> bool {
|
fn not_set(flag: &bool) -> bool {
|
||||||
!*flag
|
!*flag
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The name of the echo provider, and of the setup this machine gets on
|
/// The name of the echo provider, and of the setup this machine gets on first
|
||||||
/// first run.
|
/// run.
|
||||||
///
|
///
|
||||||
/// Echo is seeded into the config rather than conjured at read time the
|
/// Echo is seeded into the config rather than conjured at read time. An
|
||||||
/// way it used to be. An implicit provider is one a person cannot see in
|
/// implicit provider is one a person cannot see in the file or edit from the
|
||||||
/// the file or edit from the phone, and the point of this app is that
|
/// phone; if somebody deletes it, that was a choice.
|
||||||
/// configuration is visible and editable; if somebody deletes it, that was
|
|
||||||
/// a choice.
|
|
||||||
pub const ECHO_PROVIDER: &str = "echo";
|
pub const ECHO_PROVIDER: &str = "echo";
|
||||||
pub const LOCAL_SETUP: &str = "this machine";
|
pub const LOCAL_SETUP: &str = "this machine";
|
||||||
/// The id of the setup a fresh install seeds. Fixed rather than random so
|
/// The id of the setup a fresh install seeds. Fixed rather than random so a
|
||||||
/// a hand-written config can name it without looking one up.
|
/// hand-written config can name it without looking one up.
|
||||||
pub const LOCAL_SETUP_ID: &str = "local";
|
pub const LOCAL_SETUP_ID: &str = "local";
|
||||||
|
|
||||||
/// Where `ai-server --enroll-link` leaves a token for the running server
|
/// Where `ai-server --enroll-link` leaves a token for the running server to
|
||||||
/// to adopt: beside the config, since it is config in transit. See
|
/// adopt: beside the config, since it is config in transit.
|
||||||
/// `wg_app_link::enroll::spool_pending`.
|
|
||||||
pub fn pending_enrollments_dir(config_path: &Path) -> PathBuf {
|
pub fn pending_enrollments_dir(config_path: &Path) -> PathBuf {
|
||||||
config_path.with_file_name("pending-enrollments")
|
config_path.with_file_name("pending-enrollments")
|
||||||
}
|
}
|
||||||
@@ -313,22 +264,19 @@ impl Config {
|
|||||||
self.setups.iter().find(|setup| setup.id == id)
|
self.setups.iter().find(|setup| setup.id == id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A setup by the label a person sees, for messages and for the one
|
/// A setup by the label a person sees, for messages and for the one place a
|
||||||
/// place a name still arrives from outside: nothing else should look
|
/// name still arrives from outside. Nothing else should look one up this
|
||||||
/// one up this way, since labels are editable and ids are not.
|
/// way, since labels are editable and ids are not.
|
||||||
pub fn setup_named(&self, name: &str) -> Option<&SetupConfig> {
|
pub fn setup_named(&self, name: &str) -> Option<&SetupConfig> {
|
||||||
self.setups.iter().find(|setup| setup.name == name)
|
self.setups.iter().find(|setup| setup.name == name)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// This machine, offering whatever was found on it.
|
/// This machine, offering whatever was found on it.
|
||||||
///
|
///
|
||||||
/// The providers are passed in rather than written here because they
|
/// The providers are passed in rather than written here because they have to
|
||||||
/// have to be *discovered*: a hardcoded list is a claim about what is
|
/// be *discovered*: a hardcoded list is a claim about what is installed, and
|
||||||
/// installed, and this one was wrong -- every fresh install asserted a
|
/// this one was wrong -- every fresh install asserted a `claude-cli`
|
||||||
/// `claude-cli` provider whether or not `claude` existed, which on a
|
/// provider whether or not `claude` existed.
|
||||||
/// machine without it is a spawn option that cannot work and a
|
|
||||||
/// statement the server never checked. Providers are discovered by
|
|
||||||
/// asking the machine, here exactly as for any other setup.
|
|
||||||
pub fn seed(providers: Vec<ProviderConfig>) -> SetupConfig {
|
pub fn seed(providers: Vec<ProviderConfig>) -> SetupConfig {
|
||||||
SetupConfig {
|
SetupConfig {
|
||||||
id: LOCAL_SETUP_ID.to_string(),
|
id: LOCAL_SETUP_ID.to_string(),
|
||||||
@@ -338,12 +286,9 @@ impl Config {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The one provider that needs no discovery, and the floor to fall
|
/// The one provider that needs no discovery, and the floor to fall back to
|
||||||
/// back to when discovery itself fails.
|
/// when discovery itself fails. Echo runs in-process, so it exists exactly
|
||||||
///
|
/// where this server does and nowhere else.
|
||||||
/// Echo runs in-process, so it exists exactly where this server does
|
|
||||||
/// and nowhere else -- there is nothing to probe for, and offering it
|
|
||||||
/// on a remote machine would be a choice that changes nothing.
|
|
||||||
pub fn echo_provider() -> ProviderConfig {
|
pub fn echo_provider() -> ProviderConfig {
|
||||||
ProviderConfig {
|
ProviderConfig {
|
||||||
name: ECHO_PROVIDER.to_string(),
|
name: ECHO_PROVIDER.to_string(),
|
||||||
@@ -357,8 +302,8 @@ impl Config {
|
|||||||
match std::fs::read_to_string(path) {
|
match std::fs::read_to_string(path) {
|
||||||
Ok(text) => format::parse(&text)
|
Ok(text) => format::parse(&text)
|
||||||
.with_context(|| format!("{} is not valid config RON", path.display())),
|
.with_context(|| format!("{} is not valid config RON", path.display())),
|
||||||
// A first run has no config -- the normal starting state; a
|
// A first run has no config -- the normal starting state; a token is
|
||||||
// token is generated and saved on that first start.
|
// generated and saved on that first start.
|
||||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
|
||||||
warn_about_a_config_left_behind(path);
|
warn_about_a_config_left_behind(path);
|
||||||
Ok(Self::default())
|
Ok(Self::default())
|
||||||
@@ -369,12 +314,10 @@ impl Config {
|
|||||||
|
|
||||||
/// Writes the config, owner-readable only.
|
/// Writes the config, owner-readable only.
|
||||||
///
|
///
|
||||||
/// The token hashes here are verifiers, not secrets -- a 256-bit
|
/// The token hashes here are verifiers rather than secrets, but the file
|
||||||
/// random token can't be recovered from its SHA-256 -- but the file
|
/// also names every host this backend can reach and every session it is
|
||||||
/// also names every host this backend can reach and every session it
|
/// running. The mode is set on the temporary file *before* the rename, so
|
||||||
/// is running, which is nobody else's business on a shared machine.
|
/// the config is never briefly world-readable at its real path.
|
||||||
/// The mode is set on the temporary file *before* the rename, so the
|
|
||||||
/// config is never briefly world-readable at its real path.
|
|
||||||
pub fn save(&self, path: &Path) -> Result<()> {
|
pub fn save(&self, path: &Path) -> Result<()> {
|
||||||
format::write(path, self)
|
format::write(path, self)
|
||||||
}
|
}
|
||||||
|
|||||||
+104
-130
@@ -1,71 +1,58 @@
|
|||||||
//! Reading and changing files on the machine a setup names.
|
//! Reading and changing files on the machine a setup names.
|
||||||
//!
|
//!
|
||||||
//! Every operation here is one small POSIX shell script handed to
|
//! Every operation here is one small POSIX shell script handed to `Transport`,
|
||||||
//! `Transport`, exactly the way `setups::discover` and `import::list`
|
//! the way `setups::discover` and `import::list` already ask a machine a
|
||||||
//! already ask a machine a question. That is what makes the local and the
|
//! question. That is what makes the local and the ssh case one implementation:
|
||||||
//! ssh case one implementation: a second one written against `std::fs`
|
//! a second one written against `std::fs` would be the one that gets tested,
|
||||||
//! would be the one that gets tested, and the remote half -- the ordering
|
//! and the remote half -- the ordering of entries, what a symlink reports, how
|
||||||
//! of entries, what a symlink reports, how a permission error reads --
|
//! a permission error reads -- would drift until it shipped broken. The cost is
|
||||||
//! would drift until it shipped broken. The cost is an `sh` process per
|
//! an `sh` process per operation here, which is under a millisecond.
|
||||||
//! operation on this machine, which is under a millisecond.
|
|
||||||
//!
|
//!
|
||||||
//! The scripts assume GNU coreutils and findutils (`find -printf`,
|
//! The scripts assume GNU coreutils and findutils, which is what
|
||||||
//! `stat -c`, `sha256sum`, `chmod --reference`), which is what
|
//! `session::import` already assumes. A machine without them fails with that
|
||||||
//! `session::import` already assumes and what both machines here run. One
|
//! tool's own message, which names what is missing.
|
||||||
//! without them fails with that tool's own message, which names what is
|
|
||||||
//! missing.
|
|
||||||
//!
|
//!
|
||||||
//! **The phone names a path, and that is deliberate** -- see PLAN.md's
|
//! **The phone names a path, and that is deliberate** -- see PLAN.md's Security
|
||||||
//! Security section. The enrolled token already spawns an agent in any
|
//! section. What is *not* given up: no route here accepts a command. Listing,
|
||||||
//! directory on any machine a setup names, and that agent reads and writes
|
//! reading and writing are the fixed scripts below, and the phone chooses only
|
||||||
//! every file its user can; this is a shorter path to authority the token
|
//! the path and the bytes.
|
||||||
//! already holds. What is *not* given up: no route here accepts a command.
|
|
||||||
//! Listing, reading and writing are the fixed scripts below, and the phone
|
|
||||||
//! chooses only the path and the bytes.
|
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
use crate::session::transport::{Input, Launch, Transport};
|
use crate::session::transport::{Input, Launch, Transport};
|
||||||
|
|
||||||
/// The most of a file that crosses the tunnel, in bytes.
|
/// The most of a file that crosses the tunnel, in bytes. Checked on the far
|
||||||
///
|
/// machine before anything reads the file, so a 2 GB log costs a `stat` rather
|
||||||
/// Checked on the far machine before anything reads the file, so a 2 GB
|
/// than a transfer. A file over it is [`FileRead::TooBig`] with its size,
|
||||||
/// log costs a `stat` rather than a transfer. A file over it is reported
|
/// because "we did not read this" and "this is empty" must not look the same.
|
||||||
/// as [`FileRead::TooBig`] with its size, because "we did not read this"
|
|
||||||
/// and "this is empty" must not look the same on the phone.
|
|
||||||
pub const FILE_LIMIT: u64 = 1024 * 1024;
|
pub const FILE_LIMIT: u64 = 1024 * 1024;
|
||||||
|
|
||||||
/// The prelude every script here starts with: the path arrives as `$1`,
|
/// The prelude every script here starts with: the path arrives as `$1`, and
|
||||||
/// and this is where a leading `~` becomes that machine's own home.
|
/// this is where a leading `~` becomes that machine's own home.
|
||||||
///
|
///
|
||||||
/// The path is a **positional argument** and never text spliced into the
|
/// The path is a **positional argument** and never text spliced into the script
|
||||||
/// script -- the rule `import::find` follows with `"$1"`, for the reason
|
/// -- the rule `import::find` follows, for the reason `ssh::quote` exists: a
|
||||||
/// `ssh::quote` exists: a path is attacker-adjacent input in a server
|
/// path is attacker-adjacent input in a server whose job is running commands,
|
||||||
/// whose job is running commands, and interpolated it would be syntax
|
/// and interpolated it would be syntax rather than data.
|
||||||
/// rather than data.
|
|
||||||
///
|
///
|
||||||
/// `~` is the one character that costs something for it. A shell expands a
|
/// `~` is the one character that costs something for it. A shell expands a
|
||||||
/// tilde in *text*, so a path handed over as an argument arrives with a
|
/// tilde in *text*, so a path handed over as an argument arrives with a literal
|
||||||
/// literal one; expanding it here, once, gives it the same meaning
|
/// one; expanding it here gives it the same meaning `ssh::quote_path` gives it
|
||||||
/// `ssh::quote_path` and `ssh::expand_home` give it everywhere else, and
|
/// everywhere else, and it is the *far* machine's `$HOME`. `~user` stays
|
||||||
/// it is the *far* machine's `$HOME` -- the only one that could be right.
|
/// literal and fails with the shell's own message.
|
||||||
/// `~user` stays literal here too, and fails with the shell's own message.
|
|
||||||
///
|
///
|
||||||
/// Everything below uses `$p` for the path and `$2` for whatever else it
|
/// Everything below uses `$p` for the path and `$2` for whatever else.
|
||||||
/// was given.
|
|
||||||
const PATH_PRELUDE: &str = r#"p=$1; case $p in "~") p=$HOME;; "~/"*) p=$HOME/${p#"~/"};; esac; "#;
|
const PATH_PRELUDE: &str = r#"p=$1; case $p in "~") p=$HOME;; "~/"*) p=$HOME/${p#"~/"};; esac; "#;
|
||||||
|
|
||||||
/// What a directory turned out to be, and what is in it.
|
/// What a directory turned out to be, and what is in it.
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct Listing {
|
pub struct Listing {
|
||||||
/// `pwd -P` of the directory that was listed.
|
/// `pwd -P` of the directory that was listed. Answered by the machine
|
||||||
///
|
/// rather than worked out here, so the phone navigates on a resolved
|
||||||
/// Answered by the machine rather than worked out here, so the phone
|
/// absolute path: the parent of one of these is a string operation, and a
|
||||||
/// navigates on a resolved absolute path: the parent of one of these
|
/// `~` a session was spawned with is shown as what it turned out to be.
|
||||||
/// is a string operation, and a `~` a session was spawned with is
|
|
||||||
/// shown as what it turned out to be.
|
|
||||||
pub path: String,
|
pub path: String,
|
||||||
pub entries: Vec<Entry>,
|
pub entries: Vec<Entry>,
|
||||||
}
|
}
|
||||||
@@ -74,14 +61,13 @@ pub struct Listing {
|
|||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct Entry {
|
pub struct Entry {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
/// What tapping it does, which for a symlink is decided by its
|
/// What tapping it does, which for a symlink is decided by its *target* --
|
||||||
/// *target* -- a link to a directory navigates.
|
/// a link to a directory navigates.
|
||||||
pub kind: EntryKind,
|
pub kind: EntryKind,
|
||||||
pub size: u64,
|
pub size: u64,
|
||||||
/// Seconds since the epoch.
|
|
||||||
pub modified: i64,
|
pub modified: i64,
|
||||||
/// Whether the entry itself is a symlink, whatever [`Entry::kind`]
|
/// Whether the entry itself is a symlink, whatever [`Entry::kind`] says
|
||||||
/// says its target is.
|
/// its target is.
|
||||||
pub link: bool,
|
pub link: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,18 +76,18 @@ pub struct Entry {
|
|||||||
pub enum EntryKind {
|
pub enum EntryKind {
|
||||||
Directory,
|
Directory,
|
||||||
File,
|
File,
|
||||||
/// A socket, a device, a fifo -- and a symlink whose target is missing
|
/// A socket, a device, a fifo -- and a symlink whose target is missing or
|
||||||
/// or loops, which `find` reports the same way. Shown, because a
|
/// loops, which `find` reports the same way. Shown, because a directory
|
||||||
/// directory that hid what it held would be lying about being empty.
|
/// that hid what it held would be lying about being empty.
|
||||||
Other,
|
Other,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What reading a file produced -- four answers, not content-or-error.
|
/// What reading a file produced -- four answers, not content-or-error.
|
||||||
///
|
///
|
||||||
/// A binary file drawn as text and a big file cut off silently are both
|
/// A binary file drawn as text and a big file cut off silently are both wrong
|
||||||
/// wrong in ways the reader cannot see, and "couldn't read it" must not
|
/// in ways the reader cannot see, and "couldn't read it" must not look like
|
||||||
/// look like "it is empty". A file that is genuinely empty is
|
/// "it is empty". A genuinely empty file is [`FileRead::Text`] with nothing in
|
||||||
/// [`FileRead::Text`] with nothing in it, which is what it is.
|
/// it, which is what it is.
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||||
pub enum FileRead {
|
pub enum FileRead {
|
||||||
@@ -114,8 +100,8 @@ pub enum FileRead {
|
|||||||
},
|
},
|
||||||
/// Not UTF-8. Its size is reported; nothing is shown.
|
/// Not UTF-8. Its size is reported; nothing is shown.
|
||||||
Binary { size: u64, modified: i64 },
|
Binary { size: u64, modified: i64 },
|
||||||
/// Over [`FILE_LIMIT`]. Its size is reported, so the reader knows what
|
/// Over [`FILE_LIMIT`]. Its size is reported, so the reader knows what they
|
||||||
/// they are looking at rather than only that they cannot have it.
|
/// are looking at rather than only that they cannot have it.
|
||||||
TooBig { size: u64, modified: i64 },
|
TooBig { size: u64, modified: i64 },
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,18 +115,16 @@ pub struct Written {
|
|||||||
pub sha256: String,
|
pub sha256: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The exit code the write script uses for "this is not the file you
|
/// The exit code the write script uses for "this is not the file you read",
|
||||||
/// read", which the route turns into a 409. Distinct from every other
|
/// which the route turns into a 409. Distinct from every other failure, which
|
||||||
/// failure, which is a message from the machine.
|
/// is a message from the machine.
|
||||||
pub const STALE: i32 = 3;
|
pub const STALE: i32 = 3;
|
||||||
|
|
||||||
/// A path the phone may name: absolute, or home-relative on that machine.
|
/// A path the phone may name: absolute, or home-relative on that machine.
|
||||||
///
|
///
|
||||||
/// Shared with `POST /sessions/{id}/cwd`, which asks the same question for
|
/// Shared with `POST /sessions/{id}/cwd`, which asks the same question for the
|
||||||
/// the same reason -- a relative path is relative to something nobody
|
/// same reason -- a relative path is relative to something nobody looking at
|
||||||
/// looking at the screen can see, so it is refused rather than resolved
|
/// the screen can see, so it is refused rather than resolved against a guess.
|
||||||
/// against a guess. Returns the path with the whitespace a phone keyboard
|
|
||||||
/// adds taken off.
|
|
||||||
pub fn check_path(path: &str) -> Result<String> {
|
pub fn check_path(path: &str) -> Result<String> {
|
||||||
let path = path.trim();
|
let path = path.trim();
|
||||||
if path.is_empty() {
|
if path.is_empty() {
|
||||||
@@ -160,8 +144,8 @@ fn launch(script: String, path: &str, extra: Option<&str>) -> Launch {
|
|||||||
let mut args = vec![
|
let mut args = vec![
|
||||||
"-c".to_string(),
|
"-c".to_string(),
|
||||||
script,
|
script,
|
||||||
// `$0`, which is what `sh` names itself in a message about the
|
// `$0`, which is what `sh` names itself in a message about the script;
|
||||||
// script; the path is `$1`.
|
// the path is `$1`.
|
||||||
"sh".to_string(),
|
"sh".to_string(),
|
||||||
path.to_string(),
|
path.to_string(),
|
||||||
];
|
];
|
||||||
@@ -169,11 +153,10 @@ fn launch(script: String, path: &str, extra: Option<&str>) -> Launch {
|
|||||||
Launch::new("sh", args, None)
|
Launch::new("sh", args, None)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Everything in `path`, and what `path` resolved to.
|
/// Everything in `path`, and what `path` resolved to. Entries are separated by
|
||||||
///
|
/// `\0` and their fields by `\t`, so a filename with a newline or a tab in it
|
||||||
/// Entries are separated by `\0` and their fields by `\t`, so a filename
|
/// survives -- both are legal, and a listing that lost one would quietly show
|
||||||
/// with a newline or a tab in it survives -- both are legal, and a listing
|
/// the wrong thing.
|
||||||
/// that lost one would quietly show the wrong thing.
|
|
||||||
pub async fn list(transport: &Transport, path: &str) -> Result<Listing> {
|
pub async fn list(transport: &Transport, path: &str) -> Result<Listing> {
|
||||||
let script = format!(
|
let script = format!(
|
||||||
"{PATH_PRELUDE}cd -- \"$p\" && pwd -P && \
|
"{PATH_PRELUDE}cd -- \"$p\" && pwd -P && \
|
||||||
@@ -193,11 +176,9 @@ pub async fn list(transport: &Transport, path: &str) -> Result<Listing> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The `find` output above, as rows.
|
/// The `find` output above, as rows. A record without all five fields is
|
||||||
///
|
/// dropped rather than guessed at: it can only come from a `find` that printed
|
||||||
/// A record that does not have all five fields is dropped rather than
|
/// something else, and half a row is worse than no row.
|
||||||
/// guessed at: it can only come from a `find` that printed something else,
|
|
||||||
/// and half a row is worse than no row.
|
|
||||||
fn parse_entries(text: &str) -> Vec<Entry> {
|
fn parse_entries(text: &str) -> Vec<Entry> {
|
||||||
text.split('\0')
|
text.split('\0')
|
||||||
.filter(|record| !record.is_empty())
|
.filter(|record| !record.is_empty())
|
||||||
@@ -208,8 +189,7 @@ fn parse_entries(text: &str) -> Vec<Entry> {
|
|||||||
let own = fields.next()?;
|
let own = fields.next()?;
|
||||||
let target = fields.next()?;
|
let target = fields.next()?;
|
||||||
let size = fields.next()?.parse().ok()?;
|
let size = fields.next()?.parse().ok()?;
|
||||||
// `%T@` is seconds with a fractional part; the phone shows a
|
// `%T@` is seconds with a fractional part; the phone shows a date.
|
||||||
// date, so the fraction is dropped rather than carried.
|
|
||||||
let modified = fields.next()?.split('.').next()?.parse().ok()?;
|
let modified = fields.next()?.split('.').next()?.parse().ok()?;
|
||||||
let name = fields.next()?;
|
let name = fields.next()?;
|
||||||
Some(Entry {
|
Some(Entry {
|
||||||
@@ -229,14 +209,13 @@ fn parse_entries(text: &str) -> Vec<Entry> {
|
|||||||
|
|
||||||
/// One file's content, or the reason there is none to show.
|
/// One file's content, or the reason there is none to show.
|
||||||
///
|
///
|
||||||
/// The size is checked on the far machine *before* anything reads the
|
/// The size is checked on the far machine *before* anything reads the file, so
|
||||||
/// file, so a file over [`FILE_LIMIT`] costs a `stat` rather than a
|
/// a file over [`FILE_LIMIT`] costs a `stat` rather than a transfer. `stat -L`
|
||||||
/// transfer. `stat -L` and `sha256sum` both follow symlinks, as `cat`
|
/// and `sha256sum` both follow symlinks, as `cat` does.
|
||||||
/// does, so a link to a file reports the file.
|
|
||||||
pub async fn read(transport: &Transport, path: &str) -> Result<FileRead> {
|
pub async fn read(transport: &Transport, path: &str) -> Result<FileRead> {
|
||||||
// Two header lines, then the bytes: `<size> <mtime>`, then either
|
// Two header lines, then the bytes: `<size> <mtime>`, then either `tooBig`
|
||||||
// `tooBig` or the digest. A header rather than a JSON envelope because
|
// or the digest. A header rather than a JSON envelope because the content
|
||||||
// the content is bytes and may not be text at all.
|
// is bytes and may not be text at all.
|
||||||
let script = format!(
|
let script = format!(
|
||||||
"{PATH_PRELUDE}set -e; \
|
"{PATH_PRELUDE}set -e; \
|
||||||
h=$(stat -L -c '%s %Y' -- \"$p\"); \
|
h=$(stat -L -c '%s %Y' -- \"$p\"); \
|
||||||
@@ -281,24 +260,21 @@ fn split_read(out: &[u8]) -> Result<(u64, i64, &str, &[u8])> {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Replaces `path`'s contents, but only while it still hashes to
|
/// Replaces `path`'s contents, but only while it still hashes to `expected`.
|
||||||
/// `expected`.
|
|
||||||
///
|
///
|
||||||
/// Agents edit files while people read them, so a stale copy landing on
|
/// Agents edit files while people read them, so a stale copy landing on top of
|
||||||
/// top of somebody else's edit is the common case rather than the exotic
|
/// somebody else's edit is the common case rather than the exotic one. The
|
||||||
/// one. The digest the reader was shown is compared on the machine, and a
|
/// digest the reader was shown is compared on the machine, and a file that has
|
||||||
/// file that has moved on comes back as [`STALE`] rather than being
|
/// moved on comes back as [`STALE`] rather than being overwritten.
|
||||||
/// overwritten.
|
|
||||||
///
|
///
|
||||||
/// A temp file and a rename, so a connection dropped mid-write leaves the
|
/// A temp file and a rename, so a connection dropped mid-write leaves the old
|
||||||
/// old file whole rather than a truncated one, and `chmod --reference` so
|
/// file whole, and `chmod --reference` so the mode survives -- an executable
|
||||||
/// the mode survives -- an executable script written as a fresh file would
|
/// script written as a fresh file would stop being one. What that trades away:
|
||||||
/// stop being one. What that trades away: the inode changes, so a hard
|
/// the inode changes, so a hard link elsewhere stops being the same file.
|
||||||
/// link elsewhere stops being the same file. Editors do the same.
|
|
||||||
///
|
///
|
||||||
/// The check and the write are **not** atomic against a writer landing
|
/// The check and the write are **not** atomic against a writer landing between
|
||||||
/// between them -- a window of microseconds on that machine. Accepted: the
|
/// them -- a window of microseconds on that machine. Accepted: the alternative
|
||||||
/// alternative is a lock this has no way to make every other writer take.
|
/// is a lock this has no way to make every other writer take.
|
||||||
pub async fn write(
|
pub async fn write(
|
||||||
transport: &Transport,
|
transport: &Transport,
|
||||||
path: &str,
|
path: &str,
|
||||||
@@ -334,17 +310,16 @@ pub async fn write(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The file is not the one that was read. Its own type rather than an
|
/// The file is not the one that was read. Its own type rather than an error
|
||||||
/// error string, because the route answers it with a different status and
|
/// string, because the route answers it with a different status and the phone
|
||||||
/// the phone with a different question.
|
/// with a different question.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct Stale;
|
pub struct Stale;
|
||||||
|
|
||||||
/// Creates an empty file, refusing to truncate one that is already there.
|
/// Creates an empty file, refusing to truncate one that is already there.
|
||||||
///
|
/// `set -C` is the shell's own noclobber, so an existing name fails with the
|
||||||
/// `set -C` is the shell's own noclobber, so an existing name fails with
|
/// shell's own message rather than with a check that could race the redirection
|
||||||
/// the shell's own message rather than with a check that could race the
|
/// it is guarding.
|
||||||
/// redirection it is guarding.
|
|
||||||
pub async fn create_file(transport: &Transport, path: &str) -> Result<()> {
|
pub async fn create_file(transport: &Transport, path: &str) -> Result<()> {
|
||||||
let script = format!("{PATH_PRELUDE}set -C; : > \"$p\"");
|
let script = format!("{PATH_PRELUDE}set -C; : > \"$p\"");
|
||||||
transport
|
transport
|
||||||
@@ -354,9 +329,9 @@ pub async fn create_file(transport: &Transport, path: &str) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates a directory. Plain `mkdir`, not `-p`, for the same reason
|
/// Creates a directory. Plain `mkdir`, not `-p`, for the reason
|
||||||
/// [`create_file`] sets noclobber: a name that exists is something the
|
/// [`create_file`] sets noclobber: a name that exists is something the person
|
||||||
/// person typing it should be told about.
|
/// typing it should be told about.
|
||||||
pub async fn create_dir(transport: &Transport, path: &str) -> Result<()> {
|
pub async fn create_dir(transport: &Transport, path: &str) -> Result<()> {
|
||||||
let script = format!("{PATH_PRELUDE}mkdir -- \"$p\"");
|
let script = format!("{PATH_PRELUDE}mkdir -- \"$p\"");
|
||||||
transport
|
transport
|
||||||
@@ -376,8 +351,8 @@ fn text(captured: crate::session::transport::Captured) -> Result<String> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
/// The names a listing has to survive. All four are legal, and each
|
/// The names a listing has to survive. All four are legal, and each one
|
||||||
/// one broke a listing somewhere before it was separated with `\0`.
|
/// broke a listing somewhere before it was separated with `\0`.
|
||||||
#[test]
|
#[test]
|
||||||
fn a_listing_survives_the_names_a_filesystem_allows() {
|
fn a_listing_survives_the_names_a_filesystem_allows() {
|
||||||
let record = |own: &str, target: &str, size: &str, time: &str, name: &str| {
|
let record = |own: &str, target: &str, size: &str, time: &str, name: &str| {
|
||||||
@@ -406,8 +381,8 @@ mod tests {
|
|||||||
assert_eq!(entries[0].modified, 1756900000);
|
assert_eq!(entries[0].modified, 1756900000);
|
||||||
assert_eq!(entries[2].kind, EntryKind::Directory);
|
assert_eq!(entries[2].kind, EntryKind::Directory);
|
||||||
assert_eq!(entries[2].size, 4096);
|
assert_eq!(entries[2].size, 4096);
|
||||||
// The kind is the target's, so a link to a directory navigates --
|
// The kind is the target's, so a link to a directory navigates -- and
|
||||||
// and one whose target is gone is neither a file nor a directory.
|
// one whose target is gone is neither a file nor a directory.
|
||||||
assert!(entries[3].link);
|
assert!(entries[3].link);
|
||||||
assert_eq!(entries[3].kind, EntryKind::Directory);
|
assert_eq!(entries[3].kind, EntryKind::Directory);
|
||||||
assert_eq!(entries[4].kind, EntryKind::Other);
|
assert_eq!(entries[4].kind, EntryKind::Other);
|
||||||
@@ -431,9 +406,9 @@ mod tests {
|
|||||||
assert!(refused.contains("start it with / or ~"), "{refused}");
|
assert!(refused.contains("start it with / or ~"), "{refused}");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The scripts, against a real tree, through the transport that runs
|
/// The scripts, against a real tree, through the transport that runs them
|
||||||
/// them here -- which is cheap, because `sh` is wherever `cargo test`
|
/// here -- cheap, because `sh` is wherever `cargo test` is. The remote
|
||||||
/// is. The remote transport runs the identical text.
|
/// transport runs the identical text.
|
||||||
fn tree() -> tempfile::TempDir {
|
fn tree() -> tempfile::TempDir {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
std::fs::write(dir.path().join("hello.txt"), "one\ntwo\n").unwrap();
|
std::fs::write(dir.path().join("hello.txt"), "one\ntwo\n").unwrap();
|
||||||
@@ -454,8 +429,7 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
// `pwd -P`, so a temp directory reached through a symlinked /tmp
|
// `pwd -P`, so a temp directory reached through a symlinked /tmp
|
||||||
// answers with what it really is -- which is the path the phone
|
// answers with what it really is.
|
||||||
// then navigates on.
|
|
||||||
assert!(listing.path.starts_with('/'), "{}", listing.path);
|
assert!(listing.path.starts_with('/'), "{}", listing.path);
|
||||||
let mut names: Vec<&str> = listing.entries.iter().map(|e| e.name.as_str()).collect();
|
let mut names: Vec<&str> = listing.entries.iter().map(|e| e.name.as_str()).collect();
|
||||||
names.sort_unstable();
|
names.sort_unstable();
|
||||||
@@ -498,8 +472,8 @@ mod tests {
|
|||||||
std::fs::write(dir.path().join("big"), vec![b'x'; FILE_LIMIT as usize + 1]).unwrap();
|
std::fs::write(dir.path().join("big"), vec![b'x'; FILE_LIMIT as usize + 1]).unwrap();
|
||||||
assert!(matches!(read_at("big").await, FileRead::TooBig { .. }));
|
assert!(matches!(read_at("big").await, FileRead::TooBig { .. }));
|
||||||
|
|
||||||
// Empty is text with nothing in it, which is what it is -- not a
|
// Empty is text with nothing in it -- not a fourth state, and not the
|
||||||
// fourth state and not the same as any of the three above.
|
// same as any of the three above.
|
||||||
std::fs::write(dir.path().join("empty"), "").unwrap();
|
std::fs::write(dir.path().join("empty"), "").unwrap();
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
read_at("empty").await,
|
read_at("empty").await,
|
||||||
@@ -592,9 +566,9 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A path that tries to close the quote and start a command of its
|
/// A path that tries to close the quote and start a command of its own. It
|
||||||
/// own. It is an argument rather than syntax, so it stays one absurd
|
/// is an argument rather than syntax, so it stays one absurd filename -- the
|
||||||
/// filename -- the same property `ssh.rs` tests for the remote side.
|
/// same property `ssh.rs` tests for the remote side.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn a_path_full_of_shell_crosses_as_data() {
|
async fn a_path_full_of_shell_crosses_as_data() {
|
||||||
let dir = tree();
|
let dir = tree();
|
||||||
@@ -614,8 +588,8 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The tilde is the one character the prelude gives a meaning, and it
|
/// The tilde is the one character the prelude gives a meaning, and it is the
|
||||||
/// is the *machine's* home -- here, this one.
|
/// *machine's* home -- here, this one.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn a_leading_tilde_means_the_machine_s_own_home() {
|
async fn a_leading_tilde_means_the_machine_s_own_home() {
|
||||||
let Some(home) = std::env::home_dir() else {
|
let Some(home) = std::env::home_dir() else {
|
||||||
|
|||||||
+69
-92
@@ -1,14 +1,12 @@
|
|||||||
//! A phone interface to AI coding sessions -- the backend. See PLAN.md for
|
//! A phone interface to AI coding sessions -- the backend. See PLAN.md for the
|
||||||
//! the whole picture; this is the entry point: config + session registry,
|
//! whole picture; this is the entry point: config + session registry, token
|
||||||
//! token bootstrap, and the one TLS listener.
|
//! bootstrap, and the one TLS listener.
|
||||||
//!
|
//!
|
||||||
//! The listener binds the WireGuard interface's address only, and fails
|
//! The listener binds the WireGuard interface's address only, and fails closed
|
||||||
//! closed -- if `wg0` is down the server refuses to start rather than
|
//! -- if `wg0` is down the server refuses to start rather than falling back to
|
||||||
//! falling back to `0.0.0.0`, because this API *is* remote code execution
|
//! `0.0.0.0`, because this API *is* remote code execution and the tunnel is
|
||||||
//! and the tunnel is what keeps its pre-auth surface (TLS handshake, HTTP
|
//! what keeps its pre-auth surface off the open internet. `--bind` overrides
|
||||||
//! parsing, auth middleware) off the open internet. `--bind` overrides
|
//! explicitly for development; a deliberate, logged choice, never a fallback.
|
||||||
//! explicitly for development; that is a deliberate, logged choice, never a
|
|
||||||
//! fallback.
|
|
||||||
//!
|
//!
|
||||||
//! There is no plaintext listener at all, so the bearer token can't travel
|
//! There is no plaintext listener at all, so the bearer token can't travel
|
||||||
//! unencrypted by misconfiguration -- even inside the tunnel.
|
//! unencrypted by misconfiguration -- even inside the tunnel.
|
||||||
@@ -50,10 +48,9 @@ struct Args {
|
|||||||
#[arg(long, default_value_t = DEFAULT_PORT)]
|
#[arg(long, default_value_t = DEFAULT_PORT)]
|
||||||
port: u16,
|
port: u16,
|
||||||
|
|
||||||
/// Address to bind instead of the wg0 interface's -- a development
|
/// Address to bind instead of the wg0 interface's -- a development override
|
||||||
/// override (e.g. 127.0.0.1 for curl, or a LAN address for a phone
|
/// (127.0.0.1 for curl, or a LAN address for a phone before the tunnel
|
||||||
/// before the tunnel exists). Production runs without it and fails
|
/// exists). Production runs without it and fails closed when wg0 is absent.
|
||||||
/// closed when wg0 is absent.
|
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
bind: Option<IpAddr>,
|
bind: Option<IpAddr>,
|
||||||
|
|
||||||
@@ -83,44 +80,34 @@ struct Args {
|
|||||||
rotate_token: bool,
|
rotate_token: bool,
|
||||||
|
|
||||||
/// Enroll one more device without touching the running server: mint a
|
/// Enroll one more device without touching the running server: mint a
|
||||||
/// token, print its enrollment link (one line, stdout, nothing else)
|
/// token, print its enrollment link (one line, stdout, nothing else) and
|
||||||
/// and exit. The server adopts the token the first time that device
|
/// exit. The server adopts the token the first time that device uses it.
|
||||||
/// uses it. For a tool -- Dev Updater -- that opens the link on the
|
/// For a tool that opens the link on the phone, where a QR printed here
|
||||||
/// phone, where a QR printed here cannot be scanned.
|
/// cannot be scanned.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
enroll_link: bool,
|
enroll_link: bool,
|
||||||
|
|
||||||
/// Hold every response back by this many milliseconds.
|
/// Hold every response back by this many milliseconds.
|
||||||
///
|
///
|
||||||
/// A development aid, and a specific one: over the tunnel a phone's
|
/// A development aid, and a specific one: over the tunnel a phone's requests
|
||||||
/// requests take tens to hundreds of milliseconds, and several faults
|
/// take tens to hundreds of milliseconds, and several faults live entirely
|
||||||
/// live entirely in what the app does *while* one is outstanding --
|
/// in what the app does *while* one is outstanding. On a loopback server
|
||||||
/// a page of history landing mid-fling, a screen drawn before its
|
/// those windows close before anything can be observed and the bug looks
|
||||||
/// first answer arrives. On a loopback server every response is back
|
/// like it is not there.
|
||||||
/// within a millisecond or two, so those windows close before
|
|
||||||
/// anything can be observed and the bug looks like it is not there.
|
|
||||||
/// This reopens them on demand rather than by unplugging something.
|
|
||||||
#[arg(long, default_value_t = 0, value_name = "MS")]
|
#[arg(long, default_value_t = 0, value_name = "MS")]
|
||||||
delay: u64,
|
delay: u64,
|
||||||
|
|
||||||
/// Mark every session spawned here as throwaway: its process is
|
/// Mark every session spawned here as throwaway: its process is stopped
|
||||||
/// stopped when this server exits, instead of being left running for
|
/// when this server exits, instead of being left running for the next start
|
||||||
/// the next start to adopt. On by default in a debug build.
|
/// to adopt. On by default in a debug build.
|
||||||
///
|
///
|
||||||
/// Sessions outlive the backend on purpose, which is right for the
|
/// Sessions outlive the backend on purpose, which is right for the ones
|
||||||
/// ones somebody is using and wrong for the ones a test made: a
|
/// somebody is using and wrong for the ones a test made -- twelve of those
|
||||||
/// session spawned to check something leaves a `claude` behind that
|
/// accumulated on this machine in a day, each holding a conversation open.
|
||||||
/// every later server adopts, and they accumulate silently -- twelve
|
|
||||||
/// of them on this machine in a day, each holding a conversation open.
|
|
||||||
/// So a development build cleans up after itself unless told not to
|
|
||||||
/// (`--throwaway-sessions=false`), and a release build never does
|
|
||||||
/// unless asked.
|
|
||||||
///
|
///
|
||||||
/// The flag decides only what *new* sessions are marked as. What
|
/// The flag decides only what *new* sessions are marked as. What happens on
|
||||||
/// happens on the way out is decided by the mark, which is written
|
/// the way out is decided by the mark, which outlives the server that made
|
||||||
/// into the session and outlives the server that made it -- so
|
/// it.
|
||||||
/// sessions spawned without it keep running, whichever server is up
|
|
||||||
/// when one exits.
|
|
||||||
#[arg(
|
#[arg(
|
||||||
long,
|
long,
|
||||||
default_value_t = cfg!(debug_assertions),
|
default_value_t = cfg!(debug_assertions),
|
||||||
@@ -134,18 +121,16 @@ struct Args {
|
|||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<()> {
|
async fn main() -> Result<()> {
|
||||||
// Both rustls crypto providers are in the dependency graph (ureq
|
// Both rustls crypto providers are in the dependency graph (ureq brings
|
||||||
// brings ring, axum-server brings aws-lc-rs), so rustls refuses to
|
// ring, axum-server brings aws-lc-rs), so rustls refuses to pick one itself.
|
||||||
// pick one itself; choose before anything touches TLS.
|
|
||||||
rustls::crypto::aws_lc_rs::default_provider()
|
rustls::crypto::aws_lc_rs::default_provider()
|
||||||
.install_default()
|
.install_default()
|
||||||
.expect("no other TLS crypto provider is installed before main");
|
.expect("no other TLS crypto provider is installed before main");
|
||||||
|
|
||||||
// `info` unless RUST_LOG says otherwise. Written as a *fallback* rather than as the filter,
|
// `info` unless RUST_LOG says otherwise. Written as a *fallback* rather than
|
||||||
// because `with_env_filter("info")` is a fixed directive that never reads the environment --
|
// as the filter, because `with_env_filter("info")` is a fixed directive that
|
||||||
// so the per-request diagnostics that AGENTS.md tells you to turn on with
|
// never reads the environment -- so `RUST_LOG=ai_server=debug` printed
|
||||||
// `RUST_LOG=ai_server=debug` printed nothing, and the switch looked like the code it was
|
// nothing, and the switch looked like the code it was meant to instrument.
|
||||||
// meant to instrument being wrong.
|
|
||||||
tracing_subscriber::fmt()
|
tracing_subscriber::fmt()
|
||||||
.with_env_filter(
|
.with_env_filter(
|
||||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||||
@@ -157,11 +142,10 @@ async fn main() -> Result<()> {
|
|||||||
let config_path = args
|
let config_path = args
|
||||||
.config
|
.config
|
||||||
.unwrap_or_else(|| config_home("ai-app").join("config.ron"));
|
.unwrap_or_else(|| config_home("ai-app").join("config.ron"));
|
||||||
// Before the manager exists, on purpose: constructing it and seeding
|
// Before the manager exists, on purpose: constructing it and seeding setups
|
||||||
// setups touches sessions and subprocesses this invocation has no
|
// touches sessions and subprocesses this invocation has no business with
|
||||||
// business with while another instance is serving. Only the hash
|
// while another instance is serving. Only the hash reaches disk, in the
|
||||||
// reaches disk, in the spool `auth.rs` reads; the link itself goes to
|
// spool `auth.rs` reads; the link goes to stdout alone.
|
||||||
// stdout alone, because the caller opens whatever this prints.
|
|
||||||
if args.enroll_link {
|
if args.enroll_link {
|
||||||
let bind_ip = match args.bind {
|
let bind_ip = match args.bind {
|
||||||
Some(ip) => ip,
|
Some(ip) => ip,
|
||||||
@@ -182,9 +166,9 @@ async fn main() -> Result<()> {
|
|||||||
let data_dir = args
|
let data_dir = args
|
||||||
.data_dir
|
.data_dir
|
||||||
.unwrap_or_else(|| data_home("ai-app").join("sessions"));
|
.unwrap_or_else(|| data_home("ai-app").join("sessions"));
|
||||||
// Beside the session data rather than under it: models outlive every
|
// Beside the session data rather than under it: models outlive every session
|
||||||
// session and are shared by all of them, so deleting a session must
|
// and are shared by all of them, so deleting a session must never take a
|
||||||
// never take a multi-gigabyte download with it.
|
// multi-gigabyte download with it.
|
||||||
let models_dir = args
|
let models_dir = args
|
||||||
.models_dir
|
.models_dir
|
||||||
.unwrap_or_else(|| data_home("ai-app").join("models"));
|
.unwrap_or_else(|| data_home("ai-app").join("models"));
|
||||||
@@ -200,9 +184,9 @@ async fn main() -> Result<()> {
|
|||||||
server exits rather than left running (--throwaway-sessions=false to keep them)"
|
server exits rather than left running (--throwaway-sessions=false to keep them)"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// After construction rather than inside it: seeding asks this machine
|
// After construction rather than inside it: seeding asks this machine what
|
||||||
// what it has, which is I/O, and a constructor that quietly runs a
|
// it has, and a constructor that quietly runs a subprocess is a surprise to
|
||||||
// subprocess is a surprise to every caller including the tests.
|
// every caller including the tests.
|
||||||
manager.seed_setup().await?;
|
manager.seed_setup().await?;
|
||||||
|
|
||||||
tracing::info!("config: {}", config_path.display());
|
tracing::info!("config: {}", config_path.display());
|
||||||
@@ -210,9 +194,9 @@ async fn main() -> Result<()> {
|
|||||||
for setup in manager.setups() {
|
for setup in manager.setups() {
|
||||||
match &setup.ssh {
|
match &setup.ssh {
|
||||||
Some(ssh) => tracing::info!(" setup \"{}\" -> {}", setup.name, ssh.address),
|
Some(ssh) => tracing::info!(" setup \"{}\" -> {}", setup.name, ssh.address),
|
||||||
// No parenthetical naming the local machine: the default
|
// No parenthetical naming the local machine: the default setup is
|
||||||
// setup is *called* "this machine", and the line read
|
// *called* "this machine", and the line read "setup this machine
|
||||||
// "setup this machine (this machine)".
|
// (this machine)".
|
||||||
None => tracing::info!(" setup \"{}\" runs here", setup.name),
|
None => tracing::info!(" setup \"{}\" runs here", setup.name),
|
||||||
}
|
}
|
||||||
for provider in &setup.providers {
|
for provider in &setup.providers {
|
||||||
@@ -228,10 +212,9 @@ async fn main() -> Result<()> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Before the interface check below, deliberately: the certificates are
|
// Before the interface check below, deliberately: the certificates are also
|
||||||
// also what the phone app embeds at build time, so they need to be
|
// what the phone app embeds at build time, so they need to be obtainable on
|
||||||
// obtainable on a machine whose tunnel isn't up yet. The leaf is
|
// a machine whose tunnel isn't up yet. The leaf is reissued on every start.
|
||||||
// reissued on every start, so once wg0 exists the next start covers it.
|
|
||||||
let certs_dir = args
|
let certs_dir = args
|
||||||
.certs
|
.certs
|
||||||
.unwrap_or_else(|| config_home("ai-app").join("certs"));
|
.unwrap_or_else(|| config_home("ai-app").join("certs"));
|
||||||
@@ -256,9 +239,8 @@ async fn main() -> Result<()> {
|
|||||||
None => netif::wg_address("ai-server")?,
|
None => netif::wg_address("ai-server")?,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Token bootstrap: first run generates one; --rotate-token replaces
|
// Token bootstrap: first run generates one; --rotate-token replaces whatever
|
||||||
// whatever exists. Either way the plaintext appears exactly once, in
|
// exists. Either way the plaintext appears exactly once, in the QR.
|
||||||
// the QR printed here.
|
|
||||||
if args.rotate_token || manager.tokens().is_empty() {
|
if args.rotate_token || manager.tokens().is_empty() {
|
||||||
let rotating = args.rotate_token && !manager.tokens().is_empty();
|
let rotating = args.rotate_token && !manager.tokens().is_empty();
|
||||||
let token = enroll::generate_token();
|
let token = enroll::generate_token();
|
||||||
@@ -279,16 +261,13 @@ async fn main() -> Result<()> {
|
|||||||
.await
|
.await
|
||||||
.context("failed to load TLS cert/key")?;
|
.context("failed to load TLS cert/key")?;
|
||||||
|
|
||||||
// No providers listed here any more: which machines can be asked, and
|
// No providers listed here any more: which machines can be asked, and about
|
||||||
// about what, comes from the setups at the moment the screen is opened
|
// what, comes from the setups at the moment the screen is opened -- so a
|
||||||
// -- so a machine added from the phone reports its limits without a
|
// machine added from the phone reports its limits without a restart.
|
||||||
// restart, and the backend's own account stops standing in for every
|
|
||||||
// machine's.
|
|
||||||
let monitor = Arc::new(usage::UsageMonitor::new());
|
let monitor = Arc::new(usage::UsageMonitor::new());
|
||||||
|
|
||||||
// The bearer-token middleware wraps the entire router -- routes and
|
// The bearer-token middleware wraps the entire router -- routes and fallback
|
||||||
// fallback alike -- here and only here, so a new route can't forget
|
// alike -- here and only here, so a new route can't forget auth.
|
||||||
// auth. Zero unauthenticated endpoints.
|
|
||||||
let app = routes::router(Arc::clone(&manager))
|
let app = routes::router(Arc::clone(&manager))
|
||||||
.merge(routes::usage_router(monitor, Arc::clone(&manager)))
|
.merge(routes::usage_router(monitor, Arc::clone(&manager)))
|
||||||
.merge(routes::models_router(Arc::clone(&models)))
|
.merge(routes::models_router(Arc::clone(&models)))
|
||||||
@@ -297,9 +276,9 @@ async fn main() -> Result<()> {
|
|||||||
auth::require_token,
|
auth::require_token,
|
||||||
));
|
));
|
||||||
|
|
||||||
// Outside the auth layer, so an unauthenticated request is refused at
|
// Outside the auth layer, so an unauthenticated request is refused at the
|
||||||
// the speed it always was: this is here to slow the app down, not to
|
// speed it always was: this is here to slow the app down, not to widen the
|
||||||
// widen the window on anything guessing at tokens.
|
// window on anything guessing at tokens.
|
||||||
let app = match args.delay {
|
let app = match args.delay {
|
||||||
0 => app,
|
0 => app,
|
||||||
ms => {
|
ms => {
|
||||||
@@ -316,13 +295,11 @@ async fn main() -> Result<()> {
|
|||||||
let addr = SocketAddr::new(bind_ip, args.port);
|
let addr = SocketAddr::new(bind_ip, args.port);
|
||||||
tracing::info!("serving https://{addr}");
|
tracing::info!("serving https://{addr}");
|
||||||
|
|
||||||
// Let go of the sessions on the way out rather than stopping them:
|
// Let go of the sessions on the way out rather than stopping them: their
|
||||||
// their processes are meant to outlive this one, so restarting the
|
// processes are meant to outlive this one. Each is recorded in its session
|
||||||
// backend does not end a turn somebody is waiting on. Each is recorded
|
// directory and adopted again on the way back up. The exception is the
|
||||||
// in its session directory and adopted again on the way back up (see
|
// sessions marked throwaway, which are stopped first. Both signals, because
|
||||||
// `session::process`). The exception is the sessions marked throwaway,
|
// systemd and OpenRC send TERM while a terminal sends INT.
|
||||||
// which are stopped first -- see `--throwaway-sessions`. Both signals,
|
|
||||||
// because systemd and OpenRC send TERM while a terminal sends INT.
|
|
||||||
let serving = axum_server::bind_rustls(addr, tls_config)
|
let serving = axum_server::bind_rustls(addr, tls_config)
|
||||||
.serve(app.into_make_service_with_connect_info::<SocketAddr>());
|
.serve(app.into_make_service_with_connect_info::<SocketAddr>());
|
||||||
let mut terminate = signal(SignalKind::terminate()).context("listening for SIGTERM")?;
|
let mut terminate = signal(SignalKind::terminate()).context("listening for SIGTERM")?;
|
||||||
@@ -331,9 +308,9 @@ async fn main() -> Result<()> {
|
|||||||
_ = terminate.recv() => tracing::info!("SIGTERM -- letting go of sessions"),
|
_ = terminate.recv() => tracing::info!("SIGTERM -- letting go of sessions"),
|
||||||
_ = tokio::signal::ctrl_c() => tracing::info!("interrupted -- letting go of sessions"),
|
_ = tokio::signal::ctrl_c() => tracing::info!("interrupted -- letting go of sessions"),
|
||||||
}
|
}
|
||||||
// Stopped before the rest are let go of, and on every way out of the
|
// Stopped before the rest are let go of, and on every way out of the select
|
||||||
// select above: a throwaway session is one nobody meant to keep, and
|
// above: a throwaway session is one nobody meant to keep, and the whole point
|
||||||
// the whole point is that nothing has to remember to clean it up.
|
// is that nothing has to remember to clean it up.
|
||||||
manager.stop_throwaway_sessions();
|
manager.stop_throwaway_sessions();
|
||||||
manager.detach_all();
|
manager.detach_all();
|
||||||
|
|
||||||
|
|||||||
+84
-118
@@ -1,28 +1,22 @@
|
|||||||
//! GGUF models on this machine, and the downloads that produce them.
|
//! GGUF models on this machine, and the downloads that produce them.
|
||||||
//!
|
//!
|
||||||
//! The registry pattern again (see `session`): one owner, one lock, so what
|
//! The registry pattern again: one owner, one lock, so what is on disk and what
|
||||||
//! is on disk and what this server believes cannot come apart.
|
//! this server believes cannot come apart. Three things shape the design, all
|
||||||
//!
|
//! consequences of a model file being gigabytes rather than kilobytes:
|
||||||
//! Three things shape the design, all of them consequences of a model file
|
|
||||||
//! being gigabytes rather than kilobytes:
|
|
||||||
//!
|
//!
|
||||||
//! **A download belongs to the model, not to whoever asked for it.** It is
|
//! **A download belongs to the model, not to whoever asked for it.** It is
|
||||||
//! keyed by the model it produces and lives here, so any device can watch
|
//! keyed by the model it produces and lives here, so any device can watch it --
|
||||||
//! it -- including one that did not start it, and one that opened the app
|
//! including one that did not start it. State in a per-connection channel would
|
||||||
//! after it finished. State in a per-connection channel would not survive
|
//! not survive the phone locking its screen, which for an hour-long download is
|
||||||
//! the phone locking its screen, which for an hour-long download is the
|
//! the normal case.
|
||||||
//! normal case rather than an edge one.
|
|
||||||
//!
|
//!
|
||||||
//! **Every run has an id, and its outcome outlives it.** Without those,
|
//! **Every run has an id, and its outcome outlives it.** Without those, "not
|
||||||
//! "not downloading" is three different answers at once -- it finished,
|
//! downloading" is three answers at once -- it finished, it never started, or a
|
||||||
//! it never started, or a different run finished while you were away --
|
//! different run finished while you were away.
|
||||||
//! and over an hour that ambiguity is certain to be hit. A device compares
|
|
||||||
//! the run it was watching against the run reported now.
|
|
||||||
//!
|
//!
|
||||||
//! **Progress is measured, never estimated.** `total` is whatever
|
//! **Progress is measured, never estimated.** `total` is whatever
|
||||||
//! `Content-Length` said and nothing else; when the server does not send
|
//! `Content-Length` said and nothing else; when the server does not send one it
|
||||||
//! one it stays `None` and the phone shows that it does not know, rather
|
//! stays `None` and the phone shows that it does not know.
|
||||||
//! than a bar drawn from how long the last download took.
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::io::{Read, Seek, SeekFrom, Write};
|
use std::io::{Read, Seek, SeekFrom, Write};
|
||||||
@@ -40,35 +34,33 @@ use wg_app_link::private;
|
|||||||
const USER_AGENT: &str = concat!("ai-server/", env!("CARGO_PKG_VERSION"));
|
const USER_AGENT: &str = concat!("ai-server/", env!("CARGO_PKG_VERSION"));
|
||||||
|
|
||||||
/// Read size per loop iteration. Big enough that the syscall overhead is
|
/// Read size per loop iteration. Big enough that the syscall overhead is
|
||||||
/// nothing against a multi-gigabyte file, small enough that a cancel is
|
/// nothing against a multi-gigabyte file, small enough that a cancel is noticed
|
||||||
/// noticed promptly -- the flag is only checked between chunks.
|
/// promptly -- the flag is only checked between chunks.
|
||||||
const CHUNK: usize = 256 * 1024;
|
const CHUNK: usize = 256 * 1024;
|
||||||
|
|
||||||
/// A model file sitting on this machine, ready to run.
|
/// A model file sitting on this machine, ready to run.
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct LocalModel {
|
pub struct LocalModel {
|
||||||
/// `owner/repo/file.gguf` -- the HuggingFace coordinates, which are
|
/// `owner/repo/file.gguf` -- the HuggingFace coordinates, which are already
|
||||||
/// already unique, so nothing has to invent an id.
|
/// unique, so nothing has to invent an id.
|
||||||
pub key: String,
|
pub key: String,
|
||||||
pub repo: String,
|
pub repo: String,
|
||||||
pub file: String,
|
pub file: String,
|
||||||
pub bytes: u64,
|
pub bytes: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What a run is doing, or did.
|
/// What a run is doing, or did. Flat rather than a tagged enum carrying its
|
||||||
///
|
/// message, because the phone switches on this and a string it can compare is
|
||||||
/// Flat rather than a tagged enum carrying its message, because the phone
|
/// easier to render than a variant it has to destructure.
|
||||||
/// switches on this and a string it can compare is easier to render than a
|
|
||||||
/// variant it has to destructure.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum DownloadState {
|
pub enum DownloadState {
|
||||||
Running,
|
Running,
|
||||||
/// Reading the finished file back to check it against the hash
|
/// Reading the finished file back to check it against the hash HuggingFace
|
||||||
/// HuggingFace publishes. Its own state because it takes real time on
|
/// publishes. Its own state because it takes real time on a multi-gigabyte
|
||||||
/// a multi-gigabyte file and "still working" is the honest thing to
|
/// file and "still working" is the honest thing to show, rather than a bar
|
||||||
/// show, rather than a bar sitting at 100% for half a minute.
|
/// sitting at 100% for half a minute.
|
||||||
Verifying,
|
Verifying,
|
||||||
Finished,
|
Finished,
|
||||||
Failed,
|
Failed,
|
||||||
@@ -80,20 +72,17 @@ pub enum DownloadState {
|
|||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct DownloadStatus {
|
pub struct DownloadStatus {
|
||||||
pub key: String,
|
pub key: String,
|
||||||
/// Distinguishes this run from any earlier one for the same model.
|
/// Distinguishes this run from any earlier one for the same model, so a
|
||||||
/// A device that was watching run 3 can tell that what it is looking
|
/// device that was watching run 3 can tell it is now looking at run 4.
|
||||||
/// at now is run 4 rather than assuming its own run ended.
|
|
||||||
pub run: u64,
|
pub run: u64,
|
||||||
pub repo: String,
|
pub repo: String,
|
||||||
pub file: String,
|
pub file: String,
|
||||||
pub state: DownloadState,
|
pub state: DownloadState,
|
||||||
/// Bytes on disk, including any carried over from a resumed attempt.
|
|
||||||
pub done: u64,
|
pub done: u64,
|
||||||
/// What `Content-Length` said, or absent when the server did not say.
|
/// What `Content-Length` said, or absent when the server did not say.
|
||||||
/// Absent means "unknown", never "zero" -- see this module's doc.
|
/// Absent means "unknown", never "zero".
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub total: Option<u64>,
|
pub total: Option<u64>,
|
||||||
/// Present only when [`DownloadState::Failed`], and it is the reason.
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub error: Option<String>,
|
pub error: Option<String>,
|
||||||
pub started: f64,
|
pub started: f64,
|
||||||
@@ -152,9 +141,8 @@ impl Run {
|
|||||||
/// Every model this machine has, and every download in flight or finished.
|
/// Every model this machine has, and every download in flight or finished.
|
||||||
pub struct ModelStore {
|
pub struct ModelStore {
|
||||||
dir: PathBuf,
|
dir: PathBuf,
|
||||||
/// Keyed by model key: one run per model at a time, and the last run
|
/// Keyed by model key: one run per model at a time, and the last run for a
|
||||||
/// for a model stays here after it ends so its outcome can still be
|
/// model stays here after it ends so its outcome can still be read.
|
||||||
/// read. Bounded by how many distinct models have been asked for.
|
|
||||||
runs: Mutex<HashMap<String, Arc<Run>>>,
|
runs: Mutex<HashMap<String, Arc<Run>>>,
|
||||||
next_run: AtomicU64,
|
next_run: AtomicU64,
|
||||||
}
|
}
|
||||||
@@ -171,11 +159,10 @@ impl ModelStore {
|
|||||||
/// Where a model's file lives, refusing anything that would escape the
|
/// Where a model's file lives, refusing anything that would escape the
|
||||||
/// models directory.
|
/// models directory.
|
||||||
///
|
///
|
||||||
/// The repo and file come from a phone, and this server runs as the
|
/// The repo and file come from a phone, so they are treated as hostile:
|
||||||
/// user who started it, so they are treated as hostile: every
|
/// every component must be an ordinary name. Rejecting rather than
|
||||||
/// component must be an ordinary name. Rejecting is deliberate rather
|
/// sanitising, since a silently rewritten path would download the right
|
||||||
/// than sanitising, since a silently rewritten path would download the
|
/// bytes to the wrong place.
|
||||||
/// right bytes to the wrong place.
|
|
||||||
fn path_for(&self, repo: &str, file: &str) -> Result<PathBuf> {
|
fn path_for(&self, repo: &str, file: &str) -> Result<PathBuf> {
|
||||||
let mut path = self.dir.clone();
|
let mut path = self.dir.clone();
|
||||||
for part in repo.split('/').chain(file.split('/')) {
|
for part in repo.split('/').chain(file.split('/')) {
|
||||||
@@ -191,11 +178,9 @@ impl ModelStore {
|
|||||||
format!("{repo}/{file}")
|
format!("{repo}/{file}")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every `.gguf` found under the models directory, newest first.
|
/// Every `.gguf` found under the models directory, newest first. Read from
|
||||||
///
|
/// disk on each call rather than cached: a file deleted by hand should stop
|
||||||
/// Read from disk on each call rather than cached: a file deleted by
|
/// being offered.
|
||||||
/// hand should stop being offered, and the directory is small enough
|
|
||||||
/// that walking it costs nothing next to loading a model.
|
|
||||||
pub fn list(&self) -> Vec<LocalModel> {
|
pub fn list(&self) -> Vec<LocalModel> {
|
||||||
let mut found = Vec::new();
|
let mut found = Vec::new();
|
||||||
collect(&self.dir, &self.dir, &mut found);
|
collect(&self.dir, &self.dir, &mut found);
|
||||||
@@ -211,12 +196,10 @@ impl ModelStore {
|
|||||||
all
|
all
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Starts fetching `file` from `repo`, or returns the run already
|
/// Starts fetching `file` from `repo`, or returns the run already doing so.
|
||||||
/// doing so.
|
/// Idempotent on purpose: a phone that lost its connection will press the
|
||||||
///
|
/// button again, and that must join the existing run rather than start a
|
||||||
/// Idempotent on purpose: a phone that lost its connection and came
|
/// second one writing the same file.
|
||||||
/// back will press the button again, and that must join the existing
|
|
||||||
/// run rather than start a second one writing the same file.
|
|
||||||
pub fn start(self: &Arc<Self>, repo: &str, file: &str) -> Result<DownloadStatus> {
|
pub fn start(self: &Arc<Self>, repo: &str, file: &str) -> Result<DownloadStatus> {
|
||||||
let key = Self::key_for(repo, file);
|
let key = Self::key_for(repo, file);
|
||||||
let target = self.path_for(repo, file)?;
|
let target = self.path_for(repo, file)?;
|
||||||
@@ -251,8 +234,8 @@ impl ModelStore {
|
|||||||
drop(runs);
|
drop(runs);
|
||||||
|
|
||||||
// A dedicated thread rather than the blocking pool: this holds its
|
// A dedicated thread rather than the blocking pool: this holds its
|
||||||
// thread for as long as the download takes, which is minutes to
|
// thread for as long as the download takes, which is minutes to hours,
|
||||||
// hours, and the pool exists for short work.
|
// and the pool exists for short work.
|
||||||
let store = Arc::clone(self);
|
let store = Arc::clone(self);
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let outcome = store.fetch(&run, &target);
|
let outcome = store.fetch(&run, &target);
|
||||||
@@ -275,8 +258,8 @@ impl ModelStore {
|
|||||||
Ok(status)
|
Ok(status)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Asks a running download to stop. The partial file stays, so
|
/// Asks a running download to stop. The partial file stays, so starting
|
||||||
/// starting again resumes rather than refetching.
|
/// again resumes rather than refetching.
|
||||||
pub fn cancel(&self, key: &str) -> Result<DownloadStatus> {
|
pub fn cancel(&self, key: &str) -> Result<DownloadStatus> {
|
||||||
let runs = self.runs.lock().unwrap();
|
let runs = self.runs.lock().unwrap();
|
||||||
let Some(run) = runs.get(key) else {
|
let Some(run) = runs.get(key) else {
|
||||||
@@ -303,7 +286,6 @@ impl ModelStore {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The download loop: resume where a partial left off, write, report.
|
|
||||||
fn fetch(&self, run: &Run, target: &Path) -> Result<()> {
|
fn fetch(&self, run: &Run, target: &Path) -> Result<()> {
|
||||||
let partial = partial_of(target);
|
let partial = partial_of(target);
|
||||||
let identity = identity_of(target);
|
let identity = identity_of(target);
|
||||||
@@ -311,9 +293,8 @@ impl ModelStore {
|
|||||||
private::create_dir(parent)?;
|
private::create_dir(parent)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// What we have, and what it was part of. A partial with no
|
// What we have, and what it was part of. A partial with no recorded
|
||||||
// recorded identity is not resumable -- it could be a fragment of
|
// identity is not resumable -- it could be a fragment of any revision.
|
||||||
// any revision -- so it is refetched rather than guessed at.
|
|
||||||
let known = std::fs::read_to_string(&identity)
|
let known = std::fs::read_to_string(&identity)
|
||||||
.ok()
|
.ok()
|
||||||
.map(|s| s.trim().to_string());
|
.map(|s| s.trim().to_string());
|
||||||
@@ -331,13 +312,11 @@ impl ModelStore {
|
|||||||
let mut etag = etag_of(&response);
|
let mut etag = etag_of(&response);
|
||||||
|
|
||||||
// HuggingFace's CDN ignores `If-Range` -- probed 2026-08-28: a
|
// HuggingFace's CDN ignores `If-Range` -- probed 2026-08-28: a
|
||||||
// deliberately stale validator still answers 206 with the ranged
|
// deliberately stale validator still answers 206 with the ranged bytes.
|
||||||
// bytes rather than 200 with the whole file. So the header cannot
|
// So the header cannot be relied on to restart us, and the check is done
|
||||||
// be relied on to restart us, and the check is done here instead:
|
// here instead: if what arrived is not the revision our partial belongs
|
||||||
// if what arrived is not the revision our partial belongs to,
|
// to, resuming would splice two files into something of exactly the
|
||||||
// resuming would splice two files into something of exactly the
|
// right length and the wrong contents.
|
||||||
// right length and the wrong contents. Throw the partial away and
|
|
||||||
// ask again from zero.
|
|
||||||
if resumed && etag.is_some() && etag != known {
|
if resumed && etag.is_some() && etag != known {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"{} changed upstream since the partial was written -- starting again",
|
"{} changed upstream since the partial was written -- starting again",
|
||||||
@@ -349,11 +328,9 @@ impl ModelStore {
|
|||||||
etag = etag_of(&response);
|
etag = etag_of(&response);
|
||||||
}
|
}
|
||||||
|
|
||||||
// On a 206, Content-Length is the length of the *range*, not of
|
// On a 206, Content-Length is the length of the *range*, not of the file
|
||||||
// the file -- it answers a different question than the one a
|
// -- taken at face value it would fill the bar at 72 MB of a 234 MB
|
||||||
// progress bar asks, and taken at face value it would fill the bar
|
// model. The whole size is the last field of Content-Range, which has
|
||||||
// at 72 MB of a 234 MB model. The whole size is the last field of
|
|
||||||
// Content-Range (`bytes 162000000-234074815/234074816`), which has
|
|
||||||
// the further merit of not depending on where the range began.
|
// the further merit of not depending on where the range began.
|
||||||
let total: Option<u64> = if resumed {
|
let total: Option<u64> = if resumed {
|
||||||
response
|
response
|
||||||
@@ -378,12 +355,10 @@ impl ModelStore {
|
|||||||
p.total = total;
|
p.total = total;
|
||||||
}
|
}
|
||||||
|
|
||||||
// `truncate(false)` is the whole resume story: the file is opened
|
// `truncate(false)` is the whole resume story: the file is opened to be
|
||||||
// to be seeked into and appended to, and truncating here would
|
// seeked into and appended to, and truncating would throw away exactly
|
||||||
// throw away exactly the bytes the Range request just asked the
|
// the bytes the Range request just asked the server not to send again.
|
||||||
// server not to send again. Stated rather than left to the
|
// Stated rather than left to the default.
|
||||||
// default, because the default is what a reader would have to
|
|
||||||
// remember.
|
|
||||||
let mut file = std::fs::OpenOptions::new()
|
let mut file = std::fs::OpenOptions::new()
|
||||||
.create(true)
|
.create(true)
|
||||||
.write(true)
|
.write(true)
|
||||||
@@ -397,9 +372,8 @@ impl ModelStore {
|
|||||||
file.set_len(0)
|
file.set_len(0)
|
||||||
.context("truncate a partial we cannot resume onto")?;
|
.context("truncate a partial we cannot resume onto")?;
|
||||||
}
|
}
|
||||||
// Written before the body, so an interrupted download leaves a
|
// Written before the body, so an interrupted download leaves a partial
|
||||||
// partial that can still say which revision it belongs to. That is
|
// that can still say which revision it belongs to.
|
||||||
// what makes it safe to keep one across a restart of this server.
|
|
||||||
if let Some(etag) = &etag {
|
if let Some(etag) = &etag {
|
||||||
std::fs::write(&identity, etag).ok();
|
std::fs::write(&identity, etag).ok();
|
||||||
}
|
}
|
||||||
@@ -425,12 +399,10 @@ impl ModelStore {
|
|||||||
file.flush().context("flushing the model file")?;
|
file.flush().context("flushing the model file")?;
|
||||||
drop(file);
|
drop(file);
|
||||||
|
|
||||||
// Checked before the rename, so a file that fails never gets the
|
// Checked before the rename, so a file that fails never gets the real
|
||||||
// real name and `list` never offers it. With the identity check
|
// name and `list` never offers it. With the identity check above this
|
||||||
// above this should not fire; it is here because a download of
|
// should not fire; it is here because a wrong model is the kind of
|
||||||
// this size has too many ways to go subtly wrong to take on
|
// failure that surfaces as bad output rather than as an error.
|
||||||
// trust, and because a wrong model is the kind of failure that
|
|
||||||
// surfaces as bad output rather than as an error.
|
|
||||||
if let Some(expected) = published_sha256(&run.repo, &run.file) {
|
if let Some(expected) = published_sha256(&run.repo, &run.file) {
|
||||||
run.progress.lock().unwrap().state = DownloadState::Verifying;
|
run.progress.lock().unwrap().state = DownloadState::Verifying;
|
||||||
let actual = sha256_of(&partial)?;
|
let actual = sha256_of(&partial)?;
|
||||||
@@ -446,8 +418,8 @@ impl ModelStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Renamed only once complete, so a file at its real name is always
|
// Renamed only once complete, so a file at its real name is always a
|
||||||
// a whole model -- `list` needs no other way to tell.
|
// whole model -- `list` needs no other way to tell.
|
||||||
std::fs::rename(&partial, target)
|
std::fs::rename(&partial, target)
|
||||||
.with_context(|| format!("finish {}", target.display()))?;
|
.with_context(|| format!("finish {}", target.display()))?;
|
||||||
std::fs::remove_file(&identity).ok();
|
std::fs::remove_file(&identity).ok();
|
||||||
@@ -455,9 +427,8 @@ impl ModelStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The sha256 of a file, read in chunks -- these are gigabytes, and
|
/// The sha256 of a file, read in chunks -- these are gigabytes, and reading one
|
||||||
/// reading one into memory to hash it would be the largest allocation this
|
/// into memory to hash it would be the largest allocation this server makes.
|
||||||
/// server ever makes.
|
|
||||||
fn sha256_of(path: &Path) -> Result<String> {
|
fn sha256_of(path: &Path) -> Result<String> {
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
let mut file =
|
let mut file =
|
||||||
@@ -471,8 +442,8 @@ fn sha256_of(path: &Path) -> Result<String> {
|
|||||||
}
|
}
|
||||||
hasher.update(&buffer[..read]);
|
hasher.update(&buffer[..read]);
|
||||||
}
|
}
|
||||||
// Hex by hand, as wg_app_link::enroll::token_hash_hex also has to,
|
// Hex by hand, as `wg_app_link::enroll::token_hash_hex` also has to, since
|
||||||
// since this sha2 version's output type does not implement LowerHex.
|
// this sha2 version's output type does not implement LowerHex.
|
||||||
Ok(hasher
|
Ok(hasher
|
||||||
.finalize()
|
.finalize()
|
||||||
.iter()
|
.iter()
|
||||||
@@ -487,9 +458,8 @@ fn request(url: &str, from: u64) -> Result<(ureq::http::Response<ureq::Body>, bo
|
|||||||
get = get.header("Range", &format!("bytes={from}-"));
|
get = get.header("Range", &format!("bytes={from}-"));
|
||||||
}
|
}
|
||||||
let response = get.call().with_context(|| format!("GET {url}"))?;
|
let response = get.call().with_context(|| format!("GET {url}"))?;
|
||||||
// Trust the status, not the request: a server that ignores Range
|
// Trust the status, not the request: a server that ignores Range answers
|
||||||
// answers 200 with the whole file, and appending to that would
|
// 200 with the whole file, and appending to that would corrupt it.
|
||||||
// corrupt it.
|
|
||||||
let resumed = response.status() == 206;
|
let resumed = response.status() == 206;
|
||||||
Ok((response, resumed))
|
Ok((response, resumed))
|
||||||
}
|
}
|
||||||
@@ -506,8 +476,8 @@ fn etag_of(response: &ureq::http::Response<ureq::Body>) -> Option<String> {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `x.gguf` -> `x.gguf.part.etag`, holding which revision the partial
|
/// `x.gguf` -> `x.gguf.part.etag`, holding which revision the partial beside it
|
||||||
/// beside it is a piece of.
|
/// is a piece of.
|
||||||
fn identity_of(target: &Path) -> PathBuf {
|
fn identity_of(target: &Path) -> PathBuf {
|
||||||
let mut name = target.as_os_str().to_os_string();
|
let mut name = target.as_os_str().to_os_string();
|
||||||
name.push(".part.etag");
|
name.push(".part.etag");
|
||||||
@@ -567,18 +537,17 @@ pub struct RemoteRepo {
|
|||||||
pub struct RemoteFile {
|
pub struct RemoteFile {
|
||||||
pub path: String,
|
pub path: String,
|
||||||
pub bytes: u64,
|
pub bytes: u64,
|
||||||
/// Already on this machine, so the phone can say so rather than
|
/// Already on this machine, so the phone can say so rather than offering to
|
||||||
/// offering to fetch it again.
|
/// fetch it again.
|
||||||
pub have: bool,
|
pub have: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Searches HuggingFace for GGUF repositories matching `query`.
|
/// Searches HuggingFace for GGUF repositories matching `query`.
|
||||||
///
|
///
|
||||||
/// Proxied through this server rather than called from the phone, for two
|
/// Proxied through this server rather than called from the phone, for two
|
||||||
/// reasons that both matter: the app trusts exactly one certificate --
|
/// reasons that both matter: the app trusts exactly one certificate -- this
|
||||||
/// this server's -- and has no general internet trust to spend on
|
/// server's -- and has no general internet trust to spend on huggingface.co,
|
||||||
/// huggingface.co, and the machine that has to do the downloading is this
|
/// and the machine that has to do the downloading is this one.
|
||||||
/// one, so it is also the one whose view of what exists is relevant.
|
|
||||||
pub fn search(query: &str) -> Result<Vec<RemoteRepo>> {
|
pub fn search(query: &str) -> Result<Vec<RemoteRepo>> {
|
||||||
let url = format!(
|
let url = format!(
|
||||||
"https://huggingface.co/api/models?search={}&filter=gguf&limit=25&sort=downloads&direction=-1",
|
"https://huggingface.co/api/models?search={}&filter=gguf&limit=25&sort=downloads&direction=-1",
|
||||||
@@ -606,11 +575,9 @@ pub fn search(query: &str) -> Result<Vec<RemoteRepo>> {
|
|||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The sha256 HuggingFace publishes for one file, if it publishes one.
|
/// The sha256 HuggingFace publishes for one file, if it publishes one. It is
|
||||||
///
|
/// the LFS object id, which for these repositories is the sha256 of the content
|
||||||
/// It is the LFS object id, which for these repositories is the sha256 of
|
/// -- so it is a free integrity check rather than a second source of truth.
|
||||||
/// the content -- so it is a free integrity check on a download rather
|
|
||||||
/// than something we would have to compute a second source of truth for.
|
|
||||||
fn published_sha256(repo: &str, file: &str) -> Option<String> {
|
fn published_sha256(repo: &str, file: &str) -> Option<String> {
|
||||||
let url = format!("https://huggingface.co/api/models/{repo}/tree/main?expand=true");
|
let url = format!("https://huggingface.co/api/models/{repo}/tree/main?expand=true");
|
||||||
let body = get_json(&url).ok()?;
|
let body = get_json(&url).ok()?;
|
||||||
@@ -659,10 +626,9 @@ fn get_json(url: &str) -> Result<serde_json::Value> {
|
|||||||
serde_json::from_str(&text).with_context(|| format!("{url} did not return JSON"))
|
serde_json::from_str(&text).with_context(|| format!("{url} did not return JSON"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Percent-encodes a query string. Deliberately minimal -- this escapes
|
/// Percent-encodes a query string. Deliberately minimal -- this escapes what a
|
||||||
/// what a model search actually contains rather than implementing the
|
/// model search actually contains rather than implementing the whole rule set,
|
||||||
/// whole rule set, and anything unexpected becomes `%XX` rather than
|
/// and anything unexpected becomes `%XX` rather than being passed through.
|
||||||
/// being passed through.
|
|
||||||
fn urlencode(value: &str) -> String {
|
fn urlencode(value: &str) -> String {
|
||||||
value
|
value
|
||||||
.bytes()
|
.bytes()
|
||||||
|
|||||||
+266
-341
File diff suppressed because it is too large.
Load diff
+323
-460
File diff suppressed because it is too large.
Load diff
@@ -1,16 +1,13 @@
|
|||||||
//! The stream-json dialect: CLI lines in, common [`Event`]s out.
|
//! The stream-json dialect: CLI lines in, common [`Event`]s out.
|
||||||
//!
|
//!
|
||||||
//! Split from the driver beside it because the two change for unrelated
|
//! Split from the driver beside it because the two change for unrelated
|
||||||
//! reasons. This half moves when the CLI's wire format does -- a new
|
//! reasons. This half moves when the CLI's wire format does, which is what the
|
||||||
//! message subtype, a field that changed shape -- and that is what the
|
//! tests at the bottom pin by replaying recorded lines; the driver half moves
|
||||||
//! tests at the bottom pin, replaying recorded lines. The driver half
|
//! when spawning, resuming or shutting down changes.
|
||||||
//! moves when spawning, resuming or shutting down changes, and never
|
|
||||||
//! reads a line itself.
|
|
||||||
//!
|
//!
|
||||||
//! The one side effect here is saving images a tool result carries into
|
//! The one side effect here is saving images a tool result carries into the
|
||||||
//! the session directory (they would bloat the transcript as base64);
|
//! session directory; everything else is pure, which is what makes the mapping
|
||||||
//! everything else is pure, which is what makes the mapping testable
|
//! testable without a process.
|
||||||
//! without a process.
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
@@ -21,17 +18,14 @@ use super::super::driver::{Event, QuestionOption, SessionStatus, context_tokens}
|
|||||||
|
|
||||||
/// Whether this line is the CLI opening a fresh model call.
|
/// Whether this line is the CLI opening a fresh model call.
|
||||||
///
|
///
|
||||||
/// `message_start` begins one assistant message, and the CLI sends the
|
/// `message_start` begins one assistant message, and the CLI sends the previous
|
||||||
/// previous call's tool results back before it opens the next -- so this
|
/// call's tool results back before opening the next -- so this is the first
|
||||||
/// is the first moment at which anything written since the last one can
|
/// moment at which anything written since the last one can have been read.
|
||||||
/// have been read. Nothing earlier will do: the text deltas and the
|
/// Nothing earlier will do: the deltas and `tool_use` of a message *already in
|
||||||
/// `tool_use` block of a message *already in flight* keep arriving after
|
/// flight* keep arriving after a steer is written, and none of them saw it.
|
||||||
/// a steer is written, and none of them saw it.
|
|
||||||
///
|
///
|
||||||
/// Only present because the driver passes `--include-partial-messages`.
|
/// Only present because the driver passes `--include-partial-messages`, which
|
||||||
/// Without it there are no `stream_event` lines at all and this is never
|
/// is why the caller keeps a fallback that does not depend on it.
|
||||||
/// true, which is why the caller keeps a fallback that does not depend on
|
|
||||||
/// it.
|
|
||||||
pub(super) fn starts_a_model_call(message: &Value) -> bool {
|
pub(super) fn starts_a_model_call(message: &Value) -> bool {
|
||||||
message.get("type").and_then(Value::as_str) == Some("stream_event")
|
message.get("type").and_then(Value::as_str) == Some("stream_event")
|
||||||
&& message["event"].get("type").and_then(Value::as_str) == Some("message_start")
|
&& message["event"].get("type").and_then(Value::as_str) == Some("message_start")
|
||||||
@@ -46,71 +40,56 @@ pub(super) enum AnswerOutcome {
|
|||||||
Unknown,
|
Unknown,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A setting a control request asked for, held until the CLI says
|
/// A setting a control request asked for, held until the CLI says whether it
|
||||||
/// whether it took.
|
/// took. The CLI answers `set_model` with a bare success -- no value -- so the
|
||||||
///
|
|
||||||
/// The CLI answers `set_model` with a bare success -- no value -- so the
|
|
||||||
/// only way to report what was accepted is to remember what was asked.
|
/// only way to report what was accepted is to remember what was asked.
|
||||||
/// `set_permission_mode` does echo its mode back, and so does a
|
/// `set_permission_mode` does echo its mode back.
|
||||||
/// `system/status` line a moment later; both are handled where they
|
|
||||||
/// arrive, and this covers the one that says nothing.
|
|
||||||
pub(super) enum Setting {
|
pub(super) enum Setting {
|
||||||
Model(String),
|
Model(String),
|
||||||
PermissionMode(String),
|
PermissionMode(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A `can_use_tool` request we've surfaced to the phone and not yet
|
/// A `can_use_tool` request we've surfaced to the phone and not yet answered.
|
||||||
/// answered. For plain permissions there is one implicit question
|
/// For plain permissions there is one implicit question (Allow/Deny); for
|
||||||
/// (Allow/Deny); for AskUserQuestion, one per entry in `questions`.
|
/// AskUserQuestion, one per entry in `questions`.
|
||||||
struct PendingRequest {
|
struct PendingRequest {
|
||||||
request_id: String,
|
request_id: String,
|
||||||
input: Value,
|
input: Value,
|
||||||
/// Question text per sub-question, in order -- the keys the answers
|
/// Question text per sub-question, in order -- the keys the answers map
|
||||||
/// map uses. Empty for a plain permission request.
|
/// uses. Empty for a plain permission request.
|
||||||
questions: Vec<String>,
|
questions: Vec<String>,
|
||||||
answers: HashMap<String, String>,
|
answers: HashMap<String, String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Translation state: stream-json lines in, common events out. The one
|
/// Translation state: stream-json lines in, common events out.
|
||||||
/// side effect is saving images a tool result carries into the session
|
|
||||||
/// dir (they'd bloat the transcript as base64); everything else is pure,
|
|
||||||
/// so the dialect mapping is unit-testable from recorded lines.
|
|
||||||
pub(super) struct Translator {
|
pub(super) struct Translator {
|
||||||
pub(super) session_id: Option<String>,
|
pub(super) session_id: Option<String>,
|
||||||
pending: HashMap<String, PendingRequest>,
|
pending: HashMap<String, PendingRequest>,
|
||||||
/// Settings asked for and not yet answered, by request id. Its path
|
/// Settings asked for and not yet answered, by request id. Its path out is
|
||||||
/// out is the response: every entry is removed when one arrives,
|
/// the response: every entry is removed when one arrives, whether it
|
||||||
/// whether it succeeded or failed.
|
/// succeeded or failed.
|
||||||
asked: HashMap<String, Setting>,
|
asked: HashMap<String, Setting>,
|
||||||
/// Whether this side asked the turn to stop.
|
/// Whether this side asked the turn to stop.
|
||||||
///
|
///
|
||||||
/// The CLI reports an interrupted turn the same way it reports one that
|
/// The CLI reports an interrupted turn the same way it reports one that
|
||||||
/// broke -- a `result` with `is_error` set -- so the line itself cannot
|
/// broke -- a `result` with `is_error` set -- so the line cannot tell them
|
||||||
/// tell them apart, and a person who pressed Stop was shown "the turn
|
/// apart, and somebody who pressed Stop was shown "the turn ended with an
|
||||||
/// ended with an error" for doing exactly what the button says. What
|
/// error". What separates them is that *we* asked.
|
||||||
/// separates them is not in the message at all: it is that *we* asked.
|
|
||||||
/// So the driver says so before the request goes out, the same way it
|
|
||||||
/// does for a setting, and this remembers it until the result lands.
|
|
||||||
///
|
///
|
||||||
/// Its path out is that result -- set by `expect_interrupt`, cleared by
|
/// Its path out is that result, so a genuine failure in a later turn is
|
||||||
/// the next `result` whichever way it went, so a genuine failure in a
|
/// still reported.
|
||||||
/// later turn is still reported.
|
|
||||||
interrupting: bool,
|
interrupting: bool,
|
||||||
/// The input side of the newest assistant message, waiting for the
|
/// The input side of the newest assistant message, waiting for the `result`
|
||||||
/// `result` that ends the turn to carry it out.
|
/// that ends the turn to carry it out.
|
||||||
///
|
///
|
||||||
/// Read from the assistant message rather than from the result's own
|
/// Read from the assistant message rather than the result's own usage,
|
||||||
/// usage, which is the whole turn added up: measured on 2026-08-30
|
/// which is the whole turn added up: measured on 2026-08-30 against 2.1.237,
|
||||||
/// against CLI 2.1.237, a two-message turn reported
|
/// a two-message turn reported `cache_read_input_tokens` of 40,211, being
|
||||||
/// `cache_read_input_tokens` of 40,211 in its result, being 14,259 and
|
/// 14,259 and 25,952 -- the same conversation counted twice. The model held
|
||||||
/// 25,952 from the two messages -- the same conversation counted
|
/// 26,131. A turn with ten tool calls would overstate it tenfold.
|
||||||
/// twice. The model never held 40,211; it held 26,131, which is the
|
|
||||||
/// last message's three input figures. A turn with ten tool calls
|
|
||||||
/// would overstate it tenfold.
|
|
||||||
///
|
///
|
||||||
/// Its path out is that result, which takes it -- so a turn whose
|
/// Its path out is that result, so a turn whose messages carried no usage
|
||||||
/// messages carried no usage reports none rather than repeating the
|
/// reports none rather than repeating the previous turn's.
|
||||||
/// previous turn's.
|
|
||||||
context: Option<u64>,
|
context: Option<u64>,
|
||||||
session_dir: PathBuf,
|
session_dir: PathBuf,
|
||||||
}
|
}
|
||||||
@@ -128,17 +107,15 @@ impl Translator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Remembers what a control request was for, so its answer can say so.
|
/// Remembers what a control request was for, so its answer can say so.
|
||||||
///
|
/// Called before the request goes out: the reader thread is already running
|
||||||
/// Called before the request goes out, not after: the reader thread is
|
/// and a fast CLI can answer before this side gets back to it.
|
||||||
/// already running and a fast CLI can answer before this side gets
|
|
||||||
/// back to it.
|
|
||||||
pub(super) fn expect_setting(&mut self, request_id: String, setting: Setting) {
|
pub(super) fn expect_setting(&mut self, request_id: String, setting: Setting) {
|
||||||
self.asked.insert(request_id, setting);
|
self.asked.insert(request_id, setting);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Says that the turn about to end was stopped on purpose -- see
|
/// Says that the turn about to end was stopped on purpose. Called before
|
||||||
/// [`Translator::interrupting`]. Called before the request goes out,
|
/// the request goes out, for the reason [`Translator::expect_setting`]
|
||||||
/// for the reason [`Translator::expect_setting`] gives.
|
/// gives.
|
||||||
pub(super) fn expect_interrupt(&mut self) {
|
pub(super) fn expect_interrupt(&mut self) {
|
||||||
self.interrupting = true;
|
self.interrupting = true;
|
||||||
}
|
}
|
||||||
@@ -154,14 +131,11 @@ impl Translator {
|
|||||||
}
|
}
|
||||||
match message.get("type").and_then(Value::as_str) {
|
match message.get("type").and_then(Value::as_str) {
|
||||||
Some("system") => self.translate_system(message),
|
Some("system") => self.translate_system(message),
|
||||||
// The CLI's own announcement that `/clear` took effect, sent
|
// The CLI's own announcement that `/clear` took effect, sent just
|
||||||
// just before the fresh `init` that carries the new
|
// before the fresh `init` carrying the new session_id. Measured
|
||||||
// session_id. Measured against 2.1.237 rather than inferred:
|
// against 2.1.237: this used to watch for the id being *replaced*,
|
||||||
// this used to watch for the id being *replaced*, which is the
|
// which is the same event seen through a side effect. The
|
||||||
// same event seen through one of its side effects. Taking the
|
// announcement lands before the new init rather than after it.
|
||||||
// announcement instead means the transcript's divider is the
|
|
||||||
// CLI saying "I did this", and it lands before the new init
|
|
||||||
// rather than after it.
|
|
||||||
Some("conversation_reset") => vec![Event::Cleared],
|
Some("conversation_reset") => vec![Event::Cleared],
|
||||||
Some("stream_event") => self.translate_stream_event(&message["event"]),
|
Some("stream_event") => self.translate_stream_event(&message["event"]),
|
||||||
Some("assistant") => self.translate_assistant(&message["message"]),
|
Some("assistant") => self.translate_assistant(&message["message"]),
|
||||||
@@ -170,8 +144,8 @@ impl Translator {
|
|||||||
Some("control_response") => {
|
Some("control_response") => {
|
||||||
let response = &message["response"];
|
let response = &message["response"];
|
||||||
// Answered either way, so the request stops being pending
|
// Answered either way, so the request stops being pending
|
||||||
// either way -- a rejected setting that stayed here would
|
// either way -- a rejected setting that stayed here would be
|
||||||
// be applied by the next request that reused its id.
|
// applied by the next request that reused its id.
|
||||||
let asked = response
|
let asked = response
|
||||||
.get("request_id")
|
.get("request_id")
|
||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
@@ -185,9 +159,9 @@ impl Translator {
|
|||||||
message: format!("claude rejected a request: {error}"),
|
message: format!("claude rejected a request: {error}"),
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
// Success, so the setting this request asked for is now
|
// Success, so the setting this request asked for is now the
|
||||||
// the session's, and this is the only place that says so:
|
// session's, and this is the only place that says so: the
|
||||||
// the response carries no value of its own for a model.
|
// response carries no value of its own for a model.
|
||||||
match asked {
|
match asked {
|
||||||
Some(Setting::Model(model)) => vec![Event::Settings {
|
Some(Setting::Model(model)) => vec![Event::Settings {
|
||||||
model: Some(model),
|
model: Some(model),
|
||||||
@@ -195,11 +169,10 @@ impl Translator {
|
|||||||
}],
|
}],
|
||||||
Some(Setting::PermissionMode(mode)) => vec![Event::Settings {
|
Some(Setting::PermissionMode(mode)) => vec![Event::Settings {
|
||||||
model: None,
|
model: None,
|
||||||
// The CLI echoes this one, and its answer wins:
|
// The CLI echoes this one, and its answer wins: `auto`
|
||||||
// `auto` and `manual` are names it accepts on the
|
// and `manual` are names it accepts on the way in and
|
||||||
// way in and reports back under another name, so
|
// reports back under another name, so repeating the
|
||||||
// repeating the request here would show a mode the
|
// request would show a mode the session is not in.
|
||||||
// session is not in.
|
|
||||||
permission_mode: Some(
|
permission_mode: Some(
|
||||||
response["response"]["mode"]
|
response["response"]["mode"]
|
||||||
.as_str()
|
.as_str()
|
||||||
@@ -223,28 +196,22 @@ impl Translator {
|
|||||||
let mut events = Vec::new();
|
let mut events = Vec::new();
|
||||||
// A turn another agent started, which is only knowable here.
|
// A turn another agent started, which is only knowable here.
|
||||||
//
|
//
|
||||||
// Measured against CLI 2.1.237 (2026-08-31) by sending a
|
// Measured against 2.1.237 (2026-08-31) by sending a real
|
||||||
// real cross-session message to a real stream-json session:
|
// cross-session message to a real stream-json session: the CLI
|
||||||
// the CLI emits no `user` record for it, and nothing in the
|
// emits no `user` record for it and nothing in the
|
||||||
// partial-message stream mentions it either. The whole of
|
// partial-message stream mentions it. The whole of it arrives as
|
||||||
// it arrives as an `origin` object on the turn's `result`,
|
// an `origin` object on the turn's `result`, in the same shape
|
||||||
// in the same shape the session file records -- so this is
|
// the session file records -- so this is `import::peer_message`
|
||||||
// `import::peer_message` reading a different record.
|
// reading a different record.
|
||||||
//
|
//
|
||||||
// The cost is the position: the note lands after the reply
|
// The cost is the position: the note lands after the reply it
|
||||||
// it caused rather than above it, because at no earlier
|
// caused, because at no earlier point does the CLI say why the
|
||||||
// point in the turn does the CLI say why the turn started.
|
// turn started. Taken deliberately over a second reader tailing
|
||||||
// Taken deliberately over the alternative, which is a
|
// the CLI's own session file, which is two sources of truth for
|
||||||
// second reader tailing the CLI's own session file for the
|
// one conversation and a poll per live session.
|
||||||
// one record stdout does not carry -- two sources of truth
|
|
||||||
// for one conversation, and a poll per live session. What
|
|
||||||
// it buys is the thing that was missing entirely: a session
|
|
||||||
// that starts working on something nobody on this phone
|
|
||||||
// asked for is otherwise unexplainable from the phone.
|
|
||||||
//
|
//
|
||||||
// Only peer-caused turns carry it: measured over a real
|
// Only peer-caused turns carry it: four ordinary results over a
|
||||||
// session's stdout, four ordinary results and no `origin`
|
// real session's stdout had no `origin` between them.
|
||||||
// between them.
|
|
||||||
if let Some(peer) = crate::session::import::peer_message(message) {
|
if let Some(peer) = crate::session::import::peer_message(message) {
|
||||||
events.push(peer);
|
events.push(peer);
|
||||||
}
|
}
|
||||||
@@ -278,37 +245,35 @@ impl Translator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The CLI's own notices: which session this is, and what it is doing
|
/// The CLI's own notices: which session this is, and what it is doing that
|
||||||
/// that is not a turn.
|
/// is not a turn.
|
||||||
///
|
///
|
||||||
/// Compaction is the whole of that second kind, and it is announced
|
/// Compaction is the whole of that second kind, and it is announced rather
|
||||||
/// rather than inferred. Measured against CLI 2.1.237 (2026-08-29) by
|
/// than inferred. Measured against 2.1.237 (2026-08-29) by driving a session
|
||||||
/// driving a session through `/compact`, one produces in order:
|
/// through `/compact`, one produces in order:
|
||||||
///
|
///
|
||||||
/// - `{"subtype":"status","status":"compacting"}` -- the start;
|
/// - `{"subtype":"status","status":"compacting"}` -- the start;
|
||||||
/// - `{"subtype":"status","status":null,"compact_result":"success"}`,
|
/// - `{"subtype":"status","status":null,"compact_result":"success"}`, or
|
||||||
/// or `"failed"` with a `compact_error` saying why -- the end;
|
/// `"failed"` with a `compact_error` -- the end;
|
||||||
/// - a fresh `init` carrying the same `session_id`;
|
/// - a fresh `init` carrying the same `session_id`;
|
||||||
/// - `{"subtype":"compact_boundary","compact_metadata":{…}}` with the
|
/// - `{"subtype":"compact_boundary","compact_metadata":{…}}` with the token
|
||||||
/// token counts, and only when it succeeded;
|
/// counts, and only when it succeeded;
|
||||||
/// - the turn's ordinary `result`, which is what returns it to idle.
|
/// - the turn's ordinary `result`, which returns it to idle.
|
||||||
///
|
///
|
||||||
/// The keys are snake_case here and camelCase in the CLI's own
|
/// The keys are snake_case here and camelCase in the CLI's own transcript
|
||||||
/// transcript file, which records the same events. Reading the shape
|
/// file, which records the same events -- so reading the shape off that
|
||||||
/// off that file -- the obvious place to find one, since it is on
|
/// file, the obvious place to look, gets every field name wrong and
|
||||||
/// disk -- gets every field name wrong and silently yields a
|
/// silently yields a compaction with no numbers in it.
|
||||||
/// compaction with no numbers in it.
|
|
||||||
fn translate_system(&mut self, message: &Value) -> Vec<Event> {
|
fn translate_system(&mut self, message: &Value) -> Vec<Event> {
|
||||||
match message.get("subtype").and_then(Value::as_str) {
|
match message.get("subtype").and_then(Value::as_str) {
|
||||||
Some("init") => {
|
Some("init") => {
|
||||||
if let Some(id) = message.get("session_id").and_then(Value::as_str) {
|
if let Some(id) = message.get("session_id").and_then(Value::as_str) {
|
||||||
self.session_id = Some(id.to_string());
|
self.session_id = Some(id.to_string());
|
||||||
}
|
}
|
||||||
// The CLI's own account of what it is set to, and the only
|
// The CLI's own account of what it is set to, and the only one
|
||||||
// one that resolves an alias: a session launched with
|
// that resolves an alias: a session launched with
|
||||||
// `--model haiku` reports `claude-haiku-4-5-20251001`
|
// `--model haiku` reports `claude-haiku-4-5-20251001` here. It
|
||||||
// here. It arrives again after a compaction, which is
|
// arrives again after a compaction, which is free.
|
||||||
// free -- the manager drops a setting it is already in.
|
|
||||||
vec![Event::Settings {
|
vec![Event::Settings {
|
||||||
model: message
|
model: message
|
||||||
.get("model")
|
.get("model")
|
||||||
@@ -336,22 +301,19 @@ impl Translator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A `system/status` line: the CLI entering or leaving a state that is
|
/// A `system/status` line: the CLI entering or leaving a state that is not
|
||||||
/// not a turn.
|
/// a turn.
|
||||||
///
|
///
|
||||||
/// A null `status` is the leaving edge, and it carries how the thing
|
/// A null `status` is the leaving edge, and it carries how the thing went.
|
||||||
/// went. Whatever it was, the turn it happened inside is still going
|
/// The turn it happened inside is still going when it ends -- the `result`
|
||||||
/// when it ends -- the `result` has not arrived yet -- so leaving says
|
/// has not arrived -- so leaving says `Running`. A state this build does
|
||||||
/// `Running`, which is also the only place in this file that does. A
|
/// not recognise is left alone rather than mapped onto the nearest one.
|
||||||
/// state this build does not recognise is left alone rather than
|
|
||||||
/// mapped onto the nearest one we do.
|
|
||||||
fn translate_status(&self, message: &Value) -> Vec<Event> {
|
fn translate_status(&self, message: &Value) -> Vec<Event> {
|
||||||
// A mode change the CLI has made, announced a moment after it
|
// A mode change the CLI has made, announced a moment after it answers
|
||||||
// answers the request that asked for it. Measured on 2.1.237:
|
// the request. Measured on 2.1.237:
|
||||||
// `{"subtype":"status","status":null,"permissionMode":"plan"}`,
|
// `{"subtype":"status","status":null,"permissionMode":"plan"}`, which
|
||||||
// which is a leaving edge carrying no compaction result -- so it
|
// is a leaving edge carrying no compaction result -- so it is checked
|
||||||
// is checked before the compaction reading below, which would
|
// before the compaction reading below.
|
||||||
// otherwise fall through to nothing.
|
|
||||||
if let Some(mode) = message.get("permissionMode").and_then(Value::as_str) {
|
if let Some(mode) = message.get("permissionMode").and_then(Value::as_str) {
|
||||||
return vec![Event::Settings {
|
return vec![Event::Settings {
|
||||||
model: None,
|
model: None,
|
||||||
@@ -371,8 +333,8 @@ impl Translator {
|
|||||||
};
|
};
|
||||||
let mut events = Vec::new();
|
let mut events = Vec::new();
|
||||||
if result != "success" {
|
if result != "success" {
|
||||||
// The CLI's own sentence, because it is specific enough to act
|
// The CLI's own sentence, because it is specific enough to act on:
|
||||||
// on: "Not enough messages to compact." is a complete answer.
|
// "Not enough messages to compact." is a complete answer.
|
||||||
events.push(Event::Error {
|
events.push(Event::Error {
|
||||||
message: match message.get("compact_error").and_then(Value::as_str) {
|
message: match message.get("compact_error").and_then(Value::as_str) {
|
||||||
Some(why) => format!("compaction failed: {why}"),
|
Some(why) => format!("compaction failed: {why}"),
|
||||||
@@ -386,9 +348,9 @@ impl Translator {
|
|||||||
events
|
events
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Raw API streaming: only text deltas become events. Consolidated
|
/// Raw API streaming: only text deltas become events. Consolidated blocks
|
||||||
/// blocks arriving later re-carry the same text, so those are skipped
|
/// arriving later re-carry the same text, so those are skipped in
|
||||||
/// in `translate_assistant` -- one source per fact.
|
/// `translate_assistant` -- one source per fact.
|
||||||
fn translate_stream_event(&mut self, event: &Value) -> Vec<Event> {
|
fn translate_stream_event(&mut self, event: &Value) -> Vec<Event> {
|
||||||
if event.get("type").and_then(Value::as_str) == Some("content_block_delta")
|
if event.get("type").and_then(Value::as_str) == Some("content_block_delta")
|
||||||
&& let Some(delta) = event["delta"].get("text")
|
&& let Some(delta) = event["delta"].get("text")
|
||||||
@@ -448,9 +410,8 @@ impl Translator {
|
|||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
.unwrap_or("a tool");
|
.unwrap_or("a tool");
|
||||||
let input = request.get("input").cloned().unwrap_or(Value::Null);
|
let input = request.get("input").cloned().unwrap_or(Value::Null);
|
||||||
// Measured, not matched: the request names the call it is about, so
|
// Measured, not matched: the request names the call it is about, so the
|
||||||
// the phone never has to guess which tool row a permission belongs
|
// phone never has to guess which tool row a permission belongs to.
|
||||||
// to by comparing inputs.
|
|
||||||
let about = request
|
let about = request
|
||||||
.get("tool_use_id")
|
.get("tool_use_id")
|
||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
@@ -471,11 +432,10 @@ impl Translator {
|
|||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
.unwrap_or("(question)")
|
.unwrap_or("(question)")
|
||||||
.to_string();
|
.to_string();
|
||||||
// Everything the reader decides on, carried in the event.
|
// Everything the reader decides on, carried in the event. The
|
||||||
// The alternative -- and what this was -- is the phone
|
// alternative -- and what this was -- is the phone reaching into
|
||||||
// reaching into the tool call's input for the parts the
|
// the tool call's input for the parts the event dropped, which
|
||||||
// event dropped, which puts this dialect's schema in the
|
// puts this dialect's schema where no other dialect can reach it.
|
||||||
// app where no other dialect can reach it.
|
|
||||||
let options = question
|
let options = question
|
||||||
.get("options")
|
.get("options")
|
||||||
.and_then(Value::as_array)
|
.and_then(Value::as_array)
|
||||||
@@ -499,12 +459,10 @@ impl Translator {
|
|||||||
.and_then(Value::as_bool)
|
.and_then(Value::as_bool)
|
||||||
.unwrap_or(false),
|
.unwrap_or(false),
|
||||||
// The call that is asking, so all of this draws as one
|
// The call that is asking, so all of this draws as one
|
||||||
// thing. It used to be `None` on the grounds that a
|
// thing. It used to be `None` on the grounds that a question
|
||||||
// question the model asked is not permission for a
|
// the model asked is not permission for a call -- true, and
|
||||||
// call -- true, and beside the point: the reader was
|
// beside the point: the reader was shown the AskUserQuestion
|
||||||
// shown the AskUserQuestion call *and* its questions
|
// call *and* its questions as two separate cards.
|
||||||
// as two separate cards for one event, and the call
|
|
||||||
// itself said nothing they could act on.
|
|
||||||
about: about.clone(),
|
about: about.clone(),
|
||||||
});
|
});
|
||||||
questions.push(text);
|
questions.push(text);
|
||||||
@@ -515,8 +473,8 @@ impl Translator {
|
|||||||
events.push(Event::Question {
|
events.push(Event::Question {
|
||||||
id: request_id.clone(),
|
id: request_id.clone(),
|
||||||
prompt: format!("Allow {tool_name}?\n{summary}"),
|
prompt: format!("Allow {tool_name}?\n{summary}"),
|
||||||
// No header: the question is about the call it names, and
|
// No header: the question is about the call it names, and the
|
||||||
// the phone draws it on that call's own row.
|
// phone draws it on that call's own row.
|
||||||
header: None,
|
header: None,
|
||||||
options: vec![
|
options: vec![
|
||||||
QuestionOption::plain("Allow"),
|
QuestionOption::plain("Allow"),
|
||||||
@@ -541,13 +499,13 @@ impl Translator {
|
|||||||
events
|
events
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Applies one answer from the phone. Question ids are the control
|
/// Applies one answer from the phone. Question ids are the control request
|
||||||
/// request id, suffixed `#i` for AskUserQuestion sub-questions.
|
/// id, suffixed `#i` for AskUserQuestion sub-questions.
|
||||||
pub(super) fn answer(&mut self, question_id: &str, answers: &[String]) -> AnswerOutcome {
|
pub(super) fn answer(&mut self, question_id: &str, answers: &[String]) -> AnswerOutcome {
|
||||||
// Where this dialect's shape is put on: the CLI's `answers` map is
|
// Where this dialect's shape is put on: the CLI's `answers` map is
|
||||||
// string-valued whatever the question, so several choices become
|
// string-valued whatever the question, so several choices become one
|
||||||
// one line here rather than everything upstream pretending a
|
// line here rather than everything upstream pretending a question can
|
||||||
// question can only ever have one answer.
|
// only ever have one answer.
|
||||||
let answer = answers.join(", ");
|
let answer = answers.join(", ");
|
||||||
let answer = answer.as_str();
|
let answer = answer.as_str();
|
||||||
let (request_id, sub) = match question_id.split_once('#') {
|
let (request_id, sub) = match question_id.split_once('#') {
|
||||||
@@ -583,17 +541,15 @@ impl Translator {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `user` messages: tool results become ToolEnd, with any image parts
|
/// `user` messages: tool results become ToolEnd, with any image parts saved
|
||||||
/// saved into the session dir and referenced by an Image event (the
|
/// into the session dir and referenced by an Image event. Replayed and
|
||||||
/// phone fetches them from `/sessions/{id}/files/{ref}`). Replayed and
|
|
||||||
/// synthetic user text is skipped -- the manager already recorded the
|
/// synthetic user text is skipped -- the manager already recorded the
|
||||||
/// user's side.
|
/// user's side.
|
||||||
fn translate_user(&self, message: &Value) -> Vec<Event> {
|
fn translate_user(&self, message: &Value) -> Vec<Event> {
|
||||||
// Only tool results are here. The CLI never echoes a person's own
|
// Only tool results are here. The CLI never echoes a person's own
|
||||||
// message back on stdout -- measured, because the obvious way to
|
// message back on stdout -- measured, because the obvious way to learn
|
||||||
// learn that a queued message had been taken was to watch for it
|
// that a queued message had been taken was to watch for it coming back
|
||||||
// coming back -- so nothing in this function marks one as read.
|
// -- so the driver reports that itself, at the line it writes.
|
||||||
// The driver reports that itself, at the line it writes.
|
|
||||||
let Some(content) = message["message"].get("content").and_then(Value::as_array) else {
|
let Some(content) = message["message"].get("content").and_then(Value::as_array) else {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
};
|
};
|
||||||
@@ -603,9 +559,8 @@ impl Translator {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let mut texts = Vec::new();
|
let mut texts = Vec::new();
|
||||||
// Held until the call's id is in hand a few lines below: an
|
// Held until the call's id is in hand a few lines below: an image is
|
||||||
// image is drawn under the call that produced it, so it has to
|
// drawn under the call that produced it, so it has to carry that id.
|
||||||
// carry that id rather than merely arrive next to it.
|
|
||||||
let mut images = Vec::new();
|
let mut images = Vec::new();
|
||||||
match block.get("content") {
|
match block.get("content") {
|
||||||
Some(Value::String(text)) => texts.push(text.clone()),
|
Some(Value::String(text)) => texts.push(text.clone()),
|
||||||
@@ -648,11 +603,9 @@ impl Translator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A string field that is there and not empty, or `None`.
|
/// A string field that is there and not empty, or `None`. The CLI omits these
|
||||||
///
|
/// rather than sending them empty, but a caller that sends `""` means the same
|
||||||
/// The CLI omits these rather than sending them empty, but a caller that
|
/// thing and should not produce a description that draws as a blank line.
|
||||||
/// sends `""` means the same thing and should not produce a description
|
|
||||||
/// that draws as a blank line.
|
|
||||||
fn text_field(value: &Value, name: &str) -> Option<String> {
|
fn text_field(value: &Value, name: &str) -> Option<String> {
|
||||||
value
|
value
|
||||||
.get(name)
|
.get(name)
|
||||||
@@ -663,11 +616,9 @@ fn text_field(value: &Value, name: &str) -> Option<String> {
|
|||||||
|
|
||||||
/// Decodes one base64 image block into `files/` and returns its ref.
|
/// Decodes one base64 image block into `files/` and returns its ref.
|
||||||
///
|
///
|
||||||
/// A free function rather than a method because the import replay needs
|
/// A free function rather than a method because the import replay needs exactly
|
||||||
/// exactly this too: a session's history carries the same image blocks as
|
/// this too: a session's history carries the same image blocks as its live
|
||||||
/// its live output, and a reader who can see a screenshot while it happens
|
/// output. Two copies would be two naming schemes for one directory.
|
||||||
/// should still see it after a restart. Two copies of this would be two
|
|
||||||
/// naming schemes for one directory.
|
|
||||||
pub(in crate::session) fn save_image(session_dir: &Path, part: &Value) -> Option<String> {
|
pub(in crate::session) fn save_image(session_dir: &Path, part: &Value) -> Option<String> {
|
||||||
let source = part.get("source")?;
|
let source = part.get("source")?;
|
||||||
let data = source.get("data")?.as_str()?;
|
let data = source.get("data")?.as_str()?;
|
||||||
@@ -675,8 +626,8 @@ pub(in crate::session) fn save_image(session_dir: &Path, part: &Value) -> Option
|
|||||||
let bytes = base64::engine::general_purpose::STANDARD
|
let bytes = base64::engine::general_purpose::STANDARD
|
||||||
.decode(data)
|
.decode(data)
|
||||||
.ok()?;
|
.ok()?;
|
||||||
// Screenshots are the overwhelming case, and they are PNG; an
|
// Screenshots are the overwhelming case and they are PNG; an unrecognized
|
||||||
// unrecognized type is more likely a dialect change than a JPEG.
|
// type is more likely a dialect change than a JPEG.
|
||||||
let extension = source
|
let extension = source
|
||||||
.get("media_type")
|
.get("media_type")
|
||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
@@ -726,8 +677,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert_eq!(translator.session_id.as_deref(), Some("5ecf21da-d53f"));
|
assert_eq!(translator.session_id.as_deref(), Some("5ecf21da-d53f"));
|
||||||
// The resolved model, which is the point: a session launched with
|
// The resolved model, which is the point: a session launched with
|
||||||
// `--model haiku` is reported by its full name here, and that is
|
// `--model haiku` is reported by its full name here.
|
||||||
// the name the phone should be showing.
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
events,
|
events,
|
||||||
vec![Event::Settings {
|
vec![Event::Settings {
|
||||||
@@ -749,8 +699,8 @@ mod tests {
|
|||||||
Setting::PermissionMode("plan".to_string()),
|
Setting::PermissionMode("plan".to_string()),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Success carries no model of its own -- measured on 2.1.237 --
|
// Success carries no model of its own -- measured on 2.1.237 -- so what
|
||||||
// so what was asked for is the only answer available.
|
// was asked for is the only answer available.
|
||||||
let events = translate_lines(
|
let events = translate_lines(
|
||||||
&mut translator,
|
&mut translator,
|
||||||
&[
|
&[
|
||||||
@@ -765,9 +715,8 @@ mod tests {
|
|||||||
}]
|
}]
|
||||||
);
|
);
|
||||||
|
|
||||||
// A mode the CLI answers with a value of its own is taken from
|
// A mode the CLI answers with a value of its own is taken from that
|
||||||
// that value: `auto` on the way in is `default` coming back, and
|
// value: `auto` on the way in is `default` coming back.
|
||||||
// the request is not the answer.
|
|
||||||
translator.expect_setting(
|
translator.expect_setting(
|
||||||
"req-c".to_string(),
|
"req-c".to_string(),
|
||||||
Setting::PermissionMode("auto".to_string()),
|
Setting::PermissionMode("auto".to_string()),
|
||||||
@@ -801,8 +750,8 @@ mod tests {
|
|||||||
}]
|
}]
|
||||||
);
|
);
|
||||||
|
|
||||||
// And neither request is still waiting: a second answer to either
|
// And neither request is still waiting: a second answer to either id
|
||||||
// id reports nothing at all.
|
// reports nothing at all.
|
||||||
let events = translate_lines(
|
let events = translate_lines(
|
||||||
&mut translator,
|
&mut translator,
|
||||||
&[
|
&[
|
||||||
@@ -815,8 +764,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_mode_the_cli_announces_is_taken_from_the_announcement() {
|
fn a_mode_the_cli_announces_is_taken_from_the_announcement() {
|
||||||
// The line it sends just after answering `set_permission_mode`,
|
// The line it sends just after answering `set_permission_mode`, which is
|
||||||
// which is also how a mode changed from the terminal arrives.
|
// also how a mode changed from the terminal arrives.
|
||||||
let dir = tempfile::tempdir().expect("tempdir");
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
let mut translator = Translator::new(dir.path().to_path_buf());
|
let mut translator = Translator::new(dir.path().to_path_buf());
|
||||||
let events = translate_lines(
|
let events = translate_lines(
|
||||||
@@ -916,8 +865,8 @@ mod tests {
|
|||||||
panic!("expected a question, got {events:?}");
|
panic!("expected a question, got {events:?}");
|
||||||
};
|
};
|
||||||
assert_eq!(id, "req-1");
|
assert_eq!(id, "req-1");
|
||||||
// The call being asked about, so the phone draws the ask on that
|
// The call being asked about, so the phone draws the ask on that tool's
|
||||||
// tool's row instead of as a second card repeating its input.
|
// row instead of as a second card repeating its input.
|
||||||
assert_eq!(about.as_deref(), Some("toolu_03"));
|
assert_eq!(about.as_deref(), Some("toolu_03"));
|
||||||
assert!(prompt.contains("Bash") && prompt.contains("rm -rf /tmp/x"));
|
assert!(prompt.contains("Bash") && prompt.contains("rm -rf /tmp/x"));
|
||||||
assert_eq!(labels(options), ["Allow", "Deny"]);
|
assert_eq!(labels(options), ["Allow", "Deny"]);
|
||||||
@@ -1013,10 +962,9 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn a_question_carries_what_it_takes_to_answer_it() {
|
fn a_question_carries_what_it_takes_to_answer_it() {
|
||||||
// Descriptions and previews are what the reader decides on, and a
|
// Descriptions and previews are what the reader decides on, and a
|
||||||
// multi-select is how many answers the question takes. All of it
|
// multi-select is how many answers the question takes. All of it travels
|
||||||
// travels in the event: a phone that had to read this dialect's
|
// in the event: a phone that had to read this dialect's tool input to
|
||||||
// tool input to find them would be the only place that knew how,
|
// find them would be the only place that knew how.
|
||||||
// and no other provider could reach it.
|
|
||||||
let dir = tempfile::tempdir().expect("tempdir");
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
let mut translator = Translator::new(dir.path().to_path_buf());
|
let mut translator = Translator::new(dir.path().to_path_buf());
|
||||||
let events = translate_lines(
|
let events = translate_lines(
|
||||||
@@ -1049,8 +997,8 @@ mod tests {
|
|||||||
.contains("dev-updater")
|
.contains("dev-updater")
|
||||||
);
|
);
|
||||||
|
|
||||||
// Two choices, one answer: the joining is this dialect's shape,
|
// Two choices, one answer: the joining is this dialect's shape, done
|
||||||
// done where it is spoken. The CLI's answers map holds strings.
|
// where it is spoken. The CLI's answers map holds strings.
|
||||||
let AnswerOutcome::Respond(response) = translator.answer(
|
let AnswerOutcome::Respond(response) = translator.answer(
|
||||||
"req-9#0",
|
"req-9#0",
|
||||||
&["Tool calls".to_string(), "Peer messages".to_string()],
|
&["Tool calls".to_string(), "Peer messages".to_string()],
|
||||||
@@ -1078,8 +1026,8 @@ mod tests {
|
|||||||
panic!("expected an image event, got {events:?}");
|
panic!("expected an image event, got {events:?}");
|
||||||
};
|
};
|
||||||
assert!(image.ends_with(".png"));
|
assert!(image.ends_with(".png"));
|
||||||
// Named as belonging to the call that produced it, so a phone draws
|
// Named as belonging to the call that produced it, so a phone draws it
|
||||||
// it under that row rather than beside it.
|
// under that row rather than beside it.
|
||||||
assert_eq!(about.as_deref(), Some("toolu_05"));
|
assert_eq!(about.as_deref(), Some("toolu_05"));
|
||||||
let saved = dir.path().join("files").join(image);
|
let saved = dir.path().join("files").join(image);
|
||||||
assert!(saved.is_file(), "image not saved at {}", saved.display());
|
assert!(saved.is_file(), "image not saved at {}", saved.display());
|
||||||
@@ -1118,19 +1066,15 @@ mod tests {
|
|||||||
|
|
||||||
/// A turn another agent started says so, on the record that carries it.
|
/// A turn another agent started says so, on the record that carries it.
|
||||||
///
|
///
|
||||||
/// The line is the real shape, taken from a real cross-session message
|
/// The line is the real shape, taken from a real cross-session message sent
|
||||||
/// sent to a real stream-json session on CLI 2.1.237 (2026-08-31) --
|
/// to a real stream-json session on 2.1.237 (2026-08-31) -- including the
|
||||||
/// including the `from` socket path, which is deliberately *not* what a
|
/// `from` socket path, which is deliberately *not* what a reader is shown:
|
||||||
/// reader is shown: the sending session's `name` is what they recognise
|
/// the sending session's `name` is what they recognise it by. The `body` is
|
||||||
/// it by. The `body` is the message as it was written; the content the
|
/// the message as written; the content the model is given wraps the same
|
||||||
/// model is given beside it wraps the same text in a preamble and a
|
/// text in a preamble written for the model rather than for a person.
|
||||||
/// `<cross-session-message>` tag, which is written for the model rather
|
|
||||||
/// than for a person.
|
|
||||||
///
|
///
|
||||||
/// The note comes before the usage and the idle, so it sits as close to
|
/// The note comes before the usage and the idle, so it sits as close to the
|
||||||
/// the turn it explains as the wire allows -- which is after the reply,
|
/// turn it explains as the wire allows.
|
||||||
/// not above it. See the comment at the callsite for why that is the
|
|
||||||
/// best available position rather than an oversight.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_turn_started_by_another_agent_records_who_and_what() {
|
fn a_turn_started_by_another_agent_records_who_and_what() {
|
||||||
let dir = tempfile::tempdir().expect("tempdir");
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
@@ -1147,8 +1091,8 @@ mod tests {
|
|||||||
Event::PeerMessage {
|
Event::PeerMessage {
|
||||||
from: "ai-app-2-fb".to_string(),
|
from: "ai-app-2-fb".to_string(),
|
||||||
text: "Reply with just the word ACK.".to_string(),
|
text: "Reply with just the word ACK.".to_string(),
|
||||||
// Stamped by the pump, which is the only place that
|
// Stamped by the pump, which is the only place that knows
|
||||||
// knows what seq the turn started at.
|
// what seq the turn started at.
|
||||||
turn_start: None,
|
turn_start: None,
|
||||||
},
|
},
|
||||||
Event::UsageDelta {
|
Event::UsageDelta {
|
||||||
@@ -1162,9 +1106,9 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// And an ordinary turn does not, which is the half that decides
|
/// And an ordinary turn does not, which is the half that decides whether
|
||||||
/// whether the check above is a check or a rubber stamp. Measured over
|
/// the check above is a check or a rubber stamp. Measured over a real
|
||||||
/// a real session's stdout: four results, no `origin` between them.
|
/// session's stdout: four results, no `origin` between them.
|
||||||
#[test]
|
#[test]
|
||||||
fn an_ordinary_turn_carries_no_peer_note() {
|
fn an_ordinary_turn_carries_no_peer_note() {
|
||||||
let dir = tempfile::tempdir().expect("tempdir");
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
@@ -1186,12 +1130,10 @@ mod tests {
|
|||||||
/// The context is the last assistant message's, not the result's.
|
/// The context is the last assistant message's, not the result's.
|
||||||
///
|
///
|
||||||
/// Real figures from a two-message haiku turn on 2.1.237, captured
|
/// Real figures from a two-message haiku turn on 2.1.237, captured
|
||||||
/// 2026-08-30. The result adds the turn up -- its
|
/// 2026-08-30. The result adds the turn up -- its `cache_read_input_tokens`
|
||||||
/// `cache_read_input_tokens` of 40,211 is 14,259 and 25,952, the same
|
/// of 40,211 is 14,259 and 25,952, the same conversation counted twice -- so
|
||||||
/// conversation counted twice -- so reading the context off it would
|
/// reading the context off it would report a size the model never held, by
|
||||||
/// report a size the model never held, and by more the more tool calls
|
/// more the more tool calls a turn makes.
|
||||||
/// a turn makes. The last message's three input figures are what it
|
|
||||||
/// was holding when the turn ended.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_context_is_what_the_last_message_held_not_the_turn_added_up() {
|
fn the_context_is_what_the_last_message_held_not_the_turn_added_up() {
|
||||||
let dir = tempfile::tempdir().expect("tempdir");
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
@@ -1231,9 +1173,9 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_compaction_reports_its_start_and_what_it_recovered() {
|
fn a_compaction_reports_its_start_and_what_it_recovered() {
|
||||||
// Real lines (trimmed) from a 2.1.237 session driven through
|
// Real lines (trimmed) from a 2.1.237 session driven through `/compact`.
|
||||||
// `/compact`. Note the snake_case keys -- the CLI's transcript
|
// Note the snake_case keys -- the CLI's transcript file writes the same
|
||||||
// file writes the same records in camelCase.
|
// records in camelCase.
|
||||||
let dir = tempfile::tempdir().expect("tempdir");
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
let mut translator = Translator::new(dir.path().to_path_buf());
|
let mut translator = Translator::new(dir.path().to_path_buf());
|
||||||
let events = translate_lines(
|
let events = translate_lines(
|
||||||
@@ -1333,16 +1275,14 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pressing Stop is not a failure, and the CLI cannot tell you which it
|
/// Pressing Stop is not a failure, and the CLI cannot tell you which it was.
|
||||||
/// was.
|
|
||||||
///
|
///
|
||||||
/// An interrupted turn arrives as exactly the same shape a broken one
|
/// An interrupted turn arrives as exactly the same shape a broken one does,
|
||||||
/// does -- `is_error` set, on a `result` -- so somebody who pressed the
|
/// so somebody who pressed the button was shown "the turn ended with an
|
||||||
/// button was shown "the turn ended with an error" for doing what the
|
/// error". What separates the two is that this side asked. The second half
|
||||||
/// button says. What separates the two is not in the line: it is that
|
/// of this test is the one that matters, because the naive fix -- never
|
||||||
/// this side asked. The second half of this test is the one that
|
/// reporting an error result -- passes the first half and silences every
|
||||||
/// matters, because the naive fix -- never reporting an error result --
|
/// genuine failure afterwards.
|
||||||
/// passes the first half and silences every genuine failure afterwards.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_turn_stopped_on_purpose_is_not_an_error() {
|
fn a_turn_stopped_on_purpose_is_not_an_error() {
|
||||||
let dir = tempfile::tempdir().expect("tempdir");
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
|||||||
+220
-335
@@ -9,26 +9,22 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
/// The name a session's image is stored and served under -- returned by
|
/// The name a session's image is stored and served under -- minted for an
|
||||||
/// `POST /attachments` for an upload, minted by a driver for one a tool
|
/// upload or for one a tool produced, and fetched back from
|
||||||
/// produced, and fetched back from `/sessions/{id}/files/{ref}`. Both
|
/// `/sessions/{id}/files/{ref}`. One id both directions, so the transcript
|
||||||
/// directions use the one id so the transcript renders them identically.
|
/// renders them identically.
|
||||||
pub type ImageRef = String;
|
pub type ImageRef = String;
|
||||||
|
|
||||||
/// The name an upload from the phone is stored and served under: an image
|
/// The name an upload is stored and served under: an image is
|
||||||
/// is `<hex>.<extension>` and is an [`ImageRef`] like any other; any other
|
/// `<hex>.<extension>` and is an [`ImageRef`] like any other; any other file
|
||||||
/// file keeps its own name after the hex, `<hex>-<name>`, because the name
|
/// keeps its own name after the hex, `<hex>-<name>`, because the name is what
|
||||||
/// is what the reader attached and what the session is told. The two are
|
/// the reader attached and what the session is told. Told apart by
|
||||||
/// told apart by `crate::media::media_type_for`, which knows every image
|
/// `crate::media::media_type_for`.
|
||||||
/// extension this server writes.
|
|
||||||
pub type AttachmentRef = String;
|
pub type AttachmentRef = String;
|
||||||
|
|
||||||
/// One choice offered in answer to a [`Event::Question`].
|
/// One choice offered in answer to a [`Event::Question`]. More than a label
|
||||||
///
|
/// because the reader is deciding rather than confirming: what an option
|
||||||
/// More than a label because the reader is deciding, not confirming: what
|
/// means, and what picking it would produce, are what decide it.
|
||||||
/// an option means, and what picking it would produce, are the things that
|
|
||||||
/// decide it. Both are optional -- a permission's Allow and Deny mean
|
|
||||||
/// exactly what they say.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct QuestionOption {
|
pub struct QuestionOption {
|
||||||
@@ -42,7 +38,6 @@ pub struct QuestionOption {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl QuestionOption {
|
impl QuestionOption {
|
||||||
/// An option that is only its label, which is most of them.
|
|
||||||
pub fn plain(label: impl Into<String>) -> Self {
|
pub fn plain(label: impl Into<String>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
label: label.into(),
|
label: label.into(),
|
||||||
@@ -52,70 +47,52 @@ impl QuestionOption {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Everything a session can tell the outside world. Every event is
|
/// Everything a session can tell the outside world. Every event is appended
|
||||||
/// appended to the session's transcript with a sequence number, then fanned
|
/// to the transcript with a sequence number, then fanned out to SSE
|
||||||
/// out to SSE subscribers; the phone renders purely from this stream, so
|
/// subscribers, so reconnecting is just "events after seq N" -- no separate
|
||||||
/// reconnecting is just "events after seq N" -- no separate history path
|
/// history path to drift from the live one.
|
||||||
/// to drift from the live one.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
// `rename_all` renames the variants; `rename_all_fields` renames what is
|
// `rename_all` renames the variants; `rename_all_fields` renames what is
|
||||||
// inside them. Both are needed and only the first is obvious: every field
|
// inside them. Both are needed and only the first is obvious: every field
|
||||||
// here was a single lowercase word until `pre_tokens` arrived, so a
|
// here was one lowercase word until `pre_tokens` arrived, so a multi-word
|
||||||
// multi-word field went out as snake_case, the app looked for camelCase and
|
// field went out as snake_case, the app looked for camelCase and found
|
||||||
// found nothing, and the event still rendered -- as the "no counts were
|
// nothing, and the event still rendered -- as the "no counts reported" case,
|
||||||
// reported" case, which is a state it is allowed to be in. A wire mismatch
|
// which is a state it is allowed to be in.
|
||||||
// that lands on a plausible state is invisible; anything added below with a
|
|
||||||
// two-word field would have hit the same thing.
|
|
||||||
#[serde(
|
#[serde(
|
||||||
tag = "type",
|
tag = "type",
|
||||||
rename_all = "camelCase",
|
rename_all = "camelCase",
|
||||||
rename_all_fields = "camelCase"
|
rename_all_fields = "camelCase"
|
||||||
)]
|
)]
|
||||||
pub enum Event {
|
pub enum Event {
|
||||||
/// What the user sent, written into the transcript by the manager (not
|
/// What the user sent, written into the transcript by the manager (not by
|
||||||
/// by drivers) so every device renders the full conversation from the
|
/// drivers) so every device renders the conversation from one stream.
|
||||||
/// one stream. Recorded when the session reads the message, which is
|
/// Recorded when the session reads it, which is what `MessageTaken` reports.
|
||||||
/// what `MessageTaken` reports.
|
|
||||||
UserMessage {
|
UserMessage {
|
||||||
/// The [`Event::MessageQueued`] this resolves, when it waited.
|
/// The [`Event::MessageQueued`] this resolves, when it waited. The
|
||||||
///
|
/// phone has a bubble on screen for the waiting message and needs to
|
||||||
/// A message sent between turns is read at once and never queued,
|
/// know *which* one this is, rather than matching on the text and
|
||||||
/// so this is `None` for most of them. It is the pair to the id on
|
/// clearing the wrong one when the same thing was sent twice.
|
||||||
/// `MessageQueued` and exists for the same reason `CommandSent`
|
|
||||||
/// carries one: the phone has a bubble on screen for the waiting
|
|
||||||
/// message and needs to know *which* one this is, rather than
|
|
||||||
/// matching on the text and clearing the wrong one when the same
|
|
||||||
/// thing was sent twice.
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
id: Option<String>,
|
id: Option<String>,
|
||||||
text: String,
|
text: String,
|
||||||
/// What was attached to it, by the ref the files route serves.
|
/// What was attached, by the ref the files route serves. On the
|
||||||
///
|
/// message rather than beside it: these used to be their own `Image`
|
||||||
/// On the message rather than beside it. These used to be their own
|
/// events just before, which left the phone deciding from adjacency
|
||||||
/// `Image` events emitted just before, which drew a person's
|
/// which message an image belonged to. `images` on disk until
|
||||||
/// screenshot as a row of its own floating above the bubble that
|
/// 2026-09-03, when files joined them; the alias reads the older rows.
|
||||||
/// sent it -- and left the phone to decide, from nothing but
|
|
||||||
/// adjacency, which message an image belonged to. Belonging is not
|
|
||||||
/// something to infer when the sender knew.
|
|
||||||
///
|
|
||||||
/// `images` on disk until 2026-09-03, when files joined them;
|
|
||||||
/// the alias reads the rows written before that.
|
|
||||||
#[serde(default, alias = "images", skip_serializing_if = "Vec::is_empty")]
|
#[serde(default, alias = "images", skip_serializing_if = "Vec::is_empty")]
|
||||||
attachments: Vec<AttachmentRef>,
|
attachments: Vec<AttachmentRef>,
|
||||||
},
|
},
|
||||||
/// A message accepted from the phone that the session cannot read yet.
|
/// A message accepted from the phone that the session cannot read yet.
|
||||||
///
|
///
|
||||||
/// Recorded, unlike the message itself, and that difference is the
|
/// Recorded, unlike the message itself, and that difference is the point:
|
||||||
/// point. The *message* belongs in the transcript where the session
|
/// the message belongs in the transcript where the session read it, but
|
||||||
/// read it -- see `MessageTaken` -- but something has to say it is
|
/// something has to say it is waiting, and it has to be the server. The
|
||||||
/// waiting, and it has to be the server that says it: the phone used
|
/// phone used to remember its own outgoing messages, so leaving the
|
||||||
/// to remember its own outgoing messages, so leaving the session
|
/// screen showed nothing pending when something was.
|
||||||
/// screen or restarting the app showed nothing pending when something
|
|
||||||
/// was, which reads as "nothing queued" rather than "I have forgotten".
|
|
||||||
///
|
///
|
||||||
/// Carries no row of its own. It is resolved by the `UserMessage`
|
/// Carries no row of its own; resolved by the `UserMessage` bearing the
|
||||||
/// bearing the same id, exactly as `CommandQueued` is resolved by
|
/// same id, as `CommandQueued` is resolved by `CommandSent`.
|
||||||
/// `CommandSent`.
|
|
||||||
MessageQueued {
|
MessageQueued {
|
||||||
id: String,
|
id: String,
|
||||||
text: String,
|
text: String,
|
||||||
@@ -125,38 +102,31 @@ pub enum Event {
|
|||||||
#[serde(default, alias = "images", skip_serializing_if = "Vec::is_empty")]
|
#[serde(default, alias = "images", skip_serializing_if = "Vec::is_empty")]
|
||||||
attachments: Vec<AttachmentRef>,
|
attachments: Vec<AttachmentRef>,
|
||||||
},
|
},
|
||||||
/// A message taken out of the queue before the session read it, by
|
/// A message taken out of the queue before the session read it.
|
||||||
/// somebody tapping the bubble that was waiting for it.
|
|
||||||
///
|
///
|
||||||
/// Recorded for the same reason `MessageQueued` is: the queue is the
|
/// Recorded for the same reason `MessageQueued` is: the queue is the
|
||||||
/// server's, so what is waiting has to be answerable from the
|
/// server's, so what is waiting has to be answerable from the transcript
|
||||||
/// transcript alone. Without it a phone that reconnects replays the
|
/// alone. Without it a phone that reconnects replays the `MessageQueued`
|
||||||
/// `MessageQueued` and puts back a bubble for a message that will
|
/// and puts back a bubble nothing will ever resolve -- the `UserMessage`
|
||||||
/// never arrive -- and nothing later would ever resolve it, since the
|
/// that normally does is exactly what is not coming.
|
||||||
/// `UserMessage` that normally does is exactly what is not coming.
|
|
||||||
///
|
///
|
||||||
/// Only ever sent for a message that had not been handed over. One
|
/// Only ever sent for a message that had not been handed over; see
|
||||||
/// that has is not droppable and says so instead; see
|
|
||||||
/// [`Unqueued::AlreadySent`].
|
/// [`Unqueued::AlreadySent`].
|
||||||
MessageDropped {
|
MessageDropped {
|
||||||
id: String,
|
id: String,
|
||||||
},
|
},
|
||||||
/// A driver has taken one of the user's messages and started reading
|
/// A driver has taken one of the user's messages and started reading it.
|
||||||
/// it. The manager turns this into the `UserMessage` above, so it
|
/// The manager turns this into the `UserMessage` above, so it never
|
||||||
/// never reaches a phone itself.
|
/// reaches a phone itself.
|
||||||
///
|
///
|
||||||
/// It exists because sending and being read are not the same moment. A
|
/// It exists because sending and being read are not the same moment. A
|
||||||
/// message sent into a running turn waits for that turn to finish, and
|
/// message sent into a running turn waits, and recording it among things
|
||||||
/// until then the session has not seen it -- so recording it among
|
/// already read puts it in the transcript above output that predates it.
|
||||||
/// things already read puts it in the transcript above output that
|
|
||||||
/// predates it, and leaves a phone drawing it as still waiting with
|
|
||||||
/// nothing coming to say otherwise.
|
|
||||||
MessageTaken {
|
MessageTaken {
|
||||||
/// The `MessageQueued` this answers, or `None` when it never
|
/// The `MessageQueued` this answers, or `None` when it never waited.
|
||||||
/// waited. Carried through onto the `UserMessage`.
|
/// Carried through onto the `UserMessage`.
|
||||||
id: Option<String>,
|
id: Option<String>,
|
||||||
text: String,
|
text: String,
|
||||||
/// Carried through onto the `UserMessage` with everything else.
|
|
||||||
#[serde(default, alias = "images", skip_serializing_if = "Vec::is_empty")]
|
#[serde(default, alias = "images", skip_serializing_if = "Vec::is_empty")]
|
||||||
attachments: Vec<AttachmentRef>,
|
attachments: Vec<AttachmentRef>,
|
||||||
},
|
},
|
||||||
@@ -183,13 +153,10 @@ pub enum Event {
|
|||||||
Image {
|
Image {
|
||||||
#[serde(rename = "ref")]
|
#[serde(rename = "ref")]
|
||||||
image: ImageRef,
|
image: ImageRef,
|
||||||
/// The tool call whose result carried it, when one did.
|
/// The tool call whose result carried it, when one did. A screenshot
|
||||||
///
|
/// belongs under the call that took it, not floating beside it -- the
|
||||||
/// A screenshot belongs under the call that took it, not floating
|
/// reader has to pair them by position otherwise, and position is
|
||||||
/// beside it -- the reader has to pair them by position otherwise,
|
/// exactly what a page boundary breaks.
|
||||||
/// and position is exactly what a page boundary breaks. `None` for
|
|
||||||
/// an image a person attached to their own message, which belongs
|
|
||||||
/// to no call.
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
about: Option<String>,
|
about: Option<String>,
|
||||||
},
|
},
|
||||||
@@ -199,71 +166,54 @@ pub enum Event {
|
|||||||
id: String,
|
id: String,
|
||||||
prompt: String,
|
prompt: String,
|
||||||
/// A few words naming what the question is about, when the asker
|
/// A few words naming what the question is about, when the asker
|
||||||
/// offered one -- a tag beside the question rather than part of
|
/// offered one. `None` for a permission, which is about the call
|
||||||
/// it. `None` for a permission, which is about the call above it.
|
/// above it.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
header: Option<String>,
|
header: Option<String>,
|
||||||
options: Vec<QuestionOption>,
|
options: Vec<QuestionOption>,
|
||||||
/// Whether several options may be chosen at once.
|
/// Whether several options may be chosen at once. Here rather than
|
||||||
///
|
/// left for a phone to work out from the dialect underneath: how many
|
||||||
/// Here rather than left for a phone to work out from the dialect
|
/// answers a question takes is a fact about the question, and the
|
||||||
/// underneath: how many answers a question takes is a fact about
|
/// alternative was Claude Code's tool-input schema written out a
|
||||||
/// the question, and the alternative was the app parsing Claude
|
/// second time in Kotlin, where no other dialect could reach it.
|
||||||
/// Code's tool input to find out -- one dialect's schema, written
|
|
||||||
/// out a second time in Kotlin, where no other dialect could
|
|
||||||
/// reach it.
|
|
||||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||||
multi_select: bool,
|
multi_select: bool,
|
||||||
/// The tool call this is permission for, when it is one.
|
/// The tool call this is permission for, when it is one, so a phone
|
||||||
///
|
/// can draw the ask on the tool's own row rather than as a second
|
||||||
/// The CLI's `can_use_tool` request carries the `tool_use_id` of
|
/// card repeating its input. `None` for anything not about a tool.
|
||||||
/// the call it is asking about, so a phone can draw the ask on the
|
|
||||||
/// tool's own row rather than as a second card repeating its
|
|
||||||
/// input. `None` for anything that is not about a tool --
|
|
||||||
/// AskUserQuestion, and an echo session's question.
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
about: Option<String>,
|
about: Option<String>,
|
||||||
},
|
},
|
||||||
/// A message another agent sent this session.
|
/// A message another agent sent this session.
|
||||||
///
|
///
|
||||||
/// Its own kind rather than a `UserMessage`, because it is not
|
/// Its own kind rather than a `UserMessage`, because it is not something
|
||||||
/// something the reader said and a transcript that renders it in their
|
/// the reader said and a transcript that renders it in their voice is
|
||||||
/// voice is claiming they did. It also explains what would otherwise
|
/// claiming they did. It also explains what would otherwise be
|
||||||
/// be inexplicable: a session that starts working on something nobody
|
/// inexplicable: a session working on something nobody here asked for.
|
||||||
/// on this phone asked for.
|
|
||||||
PeerMessage {
|
PeerMessage {
|
||||||
/// The sending session's own name, which is what the reader
|
/// The sending session's own name, which is what the reader
|
||||||
/// recognises it by -- the socket path it came from is not.
|
/// recognises it by -- the socket path it came from is not.
|
||||||
from: String,
|
from: String,
|
||||||
text: String,
|
text: String,
|
||||||
/// The seq of the `Status::Running` that opened the turn this
|
/// The seq of the `Status::Running` that opened the turn this message
|
||||||
/// message started, so a reader can draw it above that turn.
|
/// started, so a reader can draw it above that turn.
|
||||||
///
|
///
|
||||||
/// It exists because the live Claude Code path cannot record the
|
/// The CLI says nothing about a peer message until the turn's
|
||||||
/// message where it belongs. The CLI says nothing about a peer
|
/// `result`, so the event is appended after everything it caused, and
|
||||||
/// message until the turn's `result` -- see
|
/// an append-only transcript cannot go back and insert it. Carrying
|
||||||
/// `claude::translate` -- so the event is appended after
|
/// the position instead keeps one order on the wire and one on screen.
|
||||||
/// everything it caused, and an append-only transcript cannot go
|
|
||||||
/// back and insert it. Carrying the position instead keeps one
|
|
||||||
/// order on the wire and one order on screen without a second
|
|
||||||
/// source for either.
|
|
||||||
///
|
///
|
||||||
/// Filled in by the pump, which is the only place that knows a
|
/// Filled in by the pump, the only place that knows a seq, and only
|
||||||
/// seq, and only where a turn was open: `None` for a message read
|
/// where a turn was open: `None` for a message replayed by `import`,
|
||||||
/// out of a session file by `import`, which already has it in the
|
/// which already has it in the right place.
|
||||||
/// right place, and for one that started no turn.
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
turn_start: Option<u64>,
|
turn_start: Option<u64>,
|
||||||
},
|
},
|
||||||
/// The manager's record of a question being answered, so a rendered
|
/// The manager's record of a question being answered, so a rendered
|
||||||
/// question card resolves on every device, not just the one that
|
/// question card resolves on every device rather than only the one that
|
||||||
/// answered it.
|
/// answered.
|
||||||
///
|
///
|
||||||
/// A list because a question can take several answers, and one that
|
/// A list because a question can take several answers, and one that took
|
||||||
/// took one is the list of length one rather than a different shape.
|
/// one is the list of length one rather than a different shape.
|
||||||
/// What a dialect makes of that -- Claude Code's answers map holds a
|
|
||||||
/// string, so several become one line -- is that dialect's business
|
|
||||||
/// and is done where it talks to it.
|
|
||||||
Answered {
|
Answered {
|
||||||
id: String,
|
id: String,
|
||||||
answers: Vec<String>,
|
answers: Vec<String>,
|
||||||
@@ -273,17 +223,13 @@ pub enum Event {
|
|||||||
},
|
},
|
||||||
/// What the session is set to, as the session itself reports it.
|
/// What the session is set to, as the session itself reports it.
|
||||||
///
|
///
|
||||||
/// Asking for a change and having one are different things, and only
|
/// Asking for a change and having one are different things, and only this
|
||||||
/// this one is a measurement: a model name the dialect does not know,
|
/// is a measurement: a model name the dialect does not know, a mode it
|
||||||
/// a mode it refuses, or a driver whose model is fixed at startup all
|
/// refuses, or a driver whose model is fixed at startup all leave a
|
||||||
/// leave a request that was sent and nothing that changed. Reporting
|
/// request that was sent and nothing that changed. Reporting from the
|
||||||
/// from the request instead put the answer on the phone before the
|
/// request put the answer on the phone before the question was answered.
|
||||||
/// question had been answered, and left it there when the answer was
|
|
||||||
/// no.
|
|
||||||
///
|
///
|
||||||
/// Either field alone, because the two are confirmed separately and
|
/// Either field alone, because the two are confirmed separately.
|
||||||
/// by different things -- the CLI echoes a mode change, and names the
|
|
||||||
/// model it resolved an alias to when a session starts.
|
|
||||||
Settings {
|
Settings {
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
model: Option<String>,
|
model: Option<String>,
|
||||||
@@ -295,58 +241,44 @@ pub enum Event {
|
|||||||
/// What this turn cost: the tokens it was charged for.
|
/// What this turn cost: the tokens it was charged for.
|
||||||
tokens: u64,
|
tokens: u64,
|
||||||
/// What the model was holding when the turn ended -- see
|
/// What the model was holding when the turn ended -- see
|
||||||
/// [`context_tokens`] for what goes into it.
|
/// [`context_tokens`].
|
||||||
///
|
///
|
||||||
/// Carried on the event rather than summed by whoever is reading,
|
/// Carried rather than summed by whoever is reading, because it is
|
||||||
/// because it is not a sum: a conversation's context goes *down*
|
/// not a sum: context goes *down* at a compaction and a clear, so
|
||||||
/// at a compaction and a clear, so adding turns up would report a
|
/// adding turns up would report a figure the session stopped being
|
||||||
/// figure the session stopped being true of long ago. It is also
|
/// true of long ago.
|
||||||
/// the number a reader is asking about -- how much room is left
|
|
||||||
/// before the next compaction -- rather than what has been spent
|
|
||||||
/// getting here.
|
|
||||||
///
|
///
|
||||||
/// `None` where the dialect did not say, which every reader has to
|
/// `None` where the dialect did not say, which every reader has to be
|
||||||
/// be able to draw: a turn whose usage the CLI omitted leaves the
|
/// able to draw.
|
||||||
/// context unmeasured rather than unchanged, and entries written
|
|
||||||
/// before this existed have no answer at all.
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
context: Option<u64>,
|
context: Option<u64>,
|
||||||
},
|
},
|
||||||
/// A compaction that finished, and how much context it recovered.
|
/// A compaction that finished, and how much context it recovered.
|
||||||
///
|
///
|
||||||
/// The counts are the point, and a spinner is not: what a reader wants
|
/// The counts are the point, and a spinner is not. They are optional
|
||||||
/// afterwards is that the session went from a million tokens to ten
|
/// because the record has shipped without them, and "the compaction
|
||||||
/// thousand, which is measured rather than estimated. They are
|
/// happened, we don't know by how much" is a state this has to be able to
|
||||||
/// optional because the record has shipped without them, and "the
|
/// say -- a plausible number would be indistinguishable from a counted one.
|
||||||
/// compaction happened, we don't know by how much" is a state this
|
|
||||||
/// has to be able to say -- filling in a plausible number would make
|
|
||||||
/// it indistinguishable from one that was counted.
|
|
||||||
Compacted {
|
Compacted {
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pre_tokens: Option<u64>,
|
pre_tokens: Option<u64>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
post_tokens: Option<u64>,
|
post_tokens: Option<u64>,
|
||||||
/// What asked for it, in the dialect's own word -- `auto` when the
|
/// What asked for it, in the dialect's own word -- `auto` when the
|
||||||
/// session compacted on its own. Carried rather than reduced to a
|
/// session compacted on its own. Carried rather than reduced to a bool
|
||||||
/// bool so an unrecognised trigger stays unrecognised: an
|
/// so an unrecognised trigger stays unrecognised: an automatic
|
||||||
/// automatic compaction is the one worth naming, because it
|
/// compaction is the one worth naming, because it explains a wait
|
||||||
/// explains a wait nobody asked for, and defaulting the unknown
|
/// nobody asked for.
|
||||||
/// case to "you asked for this" would explain it away.
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
trigger: Option<String>,
|
trigger: Option<String>,
|
||||||
},
|
},
|
||||||
/// A command the session was asked to run on itself, held because it
|
/// A command the session was asked to run on itself, held because it
|
||||||
/// cannot run yet.
|
/// cannot run yet. These are not messages: `/compact` and `/rename` are
|
||||||
///
|
/// instructions about the session, and a session mid-turn reads a line
|
||||||
/// These are not messages: `/compact` and `/rename` are instructions
|
/// written to it as something the model should see. So they wait, and
|
||||||
/// to the session about itself, and a session in the middle of a turn
|
/// this is what a phone draws while they do.
|
||||||
/// reads a line written to it as something the model should see. So
|
|
||||||
/// they wait for the turn to end, and this is what a phone draws
|
|
||||||
/// while they do -- otherwise pressing Compact during a long turn
|
|
||||||
/// does nothing visible for minutes and looks like it was missed.
|
|
||||||
CommandQueued {
|
CommandQueued {
|
||||||
id: String,
|
id: String,
|
||||||
/// What to show for it: the command as a person would type it.
|
|
||||||
text: String,
|
text: String,
|
||||||
},
|
},
|
||||||
/// The same command, now handed to the session. Its [`CommandQueued`]
|
/// The same command, now handed to the session. Its [`CommandQueued`]
|
||||||
@@ -356,73 +288,55 @@ pub enum Event {
|
|||||||
id: String,
|
id: String,
|
||||||
text: String,
|
text: String,
|
||||||
},
|
},
|
||||||
/// The conversation was cleared: everything above this is still in
|
/// The conversation was cleared: everything above this is still in the
|
||||||
/// the record but is no longer in the session's context.
|
/// record but is no longer in the session's context.
|
||||||
///
|
///
|
||||||
/// Nothing is deleted. A transcript is the thing a person scrolls
|
/// Nothing is deleted. A transcript is the thing a person scrolls back
|
||||||
/// back through, and a session that dropped its history from the
|
/// through, so this is a divider, not a truncation.
|
||||||
/// screen as well as from the model would lose the only copy the
|
|
||||||
/// phone has -- so this is a divider, not a truncation, and the
|
|
||||||
/// events before it stay exactly where they were.
|
|
||||||
///
|
|
||||||
/// It is also what makes clearing mean the same thing for every
|
|
||||||
/// driver, which is why the marker lives here rather than in one
|
|
||||||
/// dialect: `llama` folds its conversation out of the transcript and
|
|
||||||
/// simply folds from the last one of these, and `claude` starts a new
|
|
||||||
/// CLI conversation behind it.
|
|
||||||
///
|
///
|
||||||
/// **Load-bearing, not decorative.** For any driver that rebuilds its
|
/// **Load-bearing, not decorative.** For any driver that rebuilds its
|
||||||
/// conversation from the transcript, this marker decides what the
|
/// conversation from the transcript, this marker decides what the model
|
||||||
/// model is given -- dropping it, or treating it as something only
|
/// is given -- dropping it, or treating it as something only the phone
|
||||||
/// the phone draws, silently puts a cleared conversation back in
|
/// draws, silently puts a cleared conversation back in front of the model
|
||||||
/// front of the model at full cost. Today `llama::conversation` is
|
/// at full cost. Today `llama::conversation` is the only fold that reads
|
||||||
/// the only fold that reads it, which is the reason to write this
|
/// it, which is why this is written down rather than left to be inferred
|
||||||
/// down rather than leave it to be inferred from a second example
|
/// from a second example that does not exist.
|
||||||
/// that does not exist yet.
|
|
||||||
Cleared,
|
Cleared,
|
||||||
Error {
|
Error {
|
||||||
message: String,
|
message: String,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How much the model was holding, from the three figures a turn reports.
|
/// How much the model was holding, from the three figures a turn reports:
|
||||||
|
/// the input side only, prompt plus both cache figures. A cached token is
|
||||||
|
/// cheaper but it is still one the model was given; output is what the turn
|
||||||
|
/// produced rather than what continuing has to carry.
|
||||||
///
|
///
|
||||||
/// The input side only -- prompt plus both cache figures. A cached token
|
/// One function so the definition cannot drift, because it is extracted two
|
||||||
/// is cheaper but it is still one the model was given, so all three count;
|
/// quite different ways -- the live translators have the usage object parsed,
|
||||||
/// output is left out because it is what the turn produced rather than
|
/// and `import::context_tokens` scans it out of a raw line without parsing.
|
||||||
/// what continuing from here has to carry.
|
|
||||||
///
|
|
||||||
/// One function so the definition cannot drift, because it is extracted in
|
|
||||||
/// two quite different ways: the live translators have the usage object
|
|
||||||
/// parsed, and `import::context_tokens` scans it out of a raw line without
|
|
||||||
/// parsing, since those files reach tens of megabytes.
|
|
||||||
pub fn context_tokens(input: u64, cache_creation: u64, cache_read: u64) -> u64 {
|
pub fn context_tokens(input: u64, cache_creation: u64, cache_read: u64) -> u64 {
|
||||||
input + cache_creation + cache_read
|
input + cache_creation + cache_read
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The context after `event`, given what it was before.
|
/// The context after `event`, given what it was before.
|
||||||
///
|
///
|
||||||
/// The whole rule in one place, because three readers need the same
|
/// The whole rule in one place, because three readers need the same answer:
|
||||||
/// answer: the pump keeping a live session's figure, the transcript
|
/// the pump keeping a live session's figure, the transcript seeding it at
|
||||||
/// seeding it at startup, and the phone folding the same events into what
|
/// startup, and the phone folding the same events into what it draws.
|
||||||
/// it draws. Written here beside the events it reads so a fourth reader
|
|
||||||
/// finds it.
|
|
||||||
///
|
///
|
||||||
/// The two that *lower* it are the point. A clear takes the conversation
|
/// The two that *lower* it are the point. A clear takes the conversation away
|
||||||
/// away and a compaction replaces it with a summary, so a figure measured
|
/// and a compaction replaces it with a summary, so a figure measured before
|
||||||
/// before either stopped being true at that moment -- and carrying it
|
/// either stopped being true at that moment -- and carrying it forward is how
|
||||||
/// forward is how a session that had just been cleared went on reporting
|
/// a session that had just been cleared went on reporting the context it no
|
||||||
/// the context it no longer had.
|
/// longer had.
|
||||||
///
|
///
|
||||||
/// `None` is "we don't know", which is a state each of them can reach:
|
/// `None` is "we don't know", which each of them can reach.
|
||||||
/// nothing has been measured yet, a compaction finished without saying
|
|
||||||
/// how much it recovered, or a clear left a conversation nobody has
|
|
||||||
/// counted since.
|
|
||||||
pub fn context_after(current: Option<u64>, event: &Event) -> Option<u64> {
|
pub fn context_after(current: Option<u64>, event: &Event) -> Option<u64> {
|
||||||
match event {
|
match event {
|
||||||
// `or`, so a turn the dialect reported no usage for leaves the last
|
// `or`, so a turn the dialect reported no usage for leaves the last
|
||||||
// measurement standing: it is stale by a turn, which every context
|
// measurement standing: stale by a turn, which every context figure
|
||||||
// figure is, rather than wrong.
|
// is, rather than wrong.
|
||||||
Event::UsageDelta { context, .. } => context.or(current),
|
Event::UsageDelta { context, .. } => context.or(current),
|
||||||
Event::Compacted { post_tokens, .. } => *post_tokens,
|
Event::Compacted { post_tokens, .. } => *post_tokens,
|
||||||
Event::Cleared => None,
|
Event::Cleared => None,
|
||||||
@@ -433,11 +347,10 @@ pub fn context_after(current: Option<u64>, event: &Event) -> Option<u64> {
|
|||||||
/// Something a session can be asked to do to itself.
|
/// Something a session can be asked to do to itself.
|
||||||
///
|
///
|
||||||
/// A closed set rather than a string, because the two that are not
|
/// A closed set rather than a string, because the two that are not
|
||||||
/// dialect-specific have to reach every provider: compaction is a
|
/// dialect-specific have to reach every provider: compaction is a capability
|
||||||
/// capability an llama session may one day have, and a name is this
|
/// an llama session may one day have, and a name is this server's own. `Raw`
|
||||||
/// server's own. `Raw` is the escape for a dialect's own commands --
|
/// is the escape for a dialect's own commands, which only the thing running
|
||||||
/// `/context`, `/usage` -- which only the thing running the session can
|
/// the session can interpret.
|
||||||
/// interpret.
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub enum SessionCommand {
|
pub enum SessionCommand {
|
||||||
Compact,
|
Compact,
|
||||||
@@ -447,8 +360,8 @@ pub enum SessionCommand {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl SessionCommand {
|
impl SessionCommand {
|
||||||
/// What a person would have typed to ask for this, which is what a
|
/// What a person would have typed to ask for this, which is what a phone
|
||||||
/// phone shows while it waits.
|
/// shows while it waits.
|
||||||
pub fn label(&self) -> String {
|
pub fn label(&self) -> String {
|
||||||
match self {
|
match self {
|
||||||
Self::Compact => "/compact".to_string(),
|
Self::Compact => "/compact".to_string(),
|
||||||
@@ -477,32 +390,28 @@ pub enum SessionStatus {
|
|||||||
AwaitingInput,
|
AwaitingInput,
|
||||||
Compacting,
|
Compacting,
|
||||||
Exited,
|
Exited,
|
||||||
/// There is a process recorded for this session and the machine will
|
/// There is a process recorded for this session and the machine will not
|
||||||
/// not say whether it is still running.
|
/// say whether it is still running.
|
||||||
///
|
///
|
||||||
/// Its own state rather than the nearest of the others, because both
|
/// Its own state rather than the nearest of the others, because both
|
||||||
/// neighbours are lies with consequences: `Exited` invites starting a
|
/// neighbours are lies with consequences: `Exited` invites starting a
|
||||||
/// second process against a conversation that may already have one,
|
/// second process against a conversation that may already have one, and
|
||||||
/// and `Idle` claims a session is waiting for you when nobody has
|
/// `Idle` claims a session is waiting for you when nobody has checked.
|
||||||
/// checked. It resolves itself -- the driver keeps asking -- so what
|
|
||||||
/// it means to a reader is "wait", not "act".
|
|
||||||
Unknown,
|
Unknown,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What became of a request to take a queued message back.
|
/// What became of a request to take a queued message back.
|
||||||
///
|
///
|
||||||
/// Three states rather than a bool because the two failures are not the
|
/// Three states rather than a bool because the two failures are not the same
|
||||||
/// same fact. A driver that writes into its session the moment a message
|
/// fact. A driver that writes into its session the moment a message arrives
|
||||||
/// arrives -- which is what `ClaudeDriver` does, so that a steer reaches
|
/// -- which is what `ClaudeDriver` does, so a steer reaches the model at the
|
||||||
/// the model at the next tool boundary rather than at the end of the turn
|
/// next tool boundary -- can never take one back, and a phone told only "no"
|
||||||
/// -- can never take one back, and a phone that was told only "no" would
|
/// would have to guess whether it asked too late or asked about nothing.
|
||||||
/// have to guess whether it had asked too late or asked about nothing.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum Unqueued {
|
pub enum Unqueued {
|
||||||
/// Out of the queue; the session will never read it.
|
/// Out of the queue; the session will never read it.
|
||||||
Dropped,
|
Dropped,
|
||||||
/// Already handed to the session, so there is nothing left to take
|
/// Already handed to the session, so there is nothing left to take back.
|
||||||
/// back. The message is on its way into the conversation.
|
|
||||||
AlreadySent,
|
AlreadySent,
|
||||||
/// Nothing is waiting under that id.
|
/// Nothing is waiting under that id.
|
||||||
Unknown,
|
Unknown,
|
||||||
@@ -522,110 +431,93 @@ pub type EventSink = mpsc::UnboundedSender<Event>;
|
|||||||
pub trait Driver: Send + Sync {
|
pub trait Driver: Send + Sync {
|
||||||
/// Takes a message, now or once the session is free for it.
|
/// Takes a message, now or once the session is free for it.
|
||||||
///
|
///
|
||||||
/// Every driver owes exactly one `MessageTaken` per message, at the
|
/// Every driver owes exactly one `MessageTaken` per message, at the moment
|
||||||
/// moment it actually starts reading it: that event is what puts the
|
/// it actually starts reading it: that event is what puts the message in
|
||||||
/// message in the transcript, so a driver that never sends it drops
|
/// the transcript, so a driver that never sends it drops the message from
|
||||||
/// the message from the conversation entirely.
|
/// the conversation entirely.
|
||||||
fn send_user_message(&self, text: String, attachments: Vec<AttachmentRef>);
|
fn send_user_message(&self, text: String, attachments: Vec<AttachmentRef>);
|
||||||
/// Takes back a message that is still waiting, named by the id its
|
/// Takes back a message that is still waiting, named by the id its
|
||||||
/// [`Event::MessageQueued`] carried.
|
/// [`Event::MessageQueued`] carried.
|
||||||
///
|
///
|
||||||
/// Answering is the whole of the contract: a driver that drops the
|
/// Answering is the whole of the contract: a driver that drops the message
|
||||||
/// message owes an [`Event::MessageDropped`], and one that cannot must
|
/// owes an [`Event::MessageDropped`], and one that cannot must say which
|
||||||
/// say which of the two reasons it is, because they are different
|
/// of the two reasons it is -- "the session has already been told" is
|
||||||
/// things to a reader -- "the session has already been told" is worth
|
/// worth knowing, and "there is nothing under that id" means the bubble on
|
||||||
/// knowing, and "there is nothing under that id" means the bubble on
|
/// screen is stale. The default is the honest answer for a driver with no
|
||||||
/// screen is stale. The default is the honest answer for a driver with
|
/// queue at all.
|
||||||
/// no queue at all: nothing of yours is waiting.
|
|
||||||
fn unqueue(&self, _id: &str) -> Unqueued {
|
fn unqueue(&self, _id: &str) -> Unqueued {
|
||||||
Unqueued::Unknown
|
Unqueued::Unknown
|
||||||
}
|
}
|
||||||
/// Answers one question with everything that was chosen, in the order
|
/// Answers one question with everything that was chosen, in the order it
|
||||||
/// it was offered. One answer is a list of one; a driver whose dialect
|
/// was offered. A driver whose dialect takes a single value joins them
|
||||||
/// takes a single value joins them where it writes it.
|
/// where it writes it.
|
||||||
fn answer_question(&self, id: &str, answers: &[String]);
|
fn answer_question(&self, id: &str, answers: &[String]);
|
||||||
/// Stop mid-run; the session survives.
|
/// Stop mid-run; the session survives.
|
||||||
fn interrupt(&self);
|
fn interrupt(&self);
|
||||||
fn set_model(&self, model: &str);
|
fn set_model(&self, model: &str);
|
||||||
/// How much the session asks about before acting. Live rather than
|
/// How much the session asks about before acting. Live rather than
|
||||||
/// spawn-only: the answer changes with what is being done, and a phone
|
/// spawn-only: the answer changes with what is being done, and a phone is
|
||||||
/// is the worst place to answer "may I run this?" forty times.
|
/// the worst place to answer "may I run this?" forty times.
|
||||||
fn set_permission_mode(&self, mode: &str);
|
fn set_permission_mode(&self, mode: &str);
|
||||||
// Both of the above are requests, and neither reports the outcome by
|
// Both of the above are requests, and neither reports the outcome by
|
||||||
// returning. A driver that actually changes the setting owes an
|
// returning. A driver that changes the setting owes an [`Event::Settings`]
|
||||||
// [`Event::Settings`] once it has -- that event, and not the request,
|
// once it has -- that event, not the request, is what the manager and the
|
||||||
// is what the manager and the phone read. One that cannot change it
|
// phone read. One that cannot owes an [`Event::Error`] saying why.
|
||||||
// owes an [`Event::Error`] saying why; saying nothing leaves a phone
|
|
||||||
// showing a setting nobody applied.
|
|
||||||
|
|
||||||
/// Tells the process what this conversation is called, when it has
|
/// Tells the process what this conversation is called, when it has
|
||||||
/// somewhere to put it.
|
/// somewhere to put it.
|
||||||
///
|
///
|
||||||
/// Unlike the two above, this is not a request that can fail: the
|
/// Unlike the two above, this is not a request that can fail: the rename
|
||||||
/// rename has already happened in this server's own config, which is
|
/// has already happened in this server's config, which is what a phone
|
||||||
/// what a phone lists and the only place the name has to be. So a
|
/// lists. So a driver whose process has no notion of a name does nothing
|
||||||
/// driver whose process has no notion of a name does nothing here and
|
/// and says nothing. Claude Code has one: `--name` at creation and
|
||||||
/// says nothing -- there is no failure to report, and an error beside
|
|
||||||
/// a rename that plainly worked would be a puzzle rather than a
|
|
||||||
/// warning.
|
|
||||||
///
|
|
||||||
/// Claude Code has one: `--name` when a session is created and
|
|
||||||
/// `/rename` afterwards, which is what puts the same name in its own
|
/// `/rename` afterwards, which is what puts the same name in its own
|
||||||
/// session picker and in what other agents see.
|
/// session picker and in what other agents see.
|
||||||
fn set_title(&self, title: &str);
|
fn set_title(&self, title: &str);
|
||||||
/// Runs a command this session's own dialect understands, verbatim.
|
/// Runs a command this session's own dialect understands, verbatim --
|
||||||
|
/// `/context`, `/usage`, anything a CLI adds next month. A driver with no
|
||||||
|
/// such vocabulary says so with an [`Event::Error`] rather than sending it
|
||||||
|
/// as a message, which would put a line meant for the session in front of
|
||||||
|
/// the model.
|
||||||
///
|
///
|
||||||
/// For the ones this app has no opinion about -- `/context`, `/usage`,
|
/// Called only when the session is between turns; the waiting is done
|
||||||
/// anything a CLI adds next month. A driver whose process has no such
|
/// above, once, for every driver.
|
||||||
/// vocabulary says so with an [`Event::Error`] rather than sending it
|
|
||||||
/// as a message, which would put a line meant for the session in front
|
|
||||||
/// of the model instead.
|
|
||||||
///
|
|
||||||
/// Like [`Driver::compact`] and [`Driver::set_title`], this is called
|
|
||||||
/// only when the session is between turns; the waiting is done above,
|
|
||||||
/// once, for every driver.
|
|
||||||
fn run_command(&self, text: &str);
|
fn run_command(&self, text: &str);
|
||||||
/// pi: native compaction; claude: `/compact`.
|
/// llama: not built, and refused; claude: `/compact`.
|
||||||
fn compact(&self);
|
fn compact(&self);
|
||||||
|
|
||||||
/// Drops the conversation so far without ending the session.
|
/// Drops the conversation so far without ending the session.
|
||||||
///
|
///
|
||||||
/// The cheap half of managing a long session, and the reason it is a
|
/// The cheap half of managing a long session, and why it is a driver
|
||||||
/// driver operation rather than a manager one: compaction *reads* the
|
/// operation rather than a manager one: compaction *reads* the whole
|
||||||
/// whole conversation in order to summarise it, so on a large context
|
/// conversation in order to summarise it, so on a large context it is
|
||||||
/// it is itself one of the most expensive requests the session will
|
/// itself one of the most expensive requests the session will make --
|
||||||
/// make -- measured at 1.7 million tokens for a single automatic
|
/// measured at 1.7 million tokens for one automatic compaction on
|
||||||
/// compaction on 2026-08-29. Clearing costs nothing, because nothing
|
/// 2026-08-29. Clearing costs nothing, because nothing is sent.
|
||||||
/// is sent.
|
|
||||||
///
|
///
|
||||||
/// Every implementation emits [`Event::Cleared`] so the transcript
|
/// Every implementation emits [`Event::Cleared`] so the transcript carries
|
||||||
/// carries the divider whatever the dialect did behind it.
|
/// the divider whatever the dialect did behind it.
|
||||||
fn clear(&self);
|
fn clear(&self);
|
||||||
/// Stop attending to the process but leave it running, because this
|
/// Stop attending to the process but leave it running, because this
|
||||||
/// server is going away and means to adopt it again when it comes
|
/// server is going away and means to adopt it again.
|
||||||
/// back.
|
|
||||||
///
|
///
|
||||||
/// This is deliberately not a shutdown. A backend restart -- a
|
/// Deliberately not a shutdown: a backend restart must not end a turn that
|
||||||
/// rebuild, a service restart, a crash -- must not end a turn that is
|
/// is in flight, so a session's process outlives the server that started
|
||||||
/// in flight, so a session's process outlives the server that started
|
/// it and is found again through `session::process`.
|
||||||
/// it and is found again through `session::process`. A driver with no
|
|
||||||
/// process of its own has nothing to do here.
|
|
||||||
///
|
///
|
||||||
/// Its counterpart is [`Driver::stop`]. Every driver owes exactly one
|
/// Its counterpart is [`Driver::stop`]. Every driver owes exactly one of
|
||||||
/// of the two on the way out, and which one is the difference between
|
/// the two on the way out, and which one is the difference between "back
|
||||||
/// "back shortly" and "this conversation is over".
|
/// shortly" and "this conversation is over".
|
||||||
/// Whether a line written *now* would start a turn of its own, rather
|
/// Whether a line written *now* would start a turn of its own, rather than
|
||||||
/// than landing inside one already in flight.
|
/// landing inside one already in flight.
|
||||||
///
|
///
|
||||||
/// Asked of the driver because the driver is the only thing that knows:
|
/// Asked of the driver because the driver is the only thing that knows: it
|
||||||
/// it sees every line it wrote and every line that came back, and it
|
/// updates this the instant it writes rather than when output returns. The
|
||||||
/// updates this the instant it writes rather than when output returns.
|
/// manager's `SessionStatus` is built from what has been *recorded*, so
|
||||||
/// The manager's `SessionStatus` cannot answer it -- that is built from
|
/// between writing a line and the CLI's first output it still reads idle,
|
||||||
/// what has been *recorded*, so between writing a line and the CLI's
|
/// and a second line sent in that gap lands inside the turn the first one
|
||||||
/// first output it still reads idle, and a second line sent in that gap
|
/// started. For a command that is the difference between being executed
|
||||||
/// lands inside the turn the first one started. For a command that is
|
/// and being read to the model as text, which is silent both ways.
|
||||||
/// the difference between being executed and being read to the model as
|
|
||||||
/// text, which is silent both ways.
|
|
||||||
///
|
///
|
||||||
/// Defaults to true for a driver with no turn of its own to be inside.
|
/// Defaults to true for a driver with no turn of its own to be inside.
|
||||||
fn between_turns(&self) -> bool {
|
fn between_turns(&self) -> bool {
|
||||||
@@ -633,16 +525,12 @@ pub trait Driver: Send + Sync {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn detach(&self);
|
fn detach(&self);
|
||||||
/// End the process for good, because it must not survive this. The
|
/// End the process for good, because it must not survive this. The path
|
||||||
/// path out for everything [`detach`] preserves.
|
/// out for everything [`Driver::detach`] preserves.
|
||||||
///
|
///
|
||||||
/// Two callers, and the difference between them is only what is being
|
/// Two callers, differing only in what is being ended: a session being
|
||||||
/// ended: a session being deleted, whose conversation goes with it, and
|
/// deleted, whose conversation goes with it, and a throwaway session at a
|
||||||
/// a throwaway session at a server's exit, whose transcript stays and
|
/// server's exit, whose transcript stays and whose process does not.
|
||||||
/// whose process does not (see [`SessionConfig::throwaway`]).
|
|
||||||
///
|
|
||||||
/// [`detach`]: Driver::detach
|
|
||||||
/// [`SessionConfig::throwaway`]: crate::config::SessionConfig::throwaway
|
|
||||||
fn stop(&self);
|
fn stop(&self);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -650,11 +538,10 @@ pub trait Driver: Send + Sync {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
/// A tripwire for the wire format, not for serde.
|
/// A tripwire for the wire format, not for serde. The app reads these
|
||||||
///
|
/// names, and getting one wrong does not fail loudly: a field the app
|
||||||
/// The app reads these names, and getting one wrong does not fail
|
/// cannot find reads as a field the server chose not to send, which
|
||||||
/// loudly: a field the app cannot find reads as a field the server
|
/// several of them are allowed to be.
|
||||||
/// chose not to send, which several of them are allowed to be.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn multi_word_fields_go_out_in_camel_case() {
|
fn multi_word_fields_go_out_in_camel_case() {
|
||||||
let json = serde_json::to_value(Event::Compacted {
|
let json = serde_json::to_value(Event::Compacted {
|
||||||
@@ -674,11 +561,10 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The two events that take the context *down* are the point of the
|
/// The two events that take the context *down* are the point of the fold:
|
||||||
/// fold: a figure measured before a compaction or a clear stopped being
|
/// a figure measured before a compaction or a clear stopped being true at
|
||||||
/// true at that moment, and carrying it forward is how a session that
|
/// that moment, and carrying it forward is how a session that had just
|
||||||
/// had just been cleared went on reporting the context it no longer
|
/// been cleared went on reporting the context it no longer had.
|
||||||
/// had.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_compaction_and_a_clear_move_the_context_a_turn_cannot() {
|
fn a_compaction_and_a_clear_move_the_context_a_turn_cannot() {
|
||||||
let after = |current, event| context_after(current, &event);
|
let after = |current, event| context_after(current, &event);
|
||||||
@@ -707,8 +593,7 @@ mod tests {
|
|||||||
assert_eq!(after(Some(9_617), Event::Cleared), None);
|
assert_eq!(after(Some(9_617), Event::Cleared), None);
|
||||||
|
|
||||||
// A compaction that did not say how much it recovered leaves the
|
// A compaction that did not say how much it recovered leaves the
|
||||||
// context unknown rather than stale: it definitely moved, and the
|
// context unknown rather than stale: it definitely moved.
|
||||||
// one thing that is certainly wrong is the figure from before it.
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
after(
|
after(
|
||||||
Some(128_402),
|
Some(128_402),
|
||||||
|
|||||||
+176
-239
@@ -1,56 +1,44 @@
|
|||||||
//! The phase-1 fake driver: no child process, just events. It exists to
|
//! The fake driver: no child process, just events. It proves the whole pipe --
|
||||||
//! prove the whole pipe -- spawn, transcript, SSE cursors, questions,
|
//! spawn, transcript, SSE cursors, questions, interrupts, compaction -- and
|
||||||
//! interrupts, compaction -- before any AI is involved, and stays useful afterwards as
|
//! stays useful afterwards as a connectivity check that costs no tokens. It
|
||||||
//! a connectivity check that costs no tokens.
|
//! produces exactly the event vocabulary the real drivers do, so a UI that
|
||||||
|
//! renders echo sessions correctly renders the real thing.
|
||||||
//!
|
//!
|
||||||
//! Behavior: every message is echoed back as a few streamed text deltas.
|
//! Every message is echoed back as a few streamed text deltas. A leading word
|
||||||
//! A leading word asks for something more specific:
|
//! asks for something more specific:
|
||||||
//!
|
//!
|
||||||
//! - `/tool [input]` -- a full tool run, start through end.
|
//! - `/tool [input]` -- a full tool run, start through end.
|
||||||
//! - `/bash [command]` -- a Bash call carrying that command, for what the
|
//! - `/bash [command]` -- a Bash call carrying that command, for what the
|
||||||
//! phone's shell highlighting does to a particular line.
|
//! phone's shell highlighting does to a particular line.
|
||||||
//! - `/tools [n] [gap]` -- n calls back to back, for what a run of them
|
//! - `/tools [n] [gap]` -- n calls back to back. `gap` is seconds between one
|
||||||
//! looks like when a screen groups them. `gap` is seconds between one
|
//! call and the next, which is what makes a run *grow* while somebody is
|
||||||
//! call and the next, default none: it is what makes a run *grow* while
|
//! looking at it -- the only way to reach the state where a call opened on
|
||||||
//! somebody is looking at it, which is the only way to reach the state
|
//! its own gains a neighbour. The first call carries a screenshot, so that
|
||||||
//! where a call opened on its own gains a neighbour. The first call
|
//! state is also reachable with an image open full screen.
|
||||||
//! carries a screenshot, so that state can also be reached with an image
|
|
||||||
//! open full screen -- which is where it used to close itself.
|
|
||||||
//! - `/question [text]` -- a question, exercising the answer path.
|
//! - `/question [text]` -- a question, exercising the answer path.
|
||||||
//! - `/ask` -- an AskUserQuestion call: two questions on one tool call,
|
//! - `/ask` -- an AskUserQuestion call: two questions on one tool call, with
|
||||||
//! with descriptions, a preview and a multi-select, which is the shape
|
//! descriptions, a preview and a multi-select, which is the shape that is
|
||||||
//! that is awkward to get a real model to produce on demand. Wrapped in
|
//! awkward to get a real model to produce on demand. Wrapped in a run of
|
||||||
//! a run of ordinary calls on each side, because being asked something
|
//! ordinary calls on each side, because being asked something happens in the
|
||||||
//! happens in the middle of work and the screen has to keep it out of
|
//! middle of work.
|
||||||
//! the collapsed group around it.
|
//! - `/slow [seconds]` -- a turn that stays running (default 30), so states
|
||||||
//! - `/slow [seconds]` -- a turn that stays running (default 30), so states that only
|
//! that only exist *while* something is happening can be looked at.
|
||||||
//! exist *while* something is happening can be looked at.
|
|
||||||
//! - `/error [text]` -- a failure, which is otherwise awkward to cause.
|
//! - `/error [text]` -- a failure, which is otherwise awkward to cause.
|
||||||
//! - `/peer [text]` -- a message from another agent, which otherwise takes
|
//! - `/peer [text]`, `/peer-turn` -- a message from another agent, in the
|
||||||
//! two live sessions and one of them deciding to write.
|
//! in-place and the live shapes.
|
||||||
//! - `/compact` -- a compaction, start to finish. Typed rather than
|
//! - `/compact` -- a compaction, start to finish.
|
||||||
//! pressed, because the real dialects take it as a typed command too and
|
//! - `/stream N` -- one long answer in N small pieces, 50ms apart: the shape a
|
||||||
//! the phone no longer has a button for it.
|
//! real model's reply arrives in, and the one where the row a reader is
|
||||||
|
//! anchored to is the row that keeps changing height.
|
||||||
|
//! - `/mixed N` -- N beats of an interleaved transcript: rows of every shape
|
||||||
|
//! and height the app draws, in one session, which is what a scrolling
|
||||||
|
//! problem needs in order to be reproduced twice the same way.
|
||||||
|
//! - `/table [columns]` -- a markdown table with cells too long for one line.
|
||||||
//!
|
//!
|
||||||
//! This is exactly the event vocabulary the real drivers produce, so a UI
|
//! `/slow` earns its place: a queued message, a Stop button and a spinner are
|
||||||
//! that renders echo sessions correctly renders the real thing.
|
//! states that only exist mid-turn, and the obvious way to get one -- ask a
|
||||||
//!
|
//! real model to sleep -- does not work. It declines and answers instantly, so
|
||||||
//! - `/stream N` -- one long answer in N small pieces, 50ms apart: the
|
//! the state never arrives and the attempt still costs a turn.
|
||||||
//! shape a real model's reply arrives in, and the one where the row a
|
|
||||||
//! reader is anchored to is the row that keeps changing height.
|
|
||||||
//! - `/mixed N` -- N beats of an interleaved transcript: paragraphs of
|
|
||||||
//! different lengths, single tool calls, runs of adjacent ones, attachments
|
|
||||||
//! and a peer message. Rows of every shape and height the app draws, in
|
|
||||||
//! one session, which is what a scrolling problem needs in order to be
|
|
||||||
//! reproduced twice the same way.
|
|
||||||
//!
|
|
||||||
//! `/slow` earns its place: a queued message, a Stop button, a spinner
|
|
||||||
//! where the answer will go are all states that only exist mid-turn, and
|
|
||||||
//! the obvious way to get one -- ask a real model to sleep -- does not
|
|
||||||
//! work. It declines, reasonably, and answers instantly instead, so the
|
|
||||||
//! state never arrives and the attempt still costs a turn on somebody's
|
|
||||||
//! account. A driver that can be *told* to take its time costs nothing and
|
|
||||||
//! is the same every run.
|
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||||
@@ -62,24 +50,18 @@ use super::driver::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
/// Delay between streamed deltas -- long enough that streaming is visibly
|
/// Delay between streamed deltas -- long enough that streaming is visibly
|
||||||
/// streaming in the UI, short enough that tests waiting on a full turn
|
/// streaming, short enough that tests waiting on a full turn stay fast.
|
||||||
/// stay fast.
|
|
||||||
const DELTA_DELAY: Duration = Duration::from_millis(50);
|
const DELTA_DELAY: Duration = Duration::from_millis(50);
|
||||||
|
|
||||||
/// How long a fake compaction takes.
|
/// How long a fake compaction takes. A measured one, near enough: driving a
|
||||||
///
|
/// real session through `/compact` on 2026-08-29 took 13 seconds for a small
|
||||||
/// A measured one, near enough: driving a real session through `/compact`
|
/// conversation. Three seconds -- what this was -- is too short to look at the
|
||||||
/// on 2026-08-29 took 13 seconds for a small conversation, and a large one
|
/// row that only exists while a compaction is running.
|
||||||
/// takes minutes. Three seconds -- what this was -- is too short to look
|
|
||||||
/// at the row that only exists while a compaction is running, and too
|
|
||||||
/// short to watch its elapsed count reach two digits.
|
|
||||||
const COMPACT_TIME: Duration = Duration::from_secs(13);
|
const COMPACT_TIME: Duration = Duration::from_secs(13);
|
||||||
|
|
||||||
/// A question echo is waiting on, and the tool call it belongs to.
|
/// A question echo is waiting on, and the tool call it belongs to. `call` is
|
||||||
///
|
/// `None` for `/question`, which asks on its own the way a permission does;
|
||||||
/// `call` is `None` for `/question`, which asks on its own the way a
|
/// `Some` for `/ask`, where several questions share one call.
|
||||||
/// permission does; `Some` for `/ask`, where several questions share one
|
|
||||||
/// call and the call ends when the last of them is answered.
|
|
||||||
struct PendingQuestion {
|
struct PendingQuestion {
|
||||||
id: String,
|
id: String,
|
||||||
call: Option<String>,
|
call: Option<String>,
|
||||||
@@ -89,37 +71,31 @@ pub struct EchoDriver {
|
|||||||
sink: EventSink,
|
sink: EventSink,
|
||||||
/// Whether a turn is in flight, and what arrived during it.
|
/// Whether a turn is in flight, and what arrived during it.
|
||||||
///
|
///
|
||||||
/// A real CLI holds a message sent mid-turn and injects it at the next
|
/// A real CLI holds a message sent mid-turn and injects it at the next tool
|
||||||
/// tool boundary; echo used to answer it on the spot, which made it
|
/// boundary; echo used to answer it on the spot, which made it the wrong
|
||||||
/// the wrong shape for testing anything about queueing -- the status
|
/// shape for testing anything about queueing.
|
||||||
/// dropped to idle immediately, so a phone had nothing to show as
|
|
||||||
/// pending. Holding it here is what makes echo able to stand in.
|
|
||||||
busy: Arc<AtomicBool>,
|
busy: Arc<AtomicBool>,
|
||||||
/// Held messages with the id of the `MessageQueued` each one announced,
|
/// Held messages with the id of the `MessageQueued` each one announced, so
|
||||||
/// so the announcement can say which waiting bubble it resolves.
|
/// the announcement can say which waiting bubble it resolves.
|
||||||
queued: Arc<Mutex<Vec<Held>>>,
|
queued: Arc<Mutex<Vec<Held>>>,
|
||||||
/// Where `/mixed` writes the attachments it references, which is the same
|
/// Where `/mixed` writes the attachments it references, which is the same
|
||||||
/// directory the files route serves them from.
|
/// directory the files route serves them from.
|
||||||
session_dir: PathBuf,
|
session_dir: PathBuf,
|
||||||
/// Ids of the questions awaiting an answer, in the order they were
|
/// Ids of the questions awaiting an answer, in the order asked. A list
|
||||||
/// asked. A list because `/ask` puts up to four on one tool call, the
|
/// because `/ask` puts up to four on one tool call, and the turn resumes
|
||||||
/// way AskUserQuestion does, and the turn resumes when the last of
|
/// when the last is answered rather than the first.
|
||||||
/// them is answered rather than the first.
|
|
||||||
pending_questions: Mutex<Vec<PendingQuestion>>,
|
pending_questions: Mutex<Vec<PendingQuestion>>,
|
||||||
/// A pretend context, so the status row has something that behaves the
|
/// A pretend context, so the status row has something that behaves the way
|
||||||
/// way a real one does: it grows with each turn, drops to what the
|
/// a real one does: it grows with each turn, drops to what the compaction
|
||||||
/// compaction says it recovered, and a clear leaves it unmeasured. The
|
/// says it recovered, and a clear leaves it unmeasured. What is real is
|
||||||
/// numbers are invented like everything else here; what is real is
|
/// which way the numbers move.
|
||||||
/// which way they move.
|
|
||||||
context: Arc<AtomicU64>,
|
context: Arc<AtomicU64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EchoDriver {
|
impl EchoDriver {
|
||||||
/// A short run of ordinary calls, to sit either side of something.
|
/// A short run of ordinary calls, to sit either side of something. Three,
|
||||||
///
|
/// because two is the fewest that groups and three makes it obvious the
|
||||||
/// Three, because two is the fewest that groups and three makes it
|
/// group is a group.
|
||||||
/// obvious the group is a group -- and because the point of the
|
|
||||||
/// fixture is what a question looks like with work around it.
|
|
||||||
fn some_calls(&self, label: &str) {
|
fn some_calls(&self, label: &str) {
|
||||||
for index in 0..3 {
|
for index in 0..3 {
|
||||||
let id = format!("echo-{label}-{index}-{}", super::random_hex());
|
let id = format!("echo-{label}-{index}-{}", super::random_hex());
|
||||||
@@ -137,16 +113,15 @@ impl EchoDriver {
|
|||||||
|
|
||||||
/// An AskUserQuestion call, in the shape the CLI sends one.
|
/// An AskUserQuestion call, in the shape the CLI sends one.
|
||||||
///
|
///
|
||||||
/// Two questions on one call, because that is where the display is
|
/// Two questions on one call, because that is where the display is hardest
|
||||||
/// hardest and where it was wrong: one question with four options
|
/// and where it was wrong. Written out in full rather than generated so it
|
||||||
/// reads fine even when the options are laid out badly. Written out
|
/// carries the parts that are easy to leave out of a fixture -- a header, an
|
||||||
/// in full rather than generated so it carries the parts that are
|
/// option with a description, an option with a preview block, and a
|
||||||
/// easy to leave out of a fixture -- a header, an option with a
|
/// multi-select.
|
||||||
/// description, an option with a preview block, and a multi-select.
|
|
||||||
fn ask_user_question(&self) {
|
fn ask_user_question(&self) {
|
||||||
// Written once, in the shape the events carry, and turned into
|
// Written once, in the shape the events carry, and turned into the tool
|
||||||
// the tool call's own input below -- the CLI sends both, and two
|
// call's own input below -- the CLI sends both, and two hand-written
|
||||||
// hand-written copies of one question would drift.
|
// copies of one question would drift.
|
||||||
let asked = [
|
let asked = [
|
||||||
(
|
(
|
||||||
"Theme",
|
"Theme",
|
||||||
@@ -236,8 +211,7 @@ impl EchoDriver {
|
|||||||
header: Some(header.to_string()),
|
header: Some(header.to_string()),
|
||||||
options,
|
options,
|
||||||
multi_select: multi,
|
multi_select: multi,
|
||||||
// The call that asked, so all of it draws as one thing --
|
// The call that asked, so all of it draws as one thing.
|
||||||
// which is the whole point of the fixture.
|
|
||||||
about: Some(call.clone()),
|
about: Some(call.clone()),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -248,23 +222,21 @@ impl EchoDriver {
|
|||||||
|
|
||||||
/// One typed line, whether it arrived as a message or as a command.
|
/// One typed line, whether it arrived as a message or as a command.
|
||||||
///
|
///
|
||||||
/// `announce` is the difference and it is the whole of it: a message
|
/// `announce` is the whole difference: a message is announced with
|
||||||
/// is announced with `MessageTaken`, which is what puts it in the
|
/// `MessageTaken`, which is what puts it in the transcript, and a command is
|
||||||
/// transcript, and a command is not -- the manager has already
|
/// not -- the manager has already recorded that one was sent, and saying so
|
||||||
/// recorded that one was sent, and saying so twice drew the same
|
/// twice drew the same line in both colours.
|
||||||
/// line in both colours.
|
|
||||||
fn handle(&self, text: String, attachments: Vec<AttachmentRef>, announce: bool) {
|
fn handle(&self, text: String, attachments: Vec<AttachmentRef>, announce: bool) {
|
||||||
let sink = self.sink.clone();
|
let sink = self.sink.clone();
|
||||||
|
|
||||||
// Mid-turn messages are held rather than answered, the way a real
|
// Mid-turn messages are held rather than answered, the way a real CLI
|
||||||
// CLI holds them until the next tool boundary. Without this the
|
// holds them until the next tool boundary. Without this the session went
|
||||||
// session went idle the instant one arrived, and every state that
|
// idle the instant one arrived, and every state that only exists while
|
||||||
// only exists while something is queued was untestable.
|
// something is queued was untestable.
|
||||||
if self.busy.load(Ordering::SeqCst) {
|
if self.busy.load(Ordering::SeqCst) {
|
||||||
// The waiting is recorded, exactly as the real driver records
|
// The waiting is recorded, exactly as the real driver records it:
|
||||||
// it: the phone draws its pending bubbles from the server, so
|
// the phone draws its pending bubbles from the server, so an echo
|
||||||
// an echo session has to produce the same events or the states
|
// session has to produce the same events.
|
||||||
// it exists to exercise are not the app's real ones.
|
|
||||||
let id = super::random_hex();
|
let id = super::random_hex();
|
||||||
self.queued
|
self.queued
|
||||||
.lock()
|
.lock()
|
||||||
@@ -280,17 +252,11 @@ impl EchoDriver {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Answered on the spot rather than in the turn below, because a
|
|
||||||
// peer message is not a turn: it is something that arrives, and
|
|
||||||
// what is being exercised is the row it becomes. The message that
|
|
||||||
// asked for it is still announced -- every driver owes exactly one
|
|
||||||
// `MessageTaken` per message, and a command that quietly vanishes
|
|
||||||
// from the transcript is the one thing echo must not model.
|
|
||||||
// The live Claude Code shape, which is the one the ordering has to
|
// The live Claude Code shape, which is the one the ordering has to
|
||||||
// survive: the CLI says nothing about a peer message until the
|
// survive: the CLI says nothing about a peer message until the turn's
|
||||||
// turn's `result`, so the event arrives below the whole reply it
|
// `result`, so the event arrives below the whole reply it caused and the
|
||||||
// caused and the phone has to put it back. Checked before `/peer`,
|
// phone has to put it back. Checked before `/peer`, which would
|
||||||
// which would otherwise take the rest of this word as the body.
|
// otherwise take the rest of this word as the body.
|
||||||
if let Some(rest) = text.strip_prefix("/peer-turn") {
|
if let Some(rest) = text.strip_prefix("/peer-turn") {
|
||||||
if announce {
|
if announce {
|
||||||
self.emit(Event::MessageTaken {
|
self.emit(Event::MessageTaken {
|
||||||
@@ -345,9 +311,9 @@ impl EchoDriver {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The same word the real CLI takes, so a phone drives both the same
|
// The same word the real CLI takes, so a phone drives both the same way.
|
||||||
// way. `Driver::compact` is what the manager's own route calls;
|
// `Driver::compact` is what the manager's route calls; this is the typed
|
||||||
// this is the typed path onto it.
|
// path onto it.
|
||||||
if text.trim() == "/compact" {
|
if text.trim() == "/compact" {
|
||||||
if announce {
|
if announce {
|
||||||
self.emit(Event::MessageTaken {
|
self.emit(Event::MessageTaken {
|
||||||
@@ -403,23 +369,20 @@ impl EchoDriver {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Checked before `/tool`, which is a prefix of it: matching the
|
// Checked before `/tool`, which is a prefix of it: matching the shorter
|
||||||
// shorter one first would read "/tools 4" as a single tool whose
|
// one first would read "/tools 4" as a single tool whose input is "s 4".
|
||||||
// input is "s 4".
|
|
||||||
let many_tools = text.strip_prefix("/tools").map(|rest| {
|
let many_tools = text.strip_prefix("/tools").map(|rest| {
|
||||||
let mut words = rest.split_whitespace();
|
let mut words = rest.split_whitespace();
|
||||||
// At least two, because one call is not a run of them and this
|
// At least two, because one call is not a run of them.
|
||||||
// exists to produce a run.
|
|
||||||
let count = words
|
let count = words
|
||||||
.next()
|
.next()
|
||||||
.and_then(|w| w.parse().ok())
|
.and_then(|w| w.parse().ok())
|
||||||
.unwrap_or(3usize)
|
.unwrap_or(3usize)
|
||||||
.clamp(2, 12);
|
.clamp(2, 12);
|
||||||
// How long to wait between calls, default none. A run that
|
// How long to wait between calls, default none. A run that arrives
|
||||||
// arrives all at once cannot exercise anything about a run
|
// all at once cannot exercise a run *growing*: the case worth
|
||||||
// *growing*: the case worth watching is a call somebody has
|
// watching is a call somebody has opened and is reading when the
|
||||||
// opened and is reading when the next one turns it into a
|
// next one turns it into a group.
|
||||||
// group, and 50ms apart is faster than anybody can open one.
|
|
||||||
let gap = Duration::from_secs(
|
let gap = Duration::from_secs(
|
||||||
words
|
words
|
||||||
.next()
|
.next()
|
||||||
@@ -438,21 +401,19 @@ impl EchoDriver {
|
|||||||
let run_bash = text
|
let run_bash = text
|
||||||
.strip_prefix("/bash")
|
.strip_prefix("/bash")
|
||||||
.map(|rest| rest.trim().to_string());
|
.map(|rest| rest.trim().to_string());
|
||||||
// Seconds to stay running before answering, default 30. Clamped
|
// Seconds to stay running before answering, default 30. Clamped rather
|
||||||
// rather than trusted: this is a test affordance, and a session
|
// than trusted: a session pinned running for an hour by a typo is a
|
||||||
// pinned running for an hour by a typo is a worse outcome than a
|
// worse outcome than a short wait.
|
||||||
// short wait.
|
|
||||||
let stream = text
|
let stream = text
|
||||||
.strip_prefix("/stream")
|
.strip_prefix("/stream")
|
||||||
.map(|rest| rest.trim().parse::<usize>().unwrap_or(400).clamp(1, 4000));
|
.map(|rest| rest.trim().parse::<usize>().unwrap_or(400).clamp(1, 4000));
|
||||||
let mixed = text
|
let mixed = text
|
||||||
.strip_prefix("/mixed")
|
.strip_prefix("/mixed")
|
||||||
.map(|rest| rest.trim().parse::<usize>().unwrap_or(12).clamp(1, 400));
|
.map(|rest| rest.trim().parse::<usize>().unwrap_or(12).clamp(1, 400));
|
||||||
// How many columns wide a fixture table should be, default six.
|
// How many columns wide a fixture table should be, default six. The
|
||||||
// The count is the parameter because it is the thing the phone
|
// count is the parameter because it is what the phone has to react to: a
|
||||||
// has to react to: a narrow table lays itself out across the
|
// narrow table lays itself out across the screen and a wide one has to
|
||||||
// screen and a wide one has to start scrolling sideways, and the
|
// scroll sideways, and the boundary is where the layout is wrong.
|
||||||
// boundary between the two is where the layout is wrong.
|
|
||||||
let table = text
|
let table = text
|
||||||
.strip_prefix("/table")
|
.strip_prefix("/table")
|
||||||
.map(|rest| rest.trim().parse::<usize>().unwrap_or(6).clamp(1, 12));
|
.map(|rest| rest.trim().parse::<usize>().unwrap_or(6).clamp(1, 12));
|
||||||
@@ -472,10 +433,9 @@ impl EchoDriver {
|
|||||||
let _ = sink.send(event);
|
let _ = sink.send(event);
|
||||||
};
|
};
|
||||||
let finish = || finish_turn(&sink, &queued, &busy);
|
let finish = || finish_turn(&sink, &queued, &busy);
|
||||||
// Echo takes a message the instant it gets one, but it says so
|
// Echo takes a message the instant it gets one, but says so anyway:
|
||||||
// anyway: a driver that skips this leaves the phone holding a
|
// a driver that skips this leaves the phone holding a message it
|
||||||
// message it thinks is still queued, and the point of an echo
|
// thinks is still queued.
|
||||||
// provider is that it behaves like the real ones.
|
|
||||||
if announce {
|
if announce {
|
||||||
send(Event::MessageTaken {
|
send(Event::MessageTaken {
|
||||||
id: None,
|
id: None,
|
||||||
@@ -488,8 +448,7 @@ impl EchoDriver {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if let Some(linger) = linger {
|
if let Some(linger) = linger {
|
||||||
// A delta a second: visibly alive rather than merely slow,
|
// A delta a second: visibly alive rather than merely slow.
|
||||||
// which is what the states being looked at accompany.
|
|
||||||
let seconds = linger.as_secs();
|
let seconds = linger.as_secs();
|
||||||
for remaining in (1..=seconds).rev() {
|
for remaining in (1..=seconds).rev() {
|
||||||
send(Event::AssistantText {
|
send(Event::AssistantText {
|
||||||
@@ -532,16 +491,14 @@ impl EchoDriver {
|
|||||||
"timeout": 5000,
|
"timeout": 5000,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
// The first call carries a screenshot, and only the
|
// The first call carries a screenshot, and only the first.
|
||||||
// first. That is what makes this rig cover the case a
|
// That is what makes this rig cover the case a growing run
|
||||||
// growing run is actually about: an image opened full
|
// is about: an image opened full screen from a call that is
|
||||||
// screen from a call that is alone, and then a second
|
// alone, and then a second call turning that row into a
|
||||||
// call arriving and turning that row into a group. The
|
// group. The dialog used to be inside the row, so the reader
|
||||||
// dialog used to be inside the row, so the reader was
|
// was thrown back to the transcript by the session making
|
||||||
// thrown back to the transcript by the session making
|
// another tool call. The first call is the one that is on
|
||||||
// another tool call. Any of the calls would do; the
|
// its own for a whole `gap`.
|
||||||
// first is the one that is on its own for a whole
|
|
||||||
// `gap`, which is the window somebody can open it in.
|
|
||||||
if i == 1 {
|
if i == 1 {
|
||||||
let part = serde_json::json!({
|
let part = serde_json::json!({
|
||||||
"source": {"media_type": "image/png", "data": SAMPLE_PNG}
|
"source": {"media_type": "image/png", "data": SAMPLE_PNG}
|
||||||
@@ -565,9 +522,9 @@ impl EchoDriver {
|
|||||||
|
|
||||||
// One long answer arriving in small pieces, which is what a real
|
// One long answer arriving in small pieces, which is what a real
|
||||||
// model does and what `/slow` does not: `/slow` emits a line a
|
// model does and what `/slow` does not: `/slow` emits a line a
|
||||||
// second, so its message grows in steps a reader can watch one
|
// second, so its message grows in steps a reader can watch one at a
|
||||||
// at a time. A jump caused by the *anchor row itself* changing
|
// time. A jump caused by the *anchor row itself* changing height
|
||||||
// height needs growth that is continuous.
|
// needs growth that is continuous.
|
||||||
if let Some(pieces) = stream {
|
if let Some(pieces) = stream {
|
||||||
for i in 0..pieces {
|
for i in 0..pieces {
|
||||||
let len = 3 + (i * 7) % 14;
|
let len = 3 + (i * 7) % 14;
|
||||||
@@ -632,7 +589,6 @@ impl EchoDriver {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Word-at-a-time so streaming is visibly streaming.
|
|
||||||
for word in format!("You said: {text}").split_inclusive(' ') {
|
for word in format!("You said: {text}").split_inclusive(' ') {
|
||||||
send(Event::AssistantText {
|
send(Event::AssistantText {
|
||||||
delta: word.to_string(),
|
delta: word.to_string(),
|
||||||
@@ -640,8 +596,7 @@ impl EchoDriver {
|
|||||||
tokio::time::sleep(DELTA_DELAY).await;
|
tokio::time::sleep(DELTA_DELAY).await;
|
||||||
}
|
}
|
||||||
// A conversation gets bigger, so the pretend context does too:
|
// A conversation gets bigger, so the pretend context does too:
|
||||||
// roughly a hundred tokens a turn plus the words themselves,
|
// roughly a hundred tokens a turn plus the words themselves.
|
||||||
// which is enough to watch it climb between compactions.
|
|
||||||
let spent = text.split_whitespace().count() as u64;
|
let spent = text.split_whitespace().count() as u64;
|
||||||
send(Event::UsageDelta {
|
send(Event::UsageDelta {
|
||||||
tokens: spent,
|
tokens: spent,
|
||||||
@@ -667,36 +622,32 @@ impl EchoDriver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Sends are infallible from the driver's point of view: a closed sink
|
/// Sends are infallible from the driver's point of view: a closed sink
|
||||||
/// means the session is being torn down, and there is nobody left to
|
/// means the session is being torn down.
|
||||||
/// report to.
|
|
||||||
fn emit(&self, event: Event) {
|
fn emit(&self, event: Event) {
|
||||||
let _ = self.sink.send(event);
|
let _ = self.sink.send(event);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A 16x10 checkerboard, the smallest thing that is recognisably an image
|
/// A 16x10 checkerboard, the smallest thing recognisably an image rather than a
|
||||||
/// rather than a blank rectangle.
|
/// blank rectangle. Embedded rather than generated because the alternative is a
|
||||||
///
|
/// PNG encoder in a test rig, and what a scroll test needs from an image is
|
||||||
/// Embedded rather than generated because the alternative is a PNG encoder
|
/// that it occupies an image's worth of space.
|
||||||
/// in a test rig, and drawn at the transcript's fixed thumbnail height
|
|
||||||
/// anyway -- what a scroll test needs from an image is that it occupies an
|
|
||||||
/// image's worth of space, not that it is pretty.
|
|
||||||
const SAMPLE_PNG: &str = "iVBORw0KGgoAAAANSUhEUgAAABAAAAAKCAIAAAAy3EnLAAAAIklEQVR42mPo3PILiOTk9ICIGDYDyRqIVwphk65h1A9EsAGCYdJRj+JH4wAAAABJRU5ErkJggg==";
|
const SAMPLE_PNG: &str = "iVBORw0KGgoAAAANSUhEUgAAABAAAAAKCAIAAAAy3EnLAAAAIklEQVR42mPo3PILiOTk9ICIGDYDyRqIVwphk65h1A9EsAGCYdJRj+JH4wAAAABJRU5ErkJggg==";
|
||||||
|
|
||||||
/// One beat of `/mixed`: a row shape chosen by position, so the same N
|
/// One beat of `/mixed`: a row shape chosen by position, so the same N always
|
||||||
/// always produces the same transcript.
|
/// produces the same transcript.
|
||||||
///
|
///
|
||||||
/// Repeatable on purpose. A scrolling fault is judged by watching the same
|
/// Repeatable on purpose. A scrolling fault is judged by watching the same
|
||||||
/// content behave differently, and a rig that produced a different
|
/// content behave differently, and a rig that produced a different transcript
|
||||||
/// transcript each run would make every comparison an argument about
|
/// each run would make every comparison an argument about whether the content
|
||||||
/// whether the content changed.
|
/// changed.
|
||||||
async fn write_beat(sink: &EventSink, session_dir: &Path, beat: usize) {
|
async fn write_beat(sink: &EventSink, session_dir: &Path, beat: usize) {
|
||||||
let send = |event: Event| {
|
let send = |event: Event| {
|
||||||
let _ = sink.send(event);
|
let _ = sink.send(event);
|
||||||
};
|
};
|
||||||
match beat % 5 {
|
match beat % 5 {
|
||||||
// A paragraph, of three lengths, because a list of uniform rows
|
// A paragraph, of three lengths, because a list of uniform rows hides
|
||||||
// hides exactly the faults that uneven ones expose.
|
// exactly the faults that uneven ones expose.
|
||||||
1 => {
|
1 => {
|
||||||
let words = match beat % 3 {
|
let words = match beat % 3 {
|
||||||
0 => 12,
|
0 => 12,
|
||||||
@@ -705,9 +656,8 @@ async fn write_beat(sink: &EventSink, session_dir: &Path, beat: usize) {
|
|||||||
};
|
};
|
||||||
// Deliberately ragged: each word's length is a function of its
|
// Deliberately ragged: each word's length is a function of its
|
||||||
// position, so no two lines wrap the same way. A paragraph of
|
// position, so no two lines wrap the same way. A paragraph of
|
||||||
// uniform tokens is a wall that looks identical at every
|
// uniform tokens looks identical at every offset, which makes it
|
||||||
// offset, which makes it impossible to tell a scroll of one
|
// impossible to tell a scroll of one line from a scroll of ten.
|
||||||
// line from a scroll of ten -- by eye or by comparing frames.
|
|
||||||
let body: String = (0..words)
|
let body: String = (0..words)
|
||||||
.map(|w| {
|
.map(|w| {
|
||||||
let len = 3 + (w * 7 + beat * 3) % 14;
|
let len = 3 + (w * 7 + beat * 3) % 14;
|
||||||
@@ -731,8 +681,8 @@ async fn write_beat(sink: &EventSink, session_dir: &Path, beat: usize) {
|
|||||||
output: format!("beat {beat}: forty-two lines of nothing in particular"),
|
output: format!("beat {beat}: forty-two lines of nothing in particular"),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// A run of three, which the app folds into one collapsed group --
|
// A run of three, which the app folds into one collapsed group -- the
|
||||||
// the row whose identity depends on what is next to it.
|
// row whose identity depends on what is next to it.
|
||||||
3 => {
|
3 => {
|
||||||
for i in 1..=3 {
|
for i in 1..=3 {
|
||||||
let id = format!("t-{}", super::random_hex());
|
let id = format!("t-{}", super::random_hex());
|
||||||
@@ -779,30 +729,27 @@ async fn write_beat(sink: &EventSink, session_dir: &Path, beat: usize) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Slow enough that the phone renders each beat as it arrives rather
|
// Slow enough that the phone renders each beat as it arrives rather than
|
||||||
// than composing the whole run in one frame -- which is the condition
|
// composing the whole run in one frame -- which is the condition a scrolling
|
||||||
// a scrolling fault actually happens under.
|
// fault actually happens under.
|
||||||
tokio::time::sleep(Duration::from_millis(120)).await;
|
tokio::time::sleep(Duration::from_millis(120)).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A message written during a turn and waiting for it to end: the id of the
|
/// A message written during a turn and waiting for it to end: the id of the
|
||||||
/// `MessageQueued` that announced it, what it said, and what was attached to
|
/// `MessageQueued` that announced it, what it said, and what was attached. All
|
||||||
/// it. All three, because all three are what the `MessageTaken` at the other
|
/// three, because all three are what the `MessageTaken` at the other end owes.
|
||||||
/// end owes -- named rather than written out at each of the four places that
|
|
||||||
/// mention it.
|
|
||||||
type Held = (String, String, Vec<AttachmentRef>);
|
type Held = (String, String, Vec<AttachmentRef>);
|
||||||
|
|
||||||
/// A markdown table [columns] wide, with cells too long for one line.
|
/// A markdown table [columns] wide, with cells too long for one line.
|
||||||
///
|
///
|
||||||
/// Both halves of that matter. Long cells are what the renderer used to cut
|
/// Both halves matter. Long cells are what the renderer used to cut off with an
|
||||||
/// off with an ellipsis, and a cut cell looks exactly like a short one, so
|
/// ellipsis, and a cut cell looks exactly like a short one, so a fixture of
|
||||||
/// a fixture of tidy one-word values would have rendered perfectly while
|
/// tidy one-word values would have rendered perfectly while the defect was
|
||||||
/// the defect was still there. The column count is what decides whether
|
/// still there. The column count decides whether the table fits the screen.
|
||||||
/// the table fits the screen or has to scroll sideways.
|
|
||||||
///
|
///
|
||||||
/// Written out as markdown rather than assembled from a grid type because
|
/// Written out as markdown rather than assembled from a grid type because what
|
||||||
/// what is being tested is the renderer's parse of the syntax a model
|
/// is being tested is the renderer's parse of the syntax a model actually
|
||||||
/// actually writes, pipes and alignment row included.
|
/// writes, pipes and alignment row included.
|
||||||
fn markdown_table(columns: usize) -> String {
|
fn markdown_table(columns: usize) -> String {
|
||||||
let headings = [
|
let headings = [
|
||||||
"What it is",
|
"What it is",
|
||||||
@@ -854,17 +801,16 @@ fn markdown_table(columns: usize) -> String {
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ending a turn is also when anything held during it is taken up -- the
|
/// Ending a turn is also when anything held during it is taken up -- the moment
|
||||||
/// moment a real CLI would have injected it. One place, because a turn has
|
/// a real CLI would have injected it. One place, because a turn has several
|
||||||
/// several ways to end (a reply, an interrupt, a compaction) and every one
|
/// ways to end (a reply, an interrupt, a compaction) and every one of them owes
|
||||||
/// of them owes the same answer.
|
/// the same answer.
|
||||||
fn finish_turn(sink: &EventSink, queued: &Mutex<Vec<Held>>, busy: &AtomicBool) {
|
fn finish_turn(sink: &EventSink, queued: &Mutex<Vec<Held>>, busy: &AtomicBool) {
|
||||||
let held = std::mem::take(&mut *queued.lock().unwrap());
|
let held = std::mem::take(&mut *queued.lock().unwrap());
|
||||||
for (id, text, attachments) in held {
|
for (id, text, attachments) in held {
|
||||||
// Announced before it is answered, in that order: a phone showing
|
// Announced before it is answered, in that order: a phone showing the
|
||||||
// the message as pending needs the signal that it has been read,
|
// message as pending needs the signal that it has been read, and the
|
||||||
// and the answer is meaningless above a message still drawn as
|
// answer is meaningless above a message still drawn as waiting.
|
||||||
// waiting.
|
|
||||||
let _ = sink.send(Event::MessageTaken {
|
let _ = sink.send(Event::MessageTaken {
|
||||||
id: Some(id),
|
id: Some(id),
|
||||||
text: text.clone(),
|
text: text.clone(),
|
||||||
@@ -885,12 +831,10 @@ impl Driver for EchoDriver {
|
|||||||
!self.busy.load(Ordering::SeqCst)
|
!self.busy.load(Ordering::SeqCst)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Really droppable, which is what makes this the rig for the phone's
|
/// Really droppable, which is what makes this the rig for the phone's side
|
||||||
/// side of it: the held message is this driver's own and nothing has
|
/// of it: the held message is this driver's own and nothing has been written
|
||||||
/// been written anywhere, so a tap here exercises the whole path
|
/// anywhere, so a tap here exercises the whole path through to the bubble
|
||||||
/// through to the bubble disappearing on every device. The Claude
|
/// disappearing on every device. The Claude driver can only ever refuse.
|
||||||
/// driver can only ever refuse -- see its own `unqueue` -- so it
|
|
||||||
/// cannot exercise the case where the drop succeeds.
|
|
||||||
fn unqueue(&self, id: &str) -> Unqueued {
|
fn unqueue(&self, id: &str) -> Unqueued {
|
||||||
let mut queued = self.queued.lock().unwrap();
|
let mut queued = self.queued.lock().unwrap();
|
||||||
let Some(at) = queued.iter().position(|(waiting, ..)| waiting == id) else {
|
let Some(at) = queued.iter().position(|(waiting, ..)| waiting == id) else {
|
||||||
@@ -903,18 +847,15 @@ impl Driver for EchoDriver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn send_user_message(&self, text: String, attachments: Vec<AttachmentRef>) {
|
fn send_user_message(&self, text: String, attachments: Vec<AttachmentRef>) {
|
||||||
// Announced, because this is a message: every driver owes exactly
|
// Announced, because this is a message: every driver owes exactly one
|
||||||
// one `MessageTaken` per message, and one that quietly vanishes
|
// `MessageTaken` per message, and one that quietly vanishes from the
|
||||||
// from the transcript is the thing echo must not model. A command
|
// transcript is the thing echo must not model. A command owes none.
|
||||||
// owes none -- the manager has already recorded that it was sent,
|
|
||||||
// and announcing it again drew the same line twice, once in each
|
|
||||||
// colour.
|
|
||||||
self.handle(text, attachments, true);
|
self.handle(text, attachments, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Echo's commands *are* its messages -- `/tool`, `/slow`, `/ask` --
|
/// Echo's commands *are* its messages -- `/tool`, `/slow`, `/ask` -- so
|
||||||
/// so this is the same path with the same parsing, and the fixture
|
/// this is the same path with the same parsing, and the fixture behaves like
|
||||||
/// behaves like a real session driven the same way.
|
/// a real session driven the same way.
|
||||||
fn run_command(&self, text: &str) {
|
fn run_command(&self, text: &str) {
|
||||||
self.handle(text.to_string(), Vec::new(), false);
|
self.handle(text.to_string(), Vec::new(), false);
|
||||||
}
|
}
|
||||||
@@ -930,8 +871,8 @@ impl Driver for EchoDriver {
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let answered = pending.remove(at);
|
let answered = pending.remove(at);
|
||||||
// Whether anything on the same call is still unanswered: a
|
// Whether anything on the same call is still unanswered: a tool that
|
||||||
// tool that asked four questions ends once, not four times.
|
// asked four questions ends once, not four times.
|
||||||
let waiting = answered
|
let waiting = answered
|
||||||
.call
|
.call
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -946,9 +887,9 @@ impl Driver for EchoDriver {
|
|||||||
id: call,
|
id: call,
|
||||||
output: format!("answered: {answer}"),
|
output: format!("answered: {answer}"),
|
||||||
});
|
});
|
||||||
// The work carries on where it left off, which is what makes
|
// The work carries on where it left off, which is what makes the
|
||||||
// the asked-here row a boundary with a group on each side
|
// asked-here row a boundary with a group on each side rather than
|
||||||
// rather than the last thing in the turn.
|
// the last thing in the turn.
|
||||||
self.some_calls("after");
|
self.some_calls("after");
|
||||||
} else {
|
} else {
|
||||||
self.emit(Event::AssistantText {
|
self.emit(Event::AssistantText {
|
||||||
@@ -961,17 +902,16 @@ impl Driver for EchoDriver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn interrupt(&self) {
|
fn interrupt(&self) {
|
||||||
// Nothing real to stop; a pending question is abandoned so the
|
// Nothing real to stop; a pending question is abandoned so the session
|
||||||
// session isn't stuck awaiting input forever.
|
// isn't stuck awaiting input forever.
|
||||||
self.pending_questions.lock().unwrap().clear();
|
self.pending_questions.lock().unwrap().clear();
|
||||||
self.emit(Event::Status {
|
self.emit(Event::Status {
|
||||||
state: SessionStatus::Idle,
|
state: SessionStatus::Idle,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Nothing to forward: this process has no notion of what the
|
// Nothing to forward: this process has no notion of what the conversation
|
||||||
// conversation is called, and the rename it belongs to has already
|
// is called, and the rename has already happened where the name lives.
|
||||||
// happened where the name lives. See `Driver::set_title`.
|
|
||||||
fn set_title(&self, _title: &str) {}
|
fn set_title(&self, _title: &str) {}
|
||||||
|
|
||||||
fn set_permission_mode(&self, mode: &str) {
|
fn set_permission_mode(&self, mode: &str) {
|
||||||
@@ -986,14 +926,11 @@ impl Driver for EchoDriver {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A compaction with nothing to compact.
|
/// A compaction with nothing to compact. The counts are invented, like
|
||||||
///
|
/// everything else this driver says -- what is real is the shape and the
|
||||||
/// The counts are invented, like everything else this driver says --
|
/// order: busy, a pause long enough to see, then the result. The only other
|
||||||
/// what is real is the shape and the order: busy, a pause long enough
|
/// way to reach those states is to fill a real session's context and spend
|
||||||
/// to see, then the result. `Compacting` and `Compacted` are states a
|
/// two minutes of somebody's account getting it back.
|
||||||
/// screen has to draw, and the only other way to reach them is to fill
|
|
||||||
/// a real session's context and spend two minutes of somebody's
|
|
||||||
/// account getting it back.
|
|
||||||
fn compact(&self) {
|
fn compact(&self) {
|
||||||
let sink = self.sink.clone();
|
let sink = self.sink.clone();
|
||||||
let queued = Arc::clone(&self.queued);
|
let queued = Arc::clone(&self.queued);
|
||||||
@@ -1005,10 +942,10 @@ impl Driver for EchoDriver {
|
|||||||
state: SessionStatus::Compacting,
|
state: SessionStatus::Compacting,
|
||||||
});
|
});
|
||||||
tokio::time::sleep(COMPACT_TIME).await;
|
tokio::time::sleep(COMPACT_TIME).await;
|
||||||
// What it says it recovered is what the pretend context becomes,
|
// What it says it recovered is what the pretend context becomes, so
|
||||||
// so the figure on the status row and the one on the divider
|
// the figure on the status row and the one on the divider agree --
|
||||||
// agree -- two numbers about the same moment disagreeing is the
|
// two numbers about the same moment disagreeing is the thing this
|
||||||
// thing this rig exists to catch.
|
// rig exists to catch.
|
||||||
context.store(9_617, Ordering::SeqCst);
|
context.store(9_617, Ordering::SeqCst);
|
||||||
let _ = sink.send(Event::Compacted {
|
let _ = sink.send(Event::Compacted {
|
||||||
pre_tokens: Some(128_402),
|
pre_tokens: Some(128_402),
|
||||||
|
|||||||
+222
-321
@@ -28,20 +28,15 @@ use super::transport::{Launch, Transport};
|
|||||||
|
|
||||||
/// How much of a transcript's tail is replayed into the phone's view.
|
/// How much of a transcript's tail is replayed into the phone's view.
|
||||||
///
|
///
|
||||||
/// The imported conversation is for reading; *continuing* it is the CLI's
|
/// The imported conversation is for reading; *continuing* it is the CLI's job
|
||||||
/// job through `--resume`, and it reads the whole file itself regardless
|
/// through `--resume`, and it reads the whole file itself. So this is a
|
||||||
/// of what is shown here. So this is a display budget, not a fidelity one
|
/// display budget, and it needs to be one: these files reach tens of megabytes
|
||||||
/// -- and it needs to be a budget, because these files reach tens of
|
/// and every line would otherwise cross a WireGuard link to a phone.
|
||||||
/// megabytes (the session this feature was written in was 39 MB) and every
|
|
||||||
/// line of it would otherwise cross a WireGuard link to a phone.
|
|
||||||
const REPLAY_LINES: usize = 2000;
|
const REPLAY_LINES: usize = 2000;
|
||||||
|
|
||||||
/// Whether a session is open in a CLI somewhere.
|
/// Whether a session is open in a CLI somewhere. Three answers, because
|
||||||
///
|
/// "nobody could check" is not "nobody is using it" -- collapsing them puts
|
||||||
/// Three answers, because "nobody could check" is not "nobody is using
|
/// the dangerous case behind the safe word.
|
||||||
/// it". Collapsing them would put the dangerous case behind the safe
|
|
||||||
/// word, which is how the expensive version of this happens: an import
|
|
||||||
/// that looks permitted, of a session that is being written to.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub enum InUse {
|
pub enum InUse {
|
||||||
@@ -49,8 +44,8 @@ pub enum InUse {
|
|||||||
No,
|
No,
|
||||||
/// Checked, and a live CLI has it open.
|
/// Checked, and a live CLI has it open.
|
||||||
Yes,
|
Yes,
|
||||||
/// The machine does not keep the record this is read from, so there is
|
/// The machine does not keep the record this is read from, so there is no
|
||||||
/// no answer to be had -- not an answer of "no".
|
/// answer to be had -- not an answer of "no".
|
||||||
Unknown,
|
Unknown,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,113 +56,87 @@ pub struct Importable {
|
|||||||
/// The CLI's own session id, which is both the file name and the
|
/// The CLI's own session id, which is both the file name and the
|
||||||
/// `--resume` token.
|
/// `--resume` token.
|
||||||
pub id: String,
|
pub id: String,
|
||||||
/// Where that session was working, offered as the imported session's
|
/// Where that session was working, offered as the imported session's cwd
|
||||||
/// cwd so it resumes pointing at the same tree.
|
/// so it resumes pointing at the same tree.
|
||||||
pub cwd: String,
|
pub cwd: String,
|
||||||
/// The first thing a person said in it, for recognising it in a list.
|
/// The first thing a person said in it, for recognising it in a list.
|
||||||
pub title: String,
|
pub title: String,
|
||||||
/// Epoch seconds, for ordering by "what I was last doing".
|
/// Epoch seconds, for ordering by "what I was last doing".
|
||||||
pub modified: f64,
|
pub modified: f64,
|
||||||
pub lines: usize,
|
pub lines: usize,
|
||||||
/// How many tokens the model was holding at the last turn.
|
/// How many tokens the model was holding at the last turn: the input side
|
||||||
///
|
/// of the most recent assistant message's usage, which is the closest thing
|
||||||
/// The input side of the most recent assistant message's usage --
|
/// to "what continuing this costs" and is a number the CLI recorded rather
|
||||||
/// prompt plus both cache figures -- which is the closest thing to
|
/// than one inferred from the file.
|
||||||
/// "what continuing this costs", and unlike the size it is a number
|
|
||||||
/// the CLI itself recorded rather than one inferred from the file.
|
|
||||||
///
|
///
|
||||||
/// Size and this disagree in the direction that matters. Most of a big
|
/// Size and this disagree in the direction that matters. Most of a big
|
||||||
/// transcript is usually history from before a compaction, which the
|
/// transcript is usually history from before a compaction, which the model
|
||||||
/// model is no longer given: of the 133 MB session behind the
|
/// is no longer given: of the 133 MB session behind the 2026-08-29
|
||||||
/// 2026-08-29 incident, 99% of the bytes sat before its last
|
/// incident, 99% of the bytes sat before its last compaction summary.
|
||||||
/// compaction summary. A 77 MB file whose context is 10k tokens is
|
|
||||||
/// cheap to continue; a smaller one that has never compacted may not
|
|
||||||
/// be.
|
|
||||||
///
|
///
|
||||||
/// `None` when no assistant turn has recorded usage yet -- which is
|
/// `None` when no assistant turn has recorded usage yet -- which is not
|
||||||
/// not zero, and is why this is an option rather than a default.
|
/// zero, and is why this is an option.
|
||||||
pub context_tokens: Option<u64>,
|
pub context_tokens: Option<u64>,
|
||||||
/// Size of the file, in bytes.
|
/// Size of the file, in bytes. Reported because it predicts what
|
||||||
|
/// continuing the session will cost and lines do not: these transcripts
|
||||||
|
/// embed screenshots as base64, so one line can be a megabyte. The session
|
||||||
|
/// behind the 2026-08-29 incident was 65 MB across 13,000 lines.
|
||||||
///
|
///
|
||||||
/// Reported because it is the only thing on a row that predicts what
|
/// Shown rather than warned about: importing a large session is a choice
|
||||||
/// continuing the session will cost, and lines do not: these
|
/// somebody is entitled to make.
|
||||||
/// transcripts embed screenshots as base64, so one line can be a
|
|
||||||
/// megabyte. The session behind the 2026-08-29 incident was 65 MB
|
|
||||||
/// across 13,000 lines, which is a line count that looks unremarkable.
|
|
||||||
///
|
|
||||||
/// Shown rather than warned about. Importing a large session is a
|
|
||||||
/// choice somebody is entitled to make, and marking it would be the
|
|
||||||
/// interface nagging about a decision already taken -- but they should
|
|
||||||
/// be able to see what they are taking on.
|
|
||||||
pub bytes: u64,
|
pub bytes: u64,
|
||||||
/// Whether [`title`](Self::title) is a name somebody chose rather than
|
/// Whether [`title`](Self::title) is a name somebody chose rather than
|
||||||
/// something read out of the conversation. Sorted on, and worth the
|
/// something read out of the conversation. Worth the reader knowing: a name
|
||||||
/// reader knowing: a name is a claim about what a session *is*, and a
|
/// is a claim about what a session *is*, and a last message is only the
|
||||||
/// last message is only the last thing that happened in it.
|
/// last thing that happened in it.
|
||||||
pub named: bool,
|
pub named: bool,
|
||||||
/// Whether a CLI is running this session right now.
|
/// Whether a CLI is running this session right now.
|
||||||
///
|
///
|
||||||
/// The load-bearing field on this struct. Importing a session that is
|
/// The load-bearing field on this struct. Importing a session already open
|
||||||
/// already open puts a second `--resume` on one file: the whole
|
/// puts a second `--resume` on one file: the conversation gets duplicated
|
||||||
/// conversation gets duplicated into it, both copies then read each
|
/// into it, both copies read each other's writes as work done elsewhere,
|
||||||
/// other's writes as work done elsewhere, and the adopted one is
|
/// and the adopted one is billed for re-reading everything -- measured on
|
||||||
/// billed for re-reading everything -- measured on 2026-08-29 at 65 MB
|
/// 2026-08-29 at 65 MB and 154 screenshots.
|
||||||
/// and 154 screenshots, from importing the session the importing agent
|
|
||||||
/// was itself running in.
|
|
||||||
pub in_use: InUse,
|
pub in_use: InUse,
|
||||||
/// Where it lives. Not serialized: the phone chooses by id and the
|
/// Where it lives. Not serialized: the phone chooses by id and the server
|
||||||
/// server resolves the path, so a path never crosses the wire in
|
/// resolves the path, so a path never crosses the wire either direction.
|
||||||
/// either direction.
|
|
||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
pub path: String,
|
pub path: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Asks `transport`'s machine which Claude Code sessions it has.
|
/// Asks `transport`'s machine which Claude Code sessions it has.
|
||||||
///
|
///
|
||||||
/// One command rather than one per file, for the reason `setups::discover`
|
/// One command rather than one per file: over ssh each would be its own
|
||||||
/// gives: over ssh each would be its own connection and handshake.
|
/// connection and handshake. `stat -c` is GNU-specific, which is the thing to
|
||||||
///
|
/// change first if this ever meets a BSD.
|
||||||
/// `stat -c` is GNU-specific, which is fine for the machines here and is
|
|
||||||
/// the thing to change first if this ever meets a BSD.
|
|
||||||
pub async fn list(transport: &Transport) -> Result<Vec<Importable>> {
|
pub async fn list(transport: &Transport) -> Result<Vec<Importable>> {
|
||||||
// Which sessions are open right now, before the files themselves.
|
// Which sessions are open right now, before the files themselves.
|
||||||
//
|
//
|
||||||
// Claude Code writes a descriptor per live session at
|
// Claude Code writes a descriptor per live session at
|
||||||
// `~/.claude/sessions/<pid>.json`, and the pid is the file name. It
|
// `~/.claude/sessions/<pid>.json`, and records `procStart` -- the kernel's
|
||||||
// also records `procStart` -- the kernel's start time for that pid --
|
// start time for that pid -- for the same reason `session::process` does: a
|
||||||
// for the same reason `session::process` does: a pid on its own is
|
// pid on its own is reused, so a descriptor left by a crashed CLI would
|
||||||
// reused, so a descriptor left behind by a CLI that crashed would
|
// otherwise mark a session as open for as long as something else held its
|
||||||
// otherwise mark a session as open for as long as something else held
|
// number. Checking both is what makes this a measurement.
|
||||||
// its number. Checking both is what makes this a measurement.
|
|
||||||
//
|
//
|
||||||
// The `LIVEKNOWN` line says the directory was there to be read at
|
// The `LIVEKNOWN` line says the directory was there to be read at all.
|
||||||
// all. Without it an old CLI that keeps no descriptors would look
|
// Without it an old CLI that keeps no descriptors would look exactly like a
|
||||||
// exactly like a machine with nothing running, which is the one
|
// machine with nothing running.
|
||||||
// mistake this check exists to prevent.
|
|
||||||
//
|
//
|
||||||
// Then two questions per file, both answered from the end of it.
|
// Then two questions per file, both answered from the end of it. A rename
|
||||||
|
// if there was one, grepped over the whole file rather than its tail
|
||||||
|
// because a session can be named early and talked in for hours after. Then
|
||||||
|
// the last several things a person said -- the *last*, because the question
|
||||||
|
// this answers is "which one was I just in", and several because the final
|
||||||
|
// ones are often the CLI's own.
|
||||||
//
|
//
|
||||||
// A rename, if there was one: `/rename` appends a `custom-title`
|
// Tool results are excluded rather than typed messages included, and the
|
||||||
// record, and a name somebody chose beats anything inferred from the
|
// difference matters: a tool result is *also* a user record, so grepping
|
||||||
// conversation. Grepped over the whole file rather than its tail,
|
// the type alone gave a session that ended mid-tool a tail of empty records.
|
||||||
// because a session can be named early and talked in for hours after.
|
// But matching only a string `content` was worse -- a message carrying an
|
||||||
//
|
// attachment stores its text in a list, so that reading lost twenty rows
|
||||||
// Then the last several things a person said. The *last*, not the
|
// rather than two. Excluding `tool_use_id` keeps both shapes of a real
|
||||||
// first: the question a list like this answers is "which one was I
|
// message and drops the one that is not.
|
||||||
// just in", and every session's opening line is the least distinctive
|
|
||||||
// thing about it. Several, because the final ones are often the CLI's
|
|
||||||
// own -- a slash command, the caveat wrapped around its output -- and
|
|
||||||
// one of those identifies nothing.
|
|
||||||
//
|
|
||||||
// Tool results are excluded rather than typed messages included, and
|
|
||||||
// the difference matters: a tool result is *also* a user record --
|
|
||||||
// it is how the API models one -- so grepping the type alone gave a
|
|
||||||
// session that ended mid-tool a tail of empty records and a row
|
|
||||||
// saying nothing was said, when plenty was. But matching only a
|
|
||||||
// string `content` was worse: a message carrying an attachment stores
|
|
||||||
// its text in a list, so that reading lost twenty rows rather than
|
|
||||||
// two. Excluding `tool_use_id` keeps both shapes of a real message
|
|
||||||
// and drops the one that is not.
|
|
||||||
let script = listing_script(r#""$HOME"/.claude/projects/*/*.jsonl"#);
|
let script = listing_script(r#""$HOME"/.claude/projects/*/*.jsonl"#);
|
||||||
let launch = Launch::new("sh", vec!["-c".to_string(), script], None);
|
let launch = Launch::new("sh", vec!["-c".to_string(), script], None);
|
||||||
parse_listing(&transport.capture(&launch).await?)
|
parse_listing(&transport.capture(&launch).await?)
|
||||||
@@ -175,13 +144,10 @@ pub async fn list(transport: &Transport) -> Result<Vec<Importable>> {
|
|||||||
|
|
||||||
/// The same listing, for one session named by id.
|
/// The same listing, for one session named by id.
|
||||||
///
|
///
|
||||||
/// Importing needs everything a row holds -- the path to follow, how many
|
/// Importing needs everything a row holds, and used to get it by listing
|
||||||
/// lines have already been written, what it is called, where it was working
|
/// *every* session and searching the result -- a full read of every transcript
|
||||||
/// and whether something else has it open -- and used to get them by
|
/// on the machine, seconds of it, to answer a question about one file, paid
|
||||||
/// listing *every* session and searching the result. That is a full read of
|
/// once per import in a batch. Same script, same parsing, one glob narrower.
|
||||||
/// every transcript on the machine, seconds of it, to answer a question
|
|
||||||
/// about one file; a batch of imports paid it once each. Same script, same
|
|
||||||
/// parsing, one glob narrower.
|
|
||||||
pub async fn find(transport: &Transport, id: &str) -> Result<Option<Importable>> {
|
pub async fn find(transport: &Transport, id: &str) -> Result<Option<Importable>> {
|
||||||
if !is_session_id(id) {
|
if !is_session_id(id) {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
@@ -199,18 +165,14 @@ pub async fn find(transport: &Transport, id: &str) -> Result<Option<Importable>>
|
|||||||
|
|
||||||
/// What the machine is asked, over whichever set of files `glob` names.
|
/// What the machine is asked, over whichever set of files `glob` names.
|
||||||
///
|
///
|
||||||
/// One script with the glob substituted rather than two that drift: the
|
/// One script with the glob substituted rather than two that drift: a row has
|
||||||
/// per-file half decides what a row *is*, and a row has to mean the same
|
/// to mean the same thing whether it came from a listing or a lookup. The glob
|
||||||
/// thing whether it arrived from a listing or from a lookup. The glob is
|
/// is this module's own text; the only thing that crosses from outside is the
|
||||||
/// this module's own text; the only thing that ever crosses from outside is
|
/// id, which stays an argument and is checked by [`is_session_id`] first.
|
||||||
/// the id, which stays an argument (`$1`) and is checked by
|
|
||||||
/// [`is_session_id`] first.
|
|
||||||
fn listing_script(glob: &str) -> String {
|
fn listing_script(glob: &str) -> String {
|
||||||
// `replace` rather than `format!`: this is shell, so it is full of
|
// `replace` rather than `format!`: this is shell, so it is full of braces,
|
||||||
// braces -- `${s##*/}`, an awk program, the `{[^}]*` that finds a usage
|
// and every one would have to be doubled to survive a format string --
|
||||||
// record -- and every one of them would have to be doubled to survive a
|
// exactly the kind of edit that looks right and changes what the shell runs.
|
||||||
// format string. Doubling braces inside a script is exactly the kind of
|
|
||||||
// edit that looks right and changes what the shell runs.
|
|
||||||
SCRIPT.replace("{glob}", glob)
|
SCRIPT.replace("{glob}", glob)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,21 +222,18 @@ fn parse_listing(found: &str) -> Result<Vec<Importable>> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
// One row per session id, because the id is what everything downstream
|
// One row per session id, because the id is what everything downstream
|
||||||
// addresses: `--resume` takes it, deleting globs for it, and the
|
// addresses: `--resume` takes it, deleting globs for it, the in-flight
|
||||||
// in-flight registry is keyed on it. So two rows sharing an id are two
|
// registry is keyed on it, and the phone keys its list on it -- which
|
||||||
// rows that no operation can tell apart -- and the phone keys its list
|
// turned two rows sharing an id into a crash rather than a confusion.
|
||||||
// on it too, which turned this into a crash rather than a confusion.
|
|
||||||
//
|
//
|
||||||
// It is a real state of the machine, not corruption: resuming a session
|
// It is a real state of the machine, not corruption: resuming from a
|
||||||
// from a different working directory makes the CLI write a second file
|
// different working directory makes the CLI write a second file under that
|
||||||
// under that directory's project folder with the same id. One of the two
|
// directory's project folder with the same id. One is then usually a stub
|
||||||
// is then usually a stub of a few hundred bytes and the other is the
|
// of a few hundred bytes.
|
||||||
// conversation somebody means.
|
|
||||||
//
|
//
|
||||||
// So the copy with the most in it wins, and the row's `cwd` comes from
|
// So the copy with the most in it wins, and the row's `cwd` comes from that
|
||||||
// that same copy -- which is the directory `--resume` will find it under.
|
// same copy. Ties go to the more recent, and the *stub* is often the more
|
||||||
// Ties go to the more recent, and the *stub* is often the more recent, so
|
// recent, so size has to be the first key rather than the tie-break.
|
||||||
// the size has to be the first key rather than the tie-break.
|
|
||||||
sessions.sort_by(|a, b| {
|
sessions.sort_by(|a, b| {
|
||||||
b.lines
|
b.lines
|
||||||
.cmp(&a.lines)
|
.cmp(&a.lines)
|
||||||
@@ -283,12 +242,10 @@ fn parse_listing(found: &str) -> Result<Vec<Importable>> {
|
|||||||
let mut seen = std::collections::HashSet::new();
|
let mut seen = std::collections::HashSet::new();
|
||||||
sessions.retain(|session| seen.insert(session.id.clone()));
|
sessions.retain(|session| seen.insert(session.id.clone()));
|
||||||
|
|
||||||
// Most recent first, and only that. Naming was tried as the first key
|
// Most recent first, and only that. Naming was tried as the first key and
|
||||||
// and is a worse list: it buries what somebody was just doing under
|
// is a worse list: it buries what somebody was just doing under everything
|
||||||
// everything they ever named, and the reason to open this screen is
|
// they ever named. A name still shows, as the row's title and as a word
|
||||||
// almost always to pick up where they left off. A name still shows,
|
// beside it.
|
||||||
// as the row's title and as a word beside it -- being easier to
|
|
||||||
// recognise is what a name is for, and it does not need the order too.
|
|
||||||
sessions.sort_by(|a, b| b.modified.total_cmp(&a.modified));
|
sessions.sort_by(|a, b| b.modified.total_cmp(&a.modified));
|
||||||
Ok(sessions)
|
Ok(sessions)
|
||||||
}
|
}
|
||||||
@@ -322,23 +279,21 @@ fn parse_row(line: &str) -> Option<Importable> {
|
|||||||
if !is_hidden(&record)
|
if !is_hidden(&record)
|
||||||
&& let Some(text) = first_line_of(&record)
|
&& let Some(text) = first_line_of(&record)
|
||||||
{
|
{
|
||||||
// Kept rather than broken out of: these arrive oldest first,
|
// Kept rather than broken out of: these arrive oldest first, so the
|
||||||
// so the last one to survive the filter is the most recent
|
// last to survive the filter is the most recent thing said.
|
||||||
// thing that was actually said.
|
|
||||||
said = Some(text);
|
said = Some(text);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Some(Importable {
|
Some(Importable {
|
||||||
id,
|
id,
|
||||||
// Filled in by `list`, which is the only thing that knows: it
|
// Filled in by `list`, which is the only thing that knows: it takes one
|
||||||
// takes one command to ask a machine, and asking per row would be
|
// command to ask a machine, and asking per row would be one ssh
|
||||||
// one ssh connection each.
|
// connection each.
|
||||||
in_use: InUse::Unknown,
|
in_use: InUse::Unknown,
|
||||||
cwd: cwd.unwrap_or_default(),
|
cwd: cwd.unwrap_or_default(),
|
||||||
// A name somebody typed outranks anything read out of the
|
// A name somebody typed outranks anything read out of the conversation,
|
||||||
// conversation, because they chose it to answer this exact
|
// because they chose it to answer this exact question.
|
||||||
// question.
|
|
||||||
named: named.is_some(),
|
named: named.is_some(),
|
||||||
title: named
|
title: named
|
||||||
.or(said)
|
.or(said)
|
||||||
@@ -351,24 +306,21 @@ fn parse_row(line: &str) -> Option<Importable> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The input tokens named in one `usage` object, added up.
|
/// The input tokens named in one `usage` object, added up: prompt plus cache
|
||||||
///
|
/// creation plus cache read, all three being context the model was given. The
|
||||||
/// Prompt plus cache creation plus cache read: all three are context the
|
/// definition is [`driver::context_tokens`]; this is the same three figures dug
|
||||||
/// model was given -- the definition is [`driver::context_tokens`]; this
|
/// out of a raw line rather than a parsed one, because these files reach tens
|
||||||
/// is the same three figures dug out of a raw line rather than a parsed
|
/// of megabytes.
|
||||||
/// one, because these files reach tens of megabytes.
|
|
||||||
///
|
///
|
||||||
/// `None` for an empty blob, meaning no assistant turn has recorded usage.
|
/// `None` for an empty blob, meaning no assistant turn has recorded usage.
|
||||||
/// Missing individual fields count as zero, which is what an absent
|
/// Missing fields count as zero, which is what an absent category means.
|
||||||
/// category means; an unparseable one does the same rather than
|
|
||||||
/// discarding the figures that did read.
|
|
||||||
fn context_tokens(usage: &str) -> Option<u64> {
|
fn context_tokens(usage: &str) -> Option<u64> {
|
||||||
if usage.trim().is_empty() {
|
if usage.trim().is_empty() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
// The leading quote matters: without it `"input_tokens"` also matches
|
// The leading quote matters: without it `"input_tokens"` also matches
|
||||||
// inside `"cache_read_input_tokens"`, and the same number gets counted
|
// inside `"cache_read_input_tokens"`, and the same number is counted three
|
||||||
// three times.
|
// times.
|
||||||
let field = |name: &str| -> u64 {
|
let field = |name: &str| -> u64 {
|
||||||
usage
|
usage
|
||||||
.split_once(&format!("\"{name}\":"))
|
.split_once(&format!("\"{name}\":"))
|
||||||
@@ -388,12 +340,10 @@ fn context_tokens(usage: &str) -> Option<u64> {
|
|||||||
|
|
||||||
/// The first line of what a person typed, short enough for a list row.
|
/// The first line of what a person typed, short enough for a list row.
|
||||||
///
|
///
|
||||||
/// None for the CLI's own plumbing. A slash command, the caveat wrapped
|
/// None for the CLI's own plumbing. A slash command, the caveat wrapped around
|
||||||
/// around a local command's output, and an injected reminder are all
|
/// a local command's output, and an injected reminder are all stored as
|
||||||
/// stored as ordinary user records without the `isMeta` flag -- so titling
|
/// ordinary user records without `isMeta` -- so titling by "first user record"
|
||||||
/// by "first user record" gave a list where most rows read
|
/// gave a list where most rows read `<command-name>/clear</command-name>`.
|
||||||
/// `<command-name>/clear</command-name>`, which identifies nothing. The
|
|
||||||
/// caller offers several candidates for exactly this reason.
|
|
||||||
fn first_line_of(record: &Value) -> Option<String> {
|
fn first_line_of(record: &Value) -> Option<String> {
|
||||||
let text = text_of(record.get("message")?.get("content")?);
|
let text = text_of(record.get("message")?.get("content")?);
|
||||||
let first = text.lines().find(|line| !line.trim().is_empty())?.trim();
|
let first = text.lines().find(|line| !line.trim().is_empty())?.trim();
|
||||||
@@ -404,19 +354,15 @@ fn first_line_of(record: &Value) -> Option<String> {
|
|||||||
(!trimmed.is_empty()).then_some(trimmed)
|
(!trimmed.is_empty()).then_some(trimmed)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Records the transcript should not show: a subagent's private
|
/// Records the transcript should not show: a subagent's private conversation,
|
||||||
/// conversation, and the CLI's own injected notes.
|
/// and the CLI's own injected notes. The same rule the live translator applies
|
||||||
///
|
/// -- a sidechain is another agent talking to itself, and duplicating it would
|
||||||
/// The same rule the live translator applies -- a sidechain is another
|
|
||||||
/// agent talking to itself, and duplicating it into this transcript would
|
|
||||||
/// show the reader two conversations interleaved as one.
|
/// show the reader two conversations interleaved as one.
|
||||||
fn is_hidden(record: &Value) -> bool {
|
fn is_hidden(record: &Value) -> bool {
|
||||||
record.get("isSidechain").and_then(Value::as_bool) == Some(true)
|
record.get("isSidechain").and_then(Value::as_bool) == Some(true)
|
||||||
|| record.get("isMeta").and_then(Value::as_bool) == Some(true)
|
|| record.get("isMeta").and_then(Value::as_bool) == Some(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Concatenated text of a message's content, which is either a bare string
|
|
||||||
/// or the API's list of blocks.
|
|
||||||
fn text_of(content: &Value) -> String {
|
fn text_of(content: &Value) -> String {
|
||||||
match content {
|
match content {
|
||||||
Value::String(text) => text.clone(),
|
Value::String(text) => text.clone(),
|
||||||
@@ -432,36 +378,31 @@ fn text_of(content: &Value) -> String {
|
|||||||
|
|
||||||
/// Whether a directory the machine recorded is still there.
|
/// Whether a directory the machine recorded is still there.
|
||||||
///
|
///
|
||||||
/// Asked because a session's recorded cwd can outlive the directory: these
|
/// A session's recorded cwd can outlive the directory: these files go back
|
||||||
/// files go back months, and a checkout that moved leaves every session
|
/// months, and a checkout that moved leaves every session from before it
|
||||||
/// from before the move pointing at a path that is gone. Resuming into one
|
/// pointing at a path that is gone. Resuming into one fails at `cd` before the
|
||||||
/// fails at `cd` before the CLI starts, which is a confusing way to meet a
|
/// CLI starts.
|
||||||
/// feature whose whole promise is "carry on where you left off".
|
|
||||||
pub async fn directory_exists(transport: &Transport, path: &str) -> bool {
|
pub async fn directory_exists(transport: &Transport, path: &str) -> bool {
|
||||||
if path.is_empty() {
|
if path.is_empty() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// Asked by *entering* it rather than by `test -d <path>`, because the
|
// Asked by *entering* it rather than by `test -d <path>`, because the
|
||||||
// question this is standing in for is "can a session start here" and
|
// question this stands in for is "can a session start here" and because a
|
||||||
// because a path is only expanded where it is a working directory --
|
// path is only expanded where it is a working directory -- `~/repos/ai-app`
|
||||||
// `~/repos/ai-app` as an argument stays four literal characters on
|
// as an argument stays literal on both transports, so the old form answered
|
||||||
// both transports (`ssh::quote_path`, `ssh::expand_home`), so the old
|
// "no such directory" about every home-relative path somebody typed.
|
||||||
// form answered "no such directory" about every home-relative path
|
|
||||||
// somebody typed.
|
|
||||||
let launch = Launch::new("true", Vec::new(), Some(std::path::Path::new(path)));
|
let launch = Launch::new("true", Vec::new(), Some(std::path::Path::new(path)));
|
||||||
transport.capture(&launch).await.is_ok()
|
transport.capture(&launch).await.is_ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reads the tail of one session's file, as the raw JSONL.
|
/// Reads the tail of one session's file, as the raw JSONL.
|
||||||
///
|
///
|
||||||
/// `tail` rather than the whole file, and as [`Launch`] arguments rather
|
/// `tail` rather than the whole file, and as [`Launch`] arguments rather than a
|
||||||
/// than a shell string, so the path is an argument and never syntax.
|
/// shell string, so the path is an argument and never syntax.
|
||||||
///
|
///
|
||||||
/// Returns text rather than events because turning records into events has
|
/// Returns text rather than events because turning records into events has a
|
||||||
/// a side effect -- writing out the images they carry -- and it needs the
|
/// side effect -- writing out the images they carry -- and that needs the
|
||||||
/// session directory to write them into. That directory does not exist
|
/// session directory, which does not exist until after this runs.
|
||||||
/// until the session is created, which is after this runs, so the
|
|
||||||
/// conversion happens there instead. See [`events_from`].
|
|
||||||
pub async fn read_tail(transport: &Transport, path: &str) -> Result<String> {
|
pub async fn read_tail(transport: &Transport, path: &str) -> Result<String> {
|
||||||
let launch = Launch::new(
|
let launch = Launch::new(
|
||||||
"tail",
|
"tail",
|
||||||
@@ -477,32 +418,28 @@ pub async fn read_tail(transport: &Transport, path: &str) -> Result<String> {
|
|||||||
/// Claude Code's stored JSONL as this project's events.
|
/// Claude Code's stored JSONL as this project's events.
|
||||||
///
|
///
|
||||||
/// A partial first line is expected and ignored: `tail -n` cuts at a line
|
/// A partial first line is expected and ignored: `tail -n` cuts at a line
|
||||||
/// boundary, but the *file* may have been appended to since, and a line
|
/// boundary, but the *file* may have been appended to since.
|
||||||
/// that does not parse is one this reader has no opinion about.
|
|
||||||
///
|
///
|
||||||
/// `session_dir` is where images found along the way are written, the same
|
/// `session_dir` is where images found along the way are written, the same
|
||||||
/// place and by the same function the live translator uses -- so a
|
/// place and by the same function the live translator uses -- so a screenshot
|
||||||
/// screenshot looks identical whether it was watched as it happened or
|
/// looks identical whether it was watched happening or replayed afterwards.
|
||||||
/// replayed afterwards. It is only the *reference* that reaches the phone;
|
/// Only the *reference* reaches the phone.
|
||||||
/// the bytes are fetched from `/sessions/{id}/files/{ref}` when something
|
|
||||||
/// actually draws them, and none of this is ever sent back to the CLI,
|
|
||||||
/// which reads its own session file.
|
|
||||||
pub fn events_from(text: &str, session_dir: &std::path::Path) -> Vec<Event> {
|
pub fn events_from(text: &str, session_dir: &std::path::Path) -> Vec<Event> {
|
||||||
let mut events = Vec::new();
|
let mut events = Vec::new();
|
||||||
// What the newest record that had an opinion says the session is
|
// What the newest record that had an opinion says the session is doing.
|
||||||
// doing. Kept to the end rather than pushed as it is found, because
|
// Kept to the end rather than pushed as it is found, because the answer is
|
||||||
// the answer is the last one and everything before it is history.
|
// the last one and everything before it is history.
|
||||||
let mut state = None;
|
let mut state = None;
|
||||||
for line in text.lines() {
|
for line in text.lines() {
|
||||||
let Ok(record) = serde_json::from_str::<Value>(line) else {
|
let Ok(record) = serde_json::from_str::<Value>(line) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
if let Some(peer) = peer_message(&record) {
|
if let Some(peer) = peer_message(&record) {
|
||||||
// Before `is_hidden`, which these records are: the CLI marks
|
// Before `is_hidden`, which these records are: the CLI marks them
|
||||||
// them meta because they are not the user's own words, and
|
// meta because they are not the user's own words, and that is the
|
||||||
// that is the reason to draw them differently rather than the
|
// reason to draw them differently rather than to drop them. A
|
||||||
// reason to drop them. A session working on something a phone
|
// session working on something a phone never asked for is otherwise
|
||||||
// never asked for is otherwise unexplainable from the phone.
|
// unexplainable from the phone.
|
||||||
state = turn_state(&record).or(state);
|
state = turn_state(&record).or(state);
|
||||||
events.push(peer);
|
events.push(peer);
|
||||||
continue;
|
continue;
|
||||||
@@ -531,20 +468,18 @@ pub fn events_from(text: &str, session_dir: &std::path::Path) -> Vec<Event> {
|
|||||||
|
|
||||||
/// A message from another agent, as the CLI reports one.
|
/// A message from another agent, as the CLI reports one.
|
||||||
///
|
///
|
||||||
/// Measured from a real session file (2026-08-29): the record is a `user`
|
/// Measured from a real session file (2026-08-29): the record is a `user` one
|
||||||
/// one marked `isMeta`, and its `origin` carries `kind: "peer"`, the
|
/// marked `isMeta`, and its `origin` carries `kind: "peer"`, the sending
|
||||||
/// sending session's `name`, and the message itself as `body`. The
|
/// session's `name`, and the message as `body`. The message content beside it
|
||||||
/// message content beside it is the same text wrapped in an explanatory
|
/// is the same text wrapped in a preamble written for the model rather than for
|
||||||
/// preamble and a `<cross-session-message>` tag, which is written for the
|
/// a person, so the body is what a reader is shown.
|
||||||
/// model that has to read it rather than for a person -- so the body is
|
|
||||||
/// what a reader is shown, and the name is who they are told sent it.
|
|
||||||
///
|
///
|
||||||
/// Shared with the live driver (`claude::translate`), which finds the same
|
/// Shared with the live driver, which finds the same `origin` object on a
|
||||||
/// `origin` object on a different record -- so this reads the object and
|
/// different record -- so this reads the object and not the record around it.
|
||||||
/// not the record around it. One function because it is one wire format:
|
/// One function because it is one wire format: two copies would drift the first
|
||||||
/// two copies would drift the first time the CLI renames a field, and the
|
/// time the CLI renames a field, and the half that drifted would produce
|
||||||
/// half that drifted would go on producing nothing at all, which is
|
/// nothing at all, which is indistinguishable from nobody having sent
|
||||||
/// indistinguishable from nobody having sent anything.
|
/// anything.
|
||||||
pub(in crate::session) fn peer_message(record: &Value) -> Option<Event> {
|
pub(in crate::session) fn peer_message(record: &Value) -> Option<Event> {
|
||||||
let origin = record.get("origin")?;
|
let origin = record.get("origin")?;
|
||||||
if origin.get("kind").and_then(Value::as_str) != Some("peer") {
|
if origin.get("kind").and_then(Value::as_str) != Some("peer") {
|
||||||
@@ -562,27 +497,22 @@ pub(in crate::session) fn peer_message(record: &Value) -> Option<Event> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether this record means the session is working, as far as it can be
|
/// Whether this record means the session is working, as far as the file can
|
||||||
/// told from the file.
|
/// say.
|
||||||
///
|
///
|
||||||
/// The one thing a session file does not contain is the CLI saying "this
|
/// The one thing a session file does not contain is the CLI saying "this turn
|
||||||
/// turn is over": there is no `result` record, only the messages. What
|
/// is over": there is no `result` record. What there is instead is why the last
|
||||||
/// there is instead is why the last assistant message stopped, and that
|
/// assistant message stopped -- `tool_use` means a call is being made and more
|
||||||
/// answers it -- `tool_use` means a call is being made and more is coming,
|
/// is coming, anything else means the model has finished talking. Anything on
|
||||||
/// anything else means the model has finished talking. Anything on the
|
/// the user's side means the session has something to answer.
|
||||||
/// user's side of the conversation -- a person, a tool's result, another
|
|
||||||
/// agent -- means the session has something to answer and is answering it.
|
|
||||||
///
|
///
|
||||||
/// `None` is the third answer and it matters: a record that says nothing
|
/// `None` is the third answer and it matters: a record that says nothing about
|
||||||
/// about the turn leaves the status alone rather than voting for idle. The
|
/// the turn leaves the status alone rather than voting for idle.
|
||||||
/// same goes for a record whose reason for stopping is missing, which is
|
|
||||||
/// what a future CLI adding a shape we do not know looks like.
|
|
||||||
///
|
///
|
||||||
/// What this cannot see is a session that stopped existing mid-turn -- its
|
/// What this cannot see is a session that stopped existing mid-turn -- its
|
||||||
/// file's last record still says `tool_use`, so it reads as working
|
/// file's last record still says `tool_use`, so it reads as working forever.
|
||||||
/// forever. Nothing in the file distinguishes that from a model thinking,
|
/// Nothing in the file distinguishes that from a model thinking, and inventing
|
||||||
/// and inventing a timeout here would replace a stale reading with a
|
/// a timeout would replace a stale reading with a confident wrong one.
|
||||||
/// confident wrong one.
|
|
||||||
fn turn_state(record: &Value) -> Option<super::driver::SessionStatus> {
|
fn turn_state(record: &Value) -> Option<super::driver::SessionStatus> {
|
||||||
use super::driver::SessionStatus;
|
use super::driver::SessionStatus;
|
||||||
match record.get("type").and_then(Value::as_str)? {
|
match record.get("type").and_then(Value::as_str)? {
|
||||||
@@ -600,20 +530,19 @@ fn turn_state(record: &Value) -> Option<super::driver::SessionStatus> {
|
|||||||
|
|
||||||
fn push_user(events: &mut Vec<Event>, content: &Value, session_dir: &std::path::Path) {
|
fn push_user(events: &mut Vec<Event>, content: &Value, session_dir: &std::path::Path) {
|
||||||
// A tool result arrives as a user record, because that is how the API
|
// A tool result arrives as a user record, because that is how the API
|
||||||
// models it -- but it is the other half of a tool call, not something
|
// models it -- but it is the other half of a tool call, and showing it as a
|
||||||
// a person said, and showing it as a message would put the reader's
|
// message would put the reader's own words and a command's output in the
|
||||||
// own words and a command's output in the same voice.
|
// same voice.
|
||||||
if let Value::Array(blocks) = content {
|
if let Value::Array(blocks) = content {
|
||||||
for block in blocks {
|
for block in blocks {
|
||||||
// A picture the person attached to their own message, rather
|
// A picture the person attached to their own message rather than
|
||||||
// than one a tool produced. Same block shape, one level up.
|
// one a tool produced. Same block shape, one level up.
|
||||||
push_images(events, std::slice::from_ref(block), session_dir, None);
|
push_images(events, std::slice::from_ref(block), session_dir, None);
|
||||||
if block.get("type").and_then(Value::as_str) == Some("tool_result")
|
if block.get("type").and_then(Value::as_str) == Some("tool_result")
|
||||||
&& let Some(id) = block.get("tool_use_id").and_then(Value::as_str)
|
&& let Some(id) = block.get("tool_use_id").and_then(Value::as_str)
|
||||||
{
|
{
|
||||||
// Before the tool's own row, matching the live translator:
|
// Before the tool's own row, matching the live translator: a
|
||||||
// a screenshot belongs to the call that took it, and after
|
// screenshot belongs to the call that took it.
|
||||||
// the result it reads as belonging to whatever came next.
|
|
||||||
if let Some(Value::Array(parts)) = block.get("content") {
|
if let Some(Value::Array(parts)) = block.get("content") {
|
||||||
push_images(events, parts, session_dir, Some(id));
|
push_images(events, parts, session_dir, Some(id));
|
||||||
}
|
}
|
||||||
@@ -626,12 +555,10 @@ fn push_user(events: &mut Vec<Event>, content: &Value, session_dir: &std::path::
|
|||||||
}
|
}
|
||||||
let text = text_of(content);
|
let text = text_of(content);
|
||||||
if !text.trim().is_empty() {
|
if !text.trim().is_empty() {
|
||||||
// Replayed from the CLI's own file: it was read long ago, so
|
// Replayed from the CLI's own file: it was read long ago, so there is
|
||||||
// there is no waiting bubble for it to resolve.
|
// no waiting bubble for it to resolve. Its images are saved and
|
||||||
// The images in this record are saved and referenced separately just
|
// referenced separately just above, because they came out of somebody
|
||||||
// above, because a replayed message's pictures came out of somebody
|
// else's file rather than this app's composer.
|
||||||
// else's file rather than out of this app's composer -- there is no
|
|
||||||
// upload here whose refs could ride on the message.
|
|
||||||
events.push(Event::UserMessage {
|
events.push(Event::UserMessage {
|
||||||
id: None,
|
id: None,
|
||||||
text,
|
text,
|
||||||
@@ -640,11 +567,10 @@ fn push_user(events: &mut Vec<Event>, content: &Value, session_dir: &std::path::
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Saves every image block in `parts` and references each one.
|
/// Saves every image block in `parts` and references each one. `about` is the
|
||||||
///
|
/// call the images came out of, or `None` for one a person attached to their
|
||||||
/// `about` is the call the images came out of, or `None` for one a person attached
|
/// own message -- the same distinction the live translator makes, so replayed
|
||||||
/// to their own message -- the same distinction the live translator makes, so replayed
|
/// history draws a screenshot under the call that took it.
|
||||||
/// history draws a screenshot under the call that took it exactly as a live one does.
|
|
||||||
fn push_images(
|
fn push_images(
|
||||||
events: &mut Vec<Event>,
|
events: &mut Vec<Event>,
|
||||||
parts: &[Value],
|
parts: &[Value],
|
||||||
@@ -698,21 +624,17 @@ fn push_assistant(events: &mut Vec<Event>, content: &Value) {
|
|||||||
/// Removes each id it is given and prints one `<id>\t<state>` line per id.
|
/// Removes each id it is given and prints one `<id>\t<state>` line per id.
|
||||||
///
|
///
|
||||||
/// The three states are every way removing one id can end: `deleted` if at
|
/// The three states are every way removing one id can end: `deleted` if at
|
||||||
/// least one file went, `missing` if the glob matched nothing, `failed` if
|
/// least one file went, `missing` if the glob matched nothing, `failed` if an
|
||||||
/// an `rm` refused. "Not there" is deliberately kept apart from "it broke"
|
/// `rm` refused. "Not there" is deliberately kept apart from "it broke" --
|
||||||
/// rather than folded together by the shell -- only one of them is worth
|
/// only one of them is worth retrying.
|
||||||
/// retrying, and the caller is what knows how to word either.
|
|
||||||
///
|
///
|
||||||
/// Every copy of each id, not the first. The same id can name a file under
|
/// Every copy of each id, not the first. The same id can name a file under two
|
||||||
/// two project directories -- see the de-duplication in `parse_listing` --
|
/// project directories, and stopping at the first left the other behind, so the
|
||||||
/// and stopping at the first left the other behind, so the row came back on
|
/// row came back on the next listing after a delete that reported success.
|
||||||
/// the next listing after a delete that had reported success. `failed`
|
/// `failed` therefore sticks once set.
|
||||||
/// therefore sticks once set: one copy removed and another refused is not a
|
|
||||||
/// success.
|
|
||||||
///
|
///
|
||||||
/// Ids arrive as arguments rather than in the script text, so nothing here
|
/// Ids arrive as arguments rather than in the script text; `is_session_id` is
|
||||||
/// is shell syntax; `is_session_id` is what keeps one from globbing its way
|
/// what keeps one from globbing its way out of the projects directory.
|
||||||
/// out of the projects directory.
|
|
||||||
const DELETE_SCRIPT: &str = r#"
|
const DELETE_SCRIPT: &str = r#"
|
||||||
for id do
|
for id do
|
||||||
state=missing
|
state=missing
|
||||||
@@ -730,37 +652,30 @@ done
|
|||||||
|
|
||||||
/// Deletes sessions [`list`] reported, and says what happened to each.
|
/// Deletes sessions [`list`] reported, and says what happened to each.
|
||||||
///
|
///
|
||||||
/// By id, resolved on the machine against what it actually has, so the
|
/// By id, resolved on the machine against what it actually has, so the caller
|
||||||
/// caller never names a file -- the same rule importing follows, and it
|
/// never names a file -- the same rule importing follows, and it matters more
|
||||||
/// matters more here: this one removes something.
|
/// here: this one removes something.
|
||||||
///
|
///
|
||||||
/// Irreversible, and the caller is expected to have said so. Claude Code
|
/// Irreversible, and the caller is expected to have said so. Claude Code keeps
|
||||||
/// keeps no copy: the JSONL *is* the session, so deleting it ends any
|
/// no copy: the JSONL *is* the session.
|
||||||
/// chance of resuming that conversation, including from an ai-app session
|
|
||||||
/// that was already importing it.
|
|
||||||
///
|
///
|
||||||
/// The whole batch in one invocation, which over ssh is the difference
|
/// The whole batch in one invocation, which over ssh is the difference between
|
||||||
/// between one connection and one per session. Six deletes started in the
|
/// one connection and one per session. Six deletes started in the same tick
|
||||||
/// same tick were six `ssh` processes racing to authenticate, and a batch
|
/// were six `ssh` processes racing to authenticate, and a batch past the remote
|
||||||
/// big enough to pass the remote sshd's `MaxStartups` (10 unauthenticated
|
/// sshd's `MaxStartups` had rows come back as `Connection closed by …` -- a row
|
||||||
/// connections, by default, before it begins refusing) had rows come back
|
/// reporting a delete that never ran, for a reason nothing to do with the
|
||||||
/// as `Connection closed by … port 2222` -- a row reporting a delete that
|
/// session.
|
||||||
/// never ran, for a reason that has nothing to do with the session. One
|
|
||||||
/// connection cannot exceed that however many ids are selected.
|
|
||||||
///
|
///
|
||||||
/// Still one outcome per id, because a batch is not a transaction: six
|
/// Still one outcome per id, because a batch is not a transaction. Every
|
||||||
/// removals that must all succeed or all roll back is not something a
|
/// requested id gets an entry, so an id the machine said nothing about is
|
||||||
/// filesystem offers, and the caller settles each row from its own line.
|
/// reported as such rather than defaulting to either answer.
|
||||||
/// Every requested id gets an entry, so an id the machine said nothing
|
|
||||||
/// about is reported as such rather than defaulting to either answer.
|
|
||||||
pub async fn delete(
|
pub async fn delete(
|
||||||
transport: &Transport,
|
transport: &Transport,
|
||||||
ids: &[String],
|
ids: &[String],
|
||||||
) -> Result<HashMap<String, Result<(), String>>> {
|
) -> Result<HashMap<String, Result<(), String>>> {
|
||||||
// Refused here rather than on the machine: `is_session_id` is what
|
// Refused here rather than on the machine: `is_session_id` is what keeps an
|
||||||
// keeps an id from walking out of the projects directory, and a bad
|
// id from walking out of the projects directory. It fails only itself --
|
||||||
// one must never reach the glob. It fails only itself -- one malformed
|
// one malformed id is not a reason to leave the other five in place.
|
||||||
// id is not a reason to leave the other five in place.
|
|
||||||
let (safe, mut outcomes): (Vec<&String>, HashMap<String, Result<(), String>>) =
|
let (safe, mut outcomes): (Vec<&String>, HashMap<String, Result<(), String>>) =
|
||||||
ids.iter().fold(
|
ids.iter().fold(
|
||||||
(Vec::new(), HashMap::new()),
|
(Vec::new(), HashMap::new()),
|
||||||
@@ -780,23 +695,16 @@ pub async fn delete(
|
|||||||
return Ok(outcomes);
|
return Ok(outcomes);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The file name *is* the id, so the machine can find it by name. This
|
// The file name *is* the id, so the machine can find it by name. This used
|
||||||
// used to call `list` and search its output, which is correct and costs
|
// to call `list` and search its output, which is correct and costs a full
|
||||||
// a full read of every transcript on the machine -- around four seconds
|
// read of every transcript on the machine -- around four seconds against a
|
||||||
// against a gigabyte of them, per delete, so a batch of ten took the
|
// gigabyte of them, per delete.
|
||||||
// best part of a minute doing nothing but re-reading the same files.
|
|
||||||
// `context_of` below already resolved an id the cheap way; this is the
|
|
||||||
// same lookup, and the two now agree.
|
|
||||||
//
|
//
|
||||||
// Every copy of each id, not the first. The same id can name a file
|
// Every copy of each id, not the first: the same id can name a file under
|
||||||
// under two project directories -- see the de-duplication in
|
// two project directories, and stopping at the first left the other behind.
|
||||||
// `parse_listing` -- and stopping at the first left the other behind,
|
|
||||||
// so the row came back on the next listing after a delete that had
|
|
||||||
// reported success.
|
|
||||||
//
|
//
|
||||||
// Each id prints its own verdict rather than the loop exiting on the
|
// Each id prints its own verdict rather than the loop exiting on the first
|
||||||
// first failure: with a batch, exiting would leave every id after it
|
// failure, which would leave every id after it unexplained.
|
||||||
// unexplained. See [`DELETE_SCRIPT`] for what the words mean.
|
|
||||||
let mut args = vec![
|
let mut args = vec![
|
||||||
"-c".to_string(),
|
"-c".to_string(),
|
||||||
DELETE_SCRIPT.to_string(),
|
DELETE_SCRIPT.to_string(),
|
||||||
@@ -806,8 +714,7 @@ pub async fn delete(
|
|||||||
let launch = Launch::new("sh", args, None);
|
let launch = Launch::new("sh", args, None);
|
||||||
|
|
||||||
// A failure to run the script at all is the machine being unreachable,
|
// A failure to run the script at all is the machine being unreachable,
|
||||||
// which is true of every id in the batch rather than of any one of
|
// which is true of every id in the batch rather than of any one of them.
|
||||||
// them -- so it is returned as the error, not written into each row.
|
|
||||||
let reported = transport
|
let reported = transport
|
||||||
.capture(&launch)
|
.capture(&launch)
|
||||||
.await
|
.await
|
||||||
@@ -826,10 +733,10 @@ pub async fn delete(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Anything the machine did not mention. The connection can drop
|
// Anything the machine did not mention. The connection can drop part-way
|
||||||
// part-way through the loop, and an id whose line never arrived is one
|
// through the loop, and an id whose line never arrived is one nobody knows
|
||||||
// nobody knows the fate of -- which is its own answer, and must not be
|
// the fate of -- which is its own answer, and must not read as either a
|
||||||
// read as either a success or a clean "not there".
|
// success or a clean "not there".
|
||||||
for id in safe {
|
for id in safe {
|
||||||
outcomes.entry(id.clone()).or_insert_with(|| {
|
outcomes.entry(id.clone()).or_insert_with(|| {
|
||||||
Err(format!(
|
Err(format!(
|
||||||
@@ -843,40 +750,34 @@ pub async fn delete(
|
|||||||
|
|
||||||
/// Whether an id is one of ours to put in a shell glob.
|
/// Whether an id is one of ours to put in a shell glob.
|
||||||
///
|
///
|
||||||
/// Both places that resolve an id to a file interpolate it into
|
/// Both places that resolve an id interpolate it into
|
||||||
/// `$HOME/.claude/projects/*/"$1".jsonl`. That is an argument rather than
|
/// `$HOME/.claude/projects/*/"$1".jsonl`. That is an argument rather than
|
||||||
/// script text, so a shell cannot be talked into running something -- but a
|
/// script text, so a shell cannot be talked into running something -- but a `/`
|
||||||
/// `/` or a `..` inside it still walks the glob out of the directory the id
|
/// or a `..` inside it still walks the glob out of the directory the id is
|
||||||
/// is supposed to name. [`delete`] is where that would be fatal, because it
|
/// supposed to name, and [`delete`] removes whatever it lands on.
|
||||||
/// removes whatever it lands on, and it is exactly the reason `delete` used
|
|
||||||
/// to resolve ids by searching a listing instead.
|
|
||||||
///
|
///
|
||||||
/// Claude Code names each transcript with a uuid, so hex and dashes is the
|
/// Claude Code names each transcript with a uuid, so hex and dashes is the
|
||||||
/// whole alphabet. Refused rather than escaped: an id that is not one of
|
/// whole alphabet. Refused rather than escaped.
|
||||||
/// these did not come from the list this app showed.
|
|
||||||
fn is_session_id(id: &str) -> bool {
|
fn is_session_id(id: &str) -> bool {
|
||||||
!id.is_empty() && id.len() <= 64 && id.bytes().all(|b| b.is_ascii_hexdigit() || b == b'-')
|
!id.is_empty() && id.len() <= 64 && id.bytes().all(|b| b.is_ascii_hexdigit() || b == b'-')
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How often an imported session checks whether its source file grew.
|
/// How often an imported session checks whether its source file grew.
|
||||||
///
|
///
|
||||||
/// A poll rather than a watch, because the file may be on another machine
|
/// A poll rather than a watch, because the file may be on another machine and
|
||||||
/// and there is no portable way to be told. Ten seconds is chosen against
|
/// there is no portable way to be told. Ten seconds is chosen against the cost
|
||||||
/// the cost of an ssh round trip rather than against how fast a person
|
/// of an ssh round trip rather than against how fast a person types.
|
||||||
/// types: nothing here is waiting on it, and the events arrive on the same
|
|
||||||
/// stream as everything else once they do.
|
|
||||||
pub const SYNC_INTERVAL: std::time::Duration = std::time::Duration::from_secs(10);
|
pub const SYNC_INTERVAL: std::time::Duration = std::time::Duration::from_secs(10);
|
||||||
|
|
||||||
/// Where an imported session came from, and how much of it has been shown.
|
/// Where an imported session came from, and how much of it has been shown.
|
||||||
///
|
/// Kept beside the session rather than in its config, because it is a position
|
||||||
/// Kept beside the session rather than in its config, because it is a
|
/// in someone else's file rather than anything the person chose, and it changes
|
||||||
/// position in someone else's file rather than anything the person chose,
|
/// constantly.
|
||||||
/// and it changes constantly.
|
|
||||||
#[derive(Debug, Clone, Serialize, serde::Deserialize)]
|
#[derive(Debug, Clone, Serialize, serde::Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct Cursor {
|
pub struct Cursor {
|
||||||
/// Server-side only, and resolved once at import. Nothing accepts a
|
/// Server-side only, resolved once at import. Nothing accepts a path from
|
||||||
/// path from the phone; this is the path *we* found.
|
/// the phone; this is the path *we* found.
|
||||||
pub path: String,
|
pub path: String,
|
||||||
/// Lines of that file already accounted for -- whether replayed into
|
/// Lines of that file already accounted for -- whether replayed into
|
||||||
/// the transcript or skipped because this session wrote them itself.
|
/// the transcript or skipped because this session wrote them itself.
|
||||||
|
|||||||
+109
-132
@@ -1,29 +1,22 @@
|
|||||||
//! The llama.cpp driver: a `llama-server` process per session, spoken to
|
//! The llama.cpp driver: a `llama-server` process per session, spoken to over
|
||||||
//! over its OpenAI-compatible HTTP API and translated into the common
|
//! its OpenAI-compatible HTTP API and translated into the common event model.
|
||||||
//! event model.
|
|
||||||
//!
|
//!
|
||||||
//! Two things make this shaped differently from the Claude driver, and
|
//! Two things make this shaped differently from the Claude driver.
|
||||||
//! both are worth knowing before changing anything here.
|
|
||||||
//!
|
//!
|
||||||
//! **It is spawned but not spoken to over stdio.** The process is started
|
//! **It is spawned but not spoken to over stdio.** The process is started
|
||||||
//! through the same [`Transport`] as any other, and then reached over
|
//! through the same [`Transport`] as any other and then reached over HTTP on a
|
||||||
//! HTTP on a loopback port. That is the case the transport's doc comment
|
//! loopback port. A remote llama-server would need its port forwarded as well
|
||||||
//! flags: a remote llama-server would need its port forwarded as well as
|
//! as its command wrapped, which is not built, so a session on an ssh host is
|
||||||
//! its command wrapped, which is not built, so a session on an ssh host
|
//! refused rather than silently talking to the wrong machine.
|
||||||
//! is refused rather than silently talking to the wrong machine.
|
|
||||||
//!
|
//!
|
||||||
//! **The server is stateless between requests**, so the whole
|
//! **The server is stateless between requests**, so the whole conversation goes
|
||||||
//! conversation goes with every one. It is rebuilt from the session's
|
//! with every one. It is rebuilt from the session's transcript rather than kept
|
||||||
//! transcript rather than kept in this struct, which is not tidiness: a
|
//! in this struct, which is not tidiness: a copy in driver memory is invisible
|
||||||
//! copy in driver memory is invisible to a second device and gone when
|
//! to a second device and gone when this process restarts.
|
||||||
//! this process restarts, and the app is meant to work across devices.
|
|
||||||
//! The transcript is already the source of truth for everything else, and
|
|
||||||
//! this makes it the source of truth for the prompt too.
|
|
||||||
//!
|
//!
|
||||||
//! That leaves the Claude driver as the odd one out rather than this one:
|
//! That leaves the Claude driver as the odd one out rather than this one -- the
|
||||||
//! the CLI's own memory of a conversation is a cache in front of the same
|
//! CLI's own memory of a conversation is a cache in front of the same
|
||||||
//! transcript, not a second truth. Anyone tempted to "fix" the
|
//! transcript. Resolve any inconsistency in this direction.
|
||||||
//! inconsistency should resolve it in this direction.
|
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -38,10 +31,9 @@ use super::process;
|
|||||||
use super::transport::{Launch, Streams, Transport};
|
use super::transport::{Launch, Streams, Transport};
|
||||||
use crate::config::{ProviderConfig, SessionConfig};
|
use crate::config::{ProviderConfig, SessionConfig};
|
||||||
|
|
||||||
/// How long to wait for a model to load before giving up on it. Loading
|
/// How long to wait for a model to load before giving up. Loading is mostly
|
||||||
/// is mostly disk, and a large quantised model on a cold cache is
|
/// disk, and a large quantised model on a cold cache is genuinely slow, so this
|
||||||
/// genuinely slow, so this is generous -- the failure it exists for is a
|
/// is generous -- the failure it exists for is a server that will never answer.
|
||||||
/// server that will never answer, not one that is taking its time.
|
|
||||||
const READY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300);
|
const READY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300);
|
||||||
|
|
||||||
/// One turn in the conversation this driver keeps on the server's behalf.
|
/// One turn in the conversation this driver keeps on the server's behalf.
|
||||||
@@ -62,20 +54,19 @@ pub struct LlamaDriver {
|
|||||||
/// Set by [`Driver::interrupt`]; the streaming loop checks it between
|
/// Set by [`Driver::interrupt`]; the streaming loop checks it between
|
||||||
/// chunks and stops, leaving what was generated in the transcript.
|
/// chunks and stops, leaving what was generated in the transcript.
|
||||||
cancel: Arc<AtomicBool>,
|
cancel: Arc<AtomicBool>,
|
||||||
/// Where this session's process record lives, so [`Driver::stop`] can
|
/// Where this session's process record lives, so [`Driver::stop`] can find
|
||||||
/// find the server it has to end.
|
/// the server it has to end.
|
||||||
session_dir: PathBuf,
|
session_dir: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LlamaDriver {
|
impl LlamaDriver {
|
||||||
/// Takes charge of this session's `llama-server`: the one already
|
/// Takes charge of this session's `llama-server`: the one already loaded if
|
||||||
/// loaded if there is one, otherwise a new one.
|
/// there is one, otherwise a new one.
|
||||||
///
|
///
|
||||||
/// One entry point, for the reason `ClaudeDriver::launch` gives -- the
|
/// One entry point, for the reason `ClaudeDriver::launch` gives, expensive
|
||||||
/// choice is not the caller's and a second process is the expensive
|
/// in a different currency: two servers holding the same model is twice the
|
||||||
/// mistake. Here it is expensive in a different currency: two servers
|
/// memory, and the second would bind a different port while the phone kept
|
||||||
/// holding the same model is twice the memory, and the second would
|
/// talking to the first.
|
||||||
/// bind a different port while the phone kept talking to the first.
|
|
||||||
pub fn launch(
|
pub fn launch(
|
||||||
meta: &SessionConfig,
|
meta: &SessionConfig,
|
||||||
provider: &ProviderConfig,
|
provider: &ProviderConfig,
|
||||||
@@ -96,10 +87,10 @@ impl LlamaDriver {
|
|||||||
)?;
|
)?;
|
||||||
let path = model_path(models_dir, model)?;
|
let path = model_path(models_dir, model)?;
|
||||||
|
|
||||||
// Already loaded and still running: keep talking to it. The
|
// Already loaded and still running: keep talking to it. The health poll
|
||||||
// health poll below is what confirms it is really answering, so
|
// below confirms it is really answering, so adopting a pid whose server
|
||||||
// adopting a pid whose server has wedged still reports as a
|
// has wedged still reports as a failure rather than as a session that
|
||||||
// failure rather than as a session that silently never replies.
|
// silently never replies.
|
||||||
if let Some(process::Record {
|
if let Some(process::Record {
|
||||||
detail: process::Detail::Http { port },
|
detail: process::Detail::Http { port },
|
||||||
pid,
|
pid,
|
||||||
@@ -129,9 +120,9 @@ impl LlamaDriver {
|
|||||||
"--port".into(),
|
"--port".into(),
|
||||||
port.to_string(),
|
port.to_string(),
|
||||||
];
|
];
|
||||||
// Settings that belong to the server because they decide how the
|
// Settings that belong to the server because they decide how the model
|
||||||
// model is loaded; the sampling ones ride on each request instead,
|
// is loaded; the sampling ones ride on each request instead, so changing
|
||||||
// so changing them later needn't reload anything.
|
// them later needn't reload anything.
|
||||||
for (key, flag) in [
|
for (key, flag) in [
|
||||||
("contextSize", "-c"),
|
("contextSize", "-c"),
|
||||||
("gpuLayers", "-ngl"),
|
("gpuLayers", "-ngl"),
|
||||||
@@ -147,8 +138,8 @@ impl LlamaDriver {
|
|||||||
let launch = Launch::new(program, args, meta.cwd.as_deref());
|
let launch = Launch::new(program, args, meta.cwd.as_deref());
|
||||||
// Its output goes to files, not pipes. Not only so the process can
|
// Its output goes to files, not pipes. Not only so the process can
|
||||||
// outlive this server: nothing ever read those pipes, so a chatty
|
// outlive this server: nothing ever read those pipes, so a chatty
|
||||||
// llama-server filled the 64 KB buffer and blocked mid-load with
|
// llama-server filled the 64 KB buffer and blocked mid-load with no sign
|
||||||
// no sign of why.
|
// of why.
|
||||||
let child = transport.spawn(
|
let child = transport.spawn(
|
||||||
&launch,
|
&launch,
|
||||||
Streams::Detached {
|
Streams::Detached {
|
||||||
@@ -164,10 +155,9 @@ impl LlamaDriver {
|
|||||||
"session {} running {program} for {model} on 127.0.0.1:{port} as pid {pid}",
|
"session {} running {program} for {model} on 127.0.0.1:{port} as pid {pid}",
|
||||||
meta.id
|
meta.id
|
||||||
);
|
);
|
||||||
// Reaped so it does not become a zombie while this server is still
|
// Reaped so it does not become a zombie while this server is still its
|
||||||
// its parent; the health poll and the record are what actually say
|
// parent; the health poll and the record are what say whether the
|
||||||
// whether the session is alive, because after a restart there is no
|
// session is alive, because after a restart there is no `Child` to ask.
|
||||||
// `Child` here to ask.
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let mut child = child;
|
let mut child = child;
|
||||||
let _ = child.wait().await;
|
let _ = child.wait().await;
|
||||||
@@ -189,10 +179,10 @@ impl LlamaDriver {
|
|||||||
|
|
||||||
/// The driver for a `llama-server` at `endpoint`, however it got there.
|
/// The driver for a `llama-server` at `endpoint`, however it got there.
|
||||||
///
|
///
|
||||||
/// Shared by starting one and adopting one, because everything after
|
/// Shared by starting one and adopting one, because everything after "there
|
||||||
/// "there is a server at this address" is identical -- including
|
/// is a server at this address" is identical -- including waiting for it to
|
||||||
/// waiting for it to answer, which an adopted one still owes: a
|
/// answer, which an adopted one still owes: a recorded pid says a process
|
||||||
/// recorded pid says a process exists, not that its model is loaded.
|
/// exists, not that its model is loaded.
|
||||||
fn attached(
|
fn attached(
|
||||||
endpoint: String,
|
endpoint: String,
|
||||||
meta: &SessionConfig,
|
meta: &SessionConfig,
|
||||||
@@ -201,9 +191,9 @@ impl LlamaDriver {
|
|||||||
session_dir: &Path,
|
session_dir: &Path,
|
||||||
sink: EventSink,
|
sink: EventSink,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
// Loading is slow enough to be worth saying so: the session shows
|
// Loading is slow enough to be worth saying so: the session shows as
|
||||||
// as running until the model is in memory, then goes idle, rather
|
// running until the model is in memory, rather than looking ready and
|
||||||
// than looking ready and refusing the first message.
|
// refusing the first message.
|
||||||
let _ = sink.send(Event::Status {
|
let _ = sink.send(Event::Status {
|
||||||
state: SessionStatus::Running,
|
state: SessionStatus::Running,
|
||||||
});
|
});
|
||||||
@@ -262,11 +252,9 @@ impl LlamaDriver {
|
|||||||
/// terminal anyway.
|
/// terminal anyway.
|
||||||
const SERVER_LOG: &str = "llama-server.log";
|
const SERVER_LOG: &str = "llama-server.log";
|
||||||
|
|
||||||
/// How often a loaded server is checked for still being there.
|
/// How often a loaded server is checked for still being there. Slower than the
|
||||||
///
|
/// Claude driver's stdout poll because nothing is waiting on it: this only has
|
||||||
/// Slower than the Claude driver's stdout poll because nothing is waiting
|
/// to notice a server that has gone.
|
||||||
/// on it: this only has to notice a server that has gone, and a few
|
|
||||||
/// seconds late costs nothing.
|
|
||||||
const WATCH_INTERVAL: std::time::Duration = std::time::Duration::from_secs(2);
|
const WATCH_INTERVAL: std::time::Duration = std::time::Duration::from_secs(2);
|
||||||
|
|
||||||
/// An owner-only log opened for appending, so the two streams pointed at
|
/// An owner-only log opened for appending, so the two streams pointed at
|
||||||
@@ -281,22 +269,21 @@ fn log_file(path: &Path) -> Result<std::fs::File> {
|
|||||||
.with_context(|| format!("opening {}", path.display()))
|
.with_context(|| format!("opening {}", path.display()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reports the server going away, for as long as the session is there to
|
/// Reports the server going away, for as long as the session is there to report
|
||||||
/// report it to.
|
/// it to.
|
||||||
///
|
///
|
||||||
/// Polled rather than waited on, for the reason the Claude driver gives:
|
/// Polled rather than waited on, for the reason the Claude driver gives: after a
|
||||||
/// after a restart this server is not the process's parent and has nothing
|
/// restart this server is not the process's parent, so liveness has to be a
|
||||||
/// to wait on, so liveness has to be a question asked of the record -- and
|
/// question asked of the record -- and asking it two different ways is how the
|
||||||
/// asking it two different ways is how the two answers come to disagree.
|
/// two answers come to disagree.
|
||||||
fn watch(session_dir: PathBuf, sink: EventSink) {
|
fn watch(session_dir: PathBuf, sink: EventSink) {
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
loop {
|
loop {
|
||||||
std::thread::sleep(WATCH_INTERVAL);
|
std::thread::sleep(WATCH_INTERVAL);
|
||||||
match process::recorded(&session_dir) {
|
match process::recorded(&session_dir) {
|
||||||
Some((_, process::Liveness::Alive)) => {}
|
Some((_, process::Liveness::Alive)) => {}
|
||||||
// Nothing recorded means the session was stopped or
|
// Nothing recorded means the session was stopped or deleted
|
||||||
// deleted deliberately, and whoever did that has already
|
// deliberately, and whoever did that has already said so.
|
||||||
// said so.
|
|
||||||
None => return,
|
None => return,
|
||||||
Some((_, process::Liveness::Dead)) => {
|
Some((_, process::Liveness::Dead)) => {
|
||||||
let _ = sink.send(Event::Error {
|
let _ = sink.send(Event::Error {
|
||||||
@@ -335,34 +322,33 @@ impl Driver for LlamaDriver {
|
|||||||
let cancel = Arc::clone(&self.cancel);
|
let cancel = Arc::clone(&self.cancel);
|
||||||
cancel.store(false, Ordering::Relaxed);
|
cancel.store(false, Ordering::Relaxed);
|
||||||
|
|
||||||
// Its own thread: the request blocks for as long as the model
|
// Its own thread: the request blocks for as long as the model takes to
|
||||||
// takes to generate, which is the whole point of streaming it.
|
// generate, which is the whole point of streaming it.
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
// Nothing is ever held back here -- there is no queue to wait
|
// Nothing is ever held back here -- there is no queue to wait in --
|
||||||
// in -- so the message is taken the moment it arrives. Said
|
// so the message is taken the moment it arrives. Said anyway,
|
||||||
// anyway, because this is what records it: see `MessageTaken`.
|
// because this is what records it: see `MessageTaken`.
|
||||||
let _ = sink.send(Event::MessageTaken {
|
let _ = sink.send(Event::MessageTaken {
|
||||||
id: None,
|
id: None,
|
||||||
text: text.clone(),
|
text: text.clone(),
|
||||||
// Never any: this driver refuses attachments above, and
|
// Never any: this driver refuses attachments above.
|
||||||
// saying so is what the refusal above is for.
|
|
||||||
attachments: Vec::new(),
|
attachments: Vec::new(),
|
||||||
});
|
});
|
||||||
let _ = sink.send(Event::Status {
|
let _ = sink.send(Event::Status {
|
||||||
state: SessionStatus::Running,
|
state: SessionStatus::Running,
|
||||||
});
|
});
|
||||||
// Everything before this message, plus this message. Read
|
// Everything before this message, plus this message. Read rather
|
||||||
// rather than remembered, and `text` is appended here rather
|
// than remembered, and `text` is appended here rather than waited
|
||||||
// than waited for, because the message's own transcript entry
|
// for, because the message's own transcript entry is still on its
|
||||||
// is still on its way when this runs.
|
// way when this runs.
|
||||||
let mut messages = conversation(&transcript);
|
let mut messages = conversation(&transcript);
|
||||||
messages.push(Message {
|
messages.push(Message {
|
||||||
role: "user".into(),
|
role: "user".into(),
|
||||||
content: text,
|
content: text,
|
||||||
});
|
});
|
||||||
// The reply is not stored: the deltas below are the durable
|
// The reply is not stored: the deltas below are the durable record,
|
||||||
// record, so the next turn reads back exactly what the phone
|
// so the next turn reads back exactly what the phone was shown --
|
||||||
// was shown -- including a partial one that was interrupted.
|
// including a partial one that was interrupted.
|
||||||
if let Err(err) = generate(&endpoint, &messages, &sampling, &cancel, &sink) {
|
if let Err(err) = generate(&endpoint, &messages, &sampling, &cancel, &sink) {
|
||||||
let _ = sink.send(Event::Error {
|
let _ = sink.send(Event::Error {
|
||||||
message: format!("{err:#}"),
|
message: format!("{err:#}"),
|
||||||
@@ -375,17 +361,15 @@ impl Driver for LlamaDriver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn answer_question(&self, _id: &str, _answers: &[String]) {
|
fn answer_question(&self, _id: &str, _answers: &[String]) {
|
||||||
// Nothing here asks questions: this driver has no tools, so no
|
// Nothing here asks questions: this driver has no tools.
|
||||||
// permission prompts and no AskUserQuestion.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn interrupt(&self) {
|
fn interrupt(&self) {
|
||||||
self.cancel.store(true, Ordering::Relaxed);
|
self.cancel.store(true, Ordering::Relaxed);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Nothing to forward: this process has no notion of what the
|
// Nothing to forward: this process has no notion of what the conversation
|
||||||
// conversation is called, and the rename it belongs to has already
|
// is called, and the rename has already happened where the name lives.
|
||||||
// happened where the name lives. See `Driver::set_title`.
|
|
||||||
fn set_title(&self, _title: &str) {}
|
fn set_title(&self, _title: &str) {}
|
||||||
|
|
||||||
fn set_permission_mode(&self, _mode: &str) {
|
fn set_permission_mode(&self, _mode: &str) {
|
||||||
@@ -420,23 +404,21 @@ impl Driver for LlamaDriver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn clear(&self) {
|
fn clear(&self) {
|
||||||
// All of it. `conversation` folds from the last of these, so
|
// All of it. `conversation` folds from the last of these, so recording
|
||||||
// recording the marker *is* the reset -- there is no driver state
|
// the marker *is* the reset -- there is no driver state to keep in step
|
||||||
// to keep in step with it, which is the same property that makes
|
// with it, which is the same property that makes a second device see the
|
||||||
// a second device see the same conversation this one does.
|
// same conversation this one does.
|
||||||
let _ = self.sink.send(Event::Cleared);
|
let _ = self.sink.send(Event::Cleared);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stops generating and leaves the server loaded.
|
/// Stops generating and leaves the server loaded.
|
||||||
///
|
///
|
||||||
/// Worth being deliberate about, because the cost is asymmetric and
|
/// Worth being deliberate about, because the cost points the other way from
|
||||||
/// points the other way from the Claude driver's: a `llama-server`
|
/// the Claude driver's: a `llama-server` holds its whole model in memory, so
|
||||||
/// holds its whole model in memory, so a leaked one is gigabytes
|
/// a leaked one is gigabytes nobody is using. It is left anyway, because the
|
||||||
/// nobody is using. It is left anyway, because the alternative is
|
/// alternative is unloading and reloading that model on every backend
|
||||||
/// unloading and reloading that model on every backend restart --
|
/// restart -- minutes of disk, for a session somebody is in the middle of.
|
||||||
/// minutes of disk, for a session somebody is in the middle of. The
|
/// The record is what keeps it from being *nobody's*.
|
||||||
/// record is what keeps it from being *nobody's*: the next run of this
|
|
||||||
/// server adopts it rather than starting a second one.
|
|
||||||
fn detach(&self) {
|
fn detach(&self) {
|
||||||
self.cancel.store(true, Ordering::Relaxed);
|
self.cancel.store(true, Ordering::Relaxed);
|
||||||
}
|
}
|
||||||
@@ -452,16 +434,15 @@ impl Driver for LlamaDriver {
|
|||||||
|
|
||||||
/// The conversation so far, folded out of the transcript.
|
/// The conversation so far, folded out of the transcript.
|
||||||
///
|
///
|
||||||
/// Consecutive `AssistantText` deltas are one assistant turn, closed by
|
/// Consecutive `AssistantText` deltas are one assistant turn, closed by the next
|
||||||
/// the next user message -- which is also what makes an interrupted reply
|
/// user message -- which is also what makes an interrupted reply come back as
|
||||||
/// come back as the partial text the phone actually saw, rather than
|
/// the partial text the phone actually saw.
|
||||||
/// vanishing or being invented.
|
|
||||||
///
|
///
|
||||||
/// This must stay a pure function of the transcript and must never
|
/// This must stay a pure function of the transcript and must never re-render
|
||||||
/// re-render earlier turns. llama.cpp caches the prompt prefix, so a
|
/// earlier turns. llama.cpp caches the prompt prefix, so a growing conversation
|
||||||
/// growing conversation reprocesses almost nothing -- but only while
|
/// reprocesses almost nothing -- but only while every turn is byte-identical to
|
||||||
/// every turn is byte-identical to last time. Changing how an old turn is
|
/// last time. Changing how an old turn is rendered silently reprocesses the
|
||||||
/// rendered silently reprocesses the whole history on every message.
|
/// whole history on every message.
|
||||||
fn conversation(path: &Path) -> Vec<Message> {
|
fn conversation(path: &Path) -> Vec<Message> {
|
||||||
let Ok(events) = crate::session::transcript::read_after(path, 0) else {
|
let Ok(events) = crate::session::transcript::read_after(path, 0) else {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
@@ -469,8 +450,8 @@ fn conversation(path: &Path) -> Vec<Message> {
|
|||||||
let mut messages: Vec<Message> = Vec::new();
|
let mut messages: Vec<Message> = Vec::new();
|
||||||
let mut pending = String::new();
|
let mut pending = String::new();
|
||||||
// Everything before the last clear is still in the transcript and is
|
// Everything before the last clear is still in the transcript and is
|
||||||
// deliberately not in the conversation. Folding from zero here would
|
// deliberately not in the conversation. Folding from zero would put it back,
|
||||||
// put it back, which is the whole of what clearing had to undo.
|
// which is the whole of what clearing had to undo.
|
||||||
let events = match events.iter().rposition(|e| e.event == Event::Cleared) {
|
let events = match events.iter().rposition(|e| e.event == Event::Cleared) {
|
||||||
Some(at) => &events[at + 1..],
|
Some(at) => &events[at + 1..],
|
||||||
None => &events[..],
|
None => &events[..],
|
||||||
@@ -518,12 +499,10 @@ fn model_path(models_dir: &Path, key: &str) -> Result<PathBuf> {
|
|||||||
Ok(path)
|
Ok(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An unused loopback port, by asking the OS for one and letting it go.
|
/// An unused loopback port, by asking the OS for one and letting it go. Racy in
|
||||||
///
|
/// principle, but nothing on this machine is hunting for ports, and the
|
||||||
/// Racy in principle: something else could take it between here and
|
/// alternative -- parsing the port back out of the server's log -- couples us to
|
||||||
/// llama-server binding. In practice nothing on this machine is hunting
|
/// its output format for no real gain.
|
||||||
/// for ports, and the alternative -- parsing the port back out of the
|
|
||||||
/// server's log -- couples us to its output format for no real gain.
|
|
||||||
fn free_port() -> Result<u16> {
|
fn free_port() -> Result<u16> {
|
||||||
let listener = std::net::TcpListener::bind("127.0.0.1:0")?;
|
let listener = std::net::TcpListener::bind("127.0.0.1:0")?;
|
||||||
Ok(listener.local_addr()?.port())
|
Ok(listener.local_addr()?.port())
|
||||||
@@ -547,8 +526,8 @@ fn wait_until_ready(endpoint: &str) -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// One streamed completion: posts the conversation, emits each delta as it
|
/// One streamed completion: posts the conversation, emits each delta as it
|
||||||
/// arrives. Emits rather than returns: the transcript those events land
|
/// arrives. Emits rather than returns, because the transcript those events land
|
||||||
/// in is what the next turn reads back, so there is nothing to hand up.
|
/// in is what the next turn reads back.
|
||||||
fn generate(
|
fn generate(
|
||||||
endpoint: &str,
|
endpoint: &str,
|
||||||
messages: &[Message],
|
messages: &[Message],
|
||||||
@@ -574,16 +553,15 @@ fn generate(
|
|||||||
let reader = std::io::BufReader::new(response.body_mut().as_reader());
|
let reader = std::io::BufReader::new(response.body_mut().as_reader());
|
||||||
let mut tokens = 0u64;
|
let mut tokens = 0u64;
|
||||||
// The prompt side only, which is what the model is holding -- the same
|
// The prompt side only, which is what the model is holding -- the same
|
||||||
// definition the other dialects report, so one word on the phone means
|
// definition the other dialects report, so one word on the phone means one
|
||||||
// one thing whichever kind of session it is.
|
// thing whichever kind of session it is.
|
||||||
let mut context = None;
|
let mut context = None;
|
||||||
for line in std::io::BufRead::lines(reader) {
|
for line in std::io::BufRead::lines(reader) {
|
||||||
if cancel.load(Ordering::Relaxed) {
|
if cancel.load(Ordering::Relaxed) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
let line = line.context("reading the generation stream")?;
|
let line = line.context("reading the generation stream")?;
|
||||||
// Server-sent events: the payload lines are the ones that matter,
|
// Server-sent events: the payload lines are the ones that matter.
|
||||||
// and blank lines separate events.
|
|
||||||
let Some(payload) = line.strip_prefix("data: ") else {
|
let Some(payload) = line.strip_prefix("data: ") else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
@@ -631,8 +609,8 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::session::transcript::Transcript;
|
use crate::session::transcript::Transcript;
|
||||||
|
|
||||||
/// Writes a transcript the way the pump does, so the fold is tested
|
/// Writes a transcript the way the pump does, so the fold is tested against
|
||||||
/// against the real file format rather than a hand-built vector.
|
/// the real file format rather than a hand-built vector.
|
||||||
fn transcript_with(events: &[Event]) -> (tempfile::TempDir, PathBuf) {
|
fn transcript_with(events: &[Event]) -> (tempfile::TempDir, PathBuf) {
|
||||||
let dir = tempfile::tempdir().expect("tempdir");
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
let path = dir.path().join("transcript.jsonl");
|
let path = dir.path().join("transcript.jsonl");
|
||||||
@@ -685,11 +663,10 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
/// The interrupted case, which decides what a resumed conversation is
|
/// The interrupted case, which decides what a resumed conversation is built
|
||||||
/// built from: whatever the phone was shown. The deltas that arrived
|
/// from: whatever the phone was shown. The deltas that arrived before the
|
||||||
/// before the stop are in the transcript, so they are in the prompt --
|
/// stop are in the transcript, so they are in the prompt -- the model is
|
||||||
/// the model is never told it said something the user did not see, and
|
/// never told it said something the user did not see.
|
||||||
/// never has a turn silently dropped from under it.
|
|
||||||
fn an_interrupted_reply_stays_in_the_conversation() {
|
fn an_interrupted_reply_stays_in_the_conversation() {
|
||||||
let (_dir, path) = transcript_with(&[
|
let (_dir, path) = transcript_with(&[
|
||||||
Event::UserMessage {
|
Event::UserMessage {
|
||||||
@@ -741,9 +718,9 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
/// Clearing decides what the *model* is given, not just what the
|
/// Clearing decides what the *model* is given, not just what the phone
|
||||||
/// phone draws. Everything above the marker stays in the transcript
|
/// draws. Everything above the marker stays in the transcript and none of it
|
||||||
/// -- a person can still scroll back to it -- and none of it is sent.
|
/// is sent.
|
||||||
fn the_conversation_starts_after_the_last_clear() {
|
fn the_conversation_starts_after_the_last_clear() {
|
||||||
let (_dir, path) = transcript_with(&[
|
let (_dir, path) = transcript_with(&[
|
||||||
Event::UserMessage {
|
Event::UserMessage {
|
||||||
|
|||||||
+522
-857
File diff suppressed because it is too large.
Load diff
@@ -1,20 +1,18 @@
|
|||||||
//! What is being done to a machine's Claude Code sessions right now.
|
//! What is being done to a machine's Claude Code sessions right now.
|
||||||
//!
|
//!
|
||||||
//! Importing and deleting used to be whatever the phone was in the middle
|
//! Importing and deleting used to be whatever the phone was in the middle of:
|
||||||
//! of: the request was the work, so leaving the screen cancelled it and
|
//! the request was the work, so leaving the screen cancelled it and coming back
|
||||||
//! coming back showed no sign it had ever started. Sessions half-imported
|
//! showed no sign it had ever started. Sessions half-imported that way are the
|
||||||
//! that way are the expensive kind of missing -- the row is back in the
|
//! expensive kind of missing -- the row is back in the list looking untouched,
|
||||||
//! list looking untouched, and taking it again is the second `--resume` the
|
//! and taking it again is the second `--resume` the import path exists to
|
||||||
//! whole import path exists to prevent.
|
//! prevent.
|
||||||
//!
|
//!
|
||||||
//! So the work runs here, on the server, and this is the record of it. The
|
//! So the work runs here, on the server, and this is the record of it. The
|
||||||
//! phone reads that record two ways, and needs both: every row of `GET
|
//! phone reads that record two ways and needs both: every row of the importable
|
||||||
//! /setups/{id}/importable` carries what is happening to it, which is what
|
//! listing carries what is happening to it, which is what a phone that was
|
||||||
//! a phone that was asleep, out of range, or freshly opened has to go on;
|
//! asleep has to go on; and [`Registry::subscribe`] is the live stream, which is
|
||||||
//! and [`Registry::subscribe`] is the live stream, which is what makes a
|
//! what makes a screen change by itself. A broadcast has no memory, and a
|
||||||
//! screen somebody is looking at change by itself. Neither is sufficient
|
//! listing is only true when it was fetched.
|
||||||
//! alone -- a broadcast has no memory, and a listing is only true when it
|
|
||||||
//! was fetched.
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
@@ -31,8 +29,8 @@ pub enum Operation {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Operation {
|
impl Operation {
|
||||||
/// The word a row shows while this runs. Fixed here rather than in the
|
/// The word a row shows while this runs. Fixed here rather than in the app
|
||||||
/// app so the two ends cannot disagree about what a state is called.
|
/// so the two ends cannot disagree about what a state is called.
|
||||||
pub fn label(self) -> &'static str {
|
pub fn label(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Self::Importing => "importing",
|
Self::Importing => "importing",
|
||||||
@@ -43,11 +41,9 @@ impl Operation {
|
|||||||
|
|
||||||
/// One change to what is in flight, as it goes out on the stream.
|
/// One change to what is in flight, as it goes out on the stream.
|
||||||
///
|
///
|
||||||
/// The three states are every way an operation ends, including the two that
|
/// The three states are every way an operation ends, including the two easy to
|
||||||
/// are easy to leave out: it can still be running, it can have finished,
|
/// leave out: still running, finished, and failed. There is deliberately no
|
||||||
/// and it can have failed. There is deliberately no "unknown" -- this is
|
/// "unknown" -- this is the server's own work, so not knowing would be a bug.
|
||||||
/// the server's own work, so not knowing would be a bug rather than a
|
|
||||||
/// state.
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
#[serde(rename_all = "camelCase", tag = "state")]
|
#[serde(rename_all = "camelCase", tag = "state")]
|
||||||
pub enum Change {
|
pub enum Change {
|
||||||
@@ -68,9 +64,8 @@ pub enum Change {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Change {
|
impl Change {
|
||||||
/// Which machine this is about, so a stream scoped to one can drop the
|
/// Which machine this is about, so a stream scoped to one can drop the rest.
|
||||||
/// rest. Every variant carries it; matching here rather than at the
|
/// Every variant carries it; matching here keeps that fact in one place.
|
||||||
/// filter keeps that fact in one place.
|
|
||||||
pub fn setup(&self) -> &str {
|
pub fn setup(&self) -> &str {
|
||||||
match self {
|
match self {
|
||||||
Self::Started { setup, .. }
|
Self::Started { setup, .. }
|
||||||
@@ -84,11 +79,10 @@ impl Change {
|
|||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct Registry {
|
pub struct Registry {
|
||||||
running: Mutex<HashMap<(String, String), Operation>>,
|
running: Mutex<HashMap<(String, String), Operation>>,
|
||||||
/// Kept after the operation ends, because a phone that was not looking
|
/// Kept after the operation ends, because a phone that was not looking when
|
||||||
/// when it failed has no other way to find out. Replaced when the next
|
/// it failed has no other way to find out. Replaced when the next operation
|
||||||
/// operation on that session starts, and dropped by [`Registry::prune`]
|
/// on that session starts, and dropped by [`Registry::prune`] when the
|
||||||
/// when the session is no longer on the machine -- an error about a
|
/// session is no longer on the machine.
|
||||||
/// transcript that is gone has nothing left to be about.
|
|
||||||
failures: Mutex<HashMap<(String, String), String>>,
|
failures: Mutex<HashMap<(String, String), String>>,
|
||||||
changes: broadcast::Sender<Change>,
|
changes: broadcast::Sender<Change>,
|
||||||
}
|
}
|
||||||
@@ -98,8 +92,8 @@ impl Default for Registry {
|
|||||||
Self {
|
Self {
|
||||||
running: Mutex::new(HashMap::new()),
|
running: Mutex::new(HashMap::new()),
|
||||||
failures: Mutex::new(HashMap::new()),
|
failures: Mutex::new(HashMap::new()),
|
||||||
// Enough that a phone watching one screen cannot lag behind a
|
// Enough that a phone watching one screen cannot lag behind a batch
|
||||||
// batch of any size somebody would start by hand.
|
// of any size somebody would start by hand.
|
||||||
changes: broadcast::channel(256).0,
|
changes: broadcast::channel(256).0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -109,10 +103,9 @@ impl Registry {
|
|||||||
/// Marks an operation as running and announces it.
|
/// Marks an operation as running and announces it.
|
||||||
///
|
///
|
||||||
/// The returned guard is how it stops being marked: settle it with
|
/// The returned guard is how it stops being marked: settle it with
|
||||||
/// [`InFlight::succeeded`] or [`InFlight::failed`], or drop it and it
|
/// [`InFlight::succeeded`] or [`InFlight::failed`], or drop it and it reports
|
||||||
/// reports a failure. Dropping without settling means the task was
|
/// a failure. Dropping without settling means the task was cancelled or
|
||||||
/// cancelled or panicked, and a row stuck on "importing" for ever is a
|
/// panicked, and a row stuck on "importing" for ever is a worse answer.
|
||||||
/// worse answer than one that says it did not finish.
|
|
||||||
pub fn begin(self: &Arc<Self>, setup: &str, session: &str, operation: Operation) -> InFlight {
|
pub fn begin(self: &Arc<Self>, setup: &str, session: &str, operation: Operation) -> InFlight {
|
||||||
let key = (setup.to_string(), session.to_string());
|
let key = (setup.to_string(), session.to_string());
|
||||||
self.running.lock().unwrap().insert(key.clone(), operation);
|
self.running.lock().unwrap().insert(key.clone(), operation);
|
||||||
@@ -141,11 +134,8 @@ impl Registry {
|
|||||||
self.failures.lock().unwrap().get(&key).cloned()
|
self.failures.lock().unwrap().get(&key).cloned()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Forgets failures against sessions the machine no longer has.
|
/// Forgets failures against sessions the machine no longer has. Called from
|
||||||
///
|
/// the listing, which is the only place that knows what is still there.
|
||||||
/// Called from the listing, which is the only place that knows what is
|
|
||||||
/// still there. A deleted session's failure would otherwise outlive
|
|
||||||
/// everything it referred to.
|
|
||||||
pub fn prune(&self, setup: &str, present: &[String]) {
|
pub fn prune(&self, setup: &str, present: &[String]) {
|
||||||
self.failures
|
self.failures
|
||||||
.lock()
|
.lock()
|
||||||
@@ -155,8 +145,8 @@ impl Registry {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every change as it happens. See the module note on why this is not
|
/// Every change as it happens. See the module note on why this is not the
|
||||||
/// the only way the phone finds out.
|
/// only way the phone finds out.
|
||||||
pub fn subscribe(&self) -> broadcast::Receiver<Change> {
|
pub fn subscribe(&self) -> broadcast::Receiver<Change> {
|
||||||
self.changes.subscribe()
|
self.changes.subscribe()
|
||||||
}
|
}
|
||||||
@@ -229,8 +219,8 @@ mod tests {
|
|||||||
assert!(matches!(changes.try_recv(), Ok(Change::Finished { .. })));
|
assert!(matches!(changes.try_recv(), Ok(Change::Finished { .. })));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A failure outlives the operation, because the phone that needs it may
|
/// A failure outlives the operation, because the phone that needs it may not
|
||||||
/// not have been listening when it happened.
|
/// have been listening when it happened.
|
||||||
#[test]
|
#[test]
|
||||||
fn a_failure_is_kept_until_something_replaces_or_prunes_it() {
|
fn a_failure_is_kept_until_something_replaces_or_prunes_it() {
|
||||||
let registry = Arc::new(Registry::default());
|
let registry = Arc::new(Registry::default());
|
||||||
@@ -256,8 +246,8 @@ mod tests {
|
|||||||
assert!(registry.failure("local", "abc").is_none());
|
assert!(registry.failure("local", "abc").is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Trying again clears the last failure, so a row cannot show an error
|
/// Trying again clears the last failure, so a row cannot show an error from
|
||||||
/// from before the attempt somebody is currently watching.
|
/// before the attempt somebody is currently watching.
|
||||||
#[test]
|
#[test]
|
||||||
fn starting_again_clears_the_previous_failure() {
|
fn starting_again_clears_the_previous_failure() {
|
||||||
let registry = Arc::new(Registry::default());
|
let registry = Arc::new(Registry::default());
|
||||||
@@ -270,8 +260,8 @@ mod tests {
|
|||||||
second.succeeded();
|
second.succeeded();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A task that is cancelled or panics must not leave a row saying
|
/// A task that is cancelled or panics must not leave a row saying something
|
||||||
/// something is still happening to it.
|
/// is still happening to it.
|
||||||
#[test]
|
#[test]
|
||||||
fn dropping_an_unsettled_operation_reports_a_failure() {
|
fn dropping_an_unsettled_operation_reports_a_failure() {
|
||||||
let registry = Arc::new(Registry::default());
|
let registry = Arc::new(Registry::default());
|
||||||
|
|||||||
+113
-148
@@ -2,31 +2,26 @@
|
|||||||
//! written down so a *later* run of this server can find the same process
|
//! written down so a *later* run of this server can find the same process
|
||||||
//! rather than start a second one.
|
//! rather than start a second one.
|
||||||
//!
|
//!
|
||||||
//! The server deliberately outlives its own restarts badly and its
|
//! Stopping the backend must not kill a turn that is in flight, so session
|
||||||
//! children well: stopping the backend must not kill a turn that is in
|
//! processes are left running and adopted again on the way back up. That only
|
||||||
//! flight, so session processes are left running and adopted again on the
|
//! works if "is this still mine?" has an answer, which is what this module is.
|
||||||
//! way back up. That only works if "is this still mine?" has an answer,
|
|
||||||
//! which is what this module is.
|
|
||||||
//!
|
//!
|
||||||
//! **A pid is not an identity.** Pids are reused, so adopting one by
|
//! **A pid is not an identity.** Pids are reused, so adopting one by number
|
||||||
//! number alone eventually means treating a stranger's process as a
|
//! alone eventually means treating a stranger's process as a session -- never
|
||||||
//! session -- never resuming the real conversation, and signalling
|
//! resuming the real conversation, and signalling something unrelated when the
|
||||||
//! something unrelated when the session is deleted. The kernel's start
|
//! session is deleted. The kernel's start time for that pid is recorded beside
|
||||||
//! time for that pid is recorded beside it; the pair is unique for as long
|
//! it; the pair is unique for as long as the machine has been up.
|
||||||
//! as the machine has been up, which is longer than any of this lives.
|
|
||||||
//!
|
//!
|
||||||
//! **How to reach it again belongs here too**, in the same record and the
|
//! **How to reach it again belongs here too**, in the same record and the same
|
||||||
//! same write, because it answers the other half of the same question: not
|
//! write, because it answers the other half of the same question. Splitting
|
||||||
//! just "is my process still there" but "where do I pick it up". Splitting
|
//! them would be two files that can disagree about one process. What it takes
|
||||||
//! them would be two files that can disagree about one process. What that
|
//! differs by driver, so it is a typed [`Detail`] rather than a union of every
|
||||||
//! takes differs by driver -- a reading position into a log for one spoken
|
//! driver's fields.
|
||||||
//! to over stdio, a port for one spoken to over HTTP -- so it is a typed
|
|
||||||
//! [`Detail`] rather than a union of every driver's fields.
|
|
||||||
//!
|
//!
|
||||||
//! The record is rewritten in place as reading advances. A crash during
|
//! The record is rewritten in place as reading advances. A crash during that
|
||||||
//! that write leaves a record that does not parse, which is read as "no
|
//! write leaves a record that does not parse, which is read as "no live
|
||||||
//! live process" -- so the failure is the old behaviour (start one with
|
//! process" -- so the failure is the old behaviour rather than a wrong
|
||||||
//! `--resume`) rather than a wrong adoption.
|
//! adoption.
|
||||||
|
|
||||||
use std::os::unix::fs::OpenOptionsExt;
|
use std::os::unix::fs::OpenOptionsExt;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
@@ -40,8 +35,8 @@ const RECORD_FILE: &str = "process.json";
|
|||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct Record {
|
pub struct Record {
|
||||||
pub pid: u32,
|
pub pid: u32,
|
||||||
/// The kernel's start time for `pid`, in clock ticks since boot. See
|
/// The kernel's start time for `pid`, in clock ticks since boot. See the
|
||||||
/// the module comment: this is what makes the pid an identity.
|
/// module comment: this is what makes the pid an identity.
|
||||||
pub started: u64,
|
pub started: u64,
|
||||||
/// What the driver needs in order to pick this process back up.
|
/// What the driver needs in order to pick this process back up.
|
||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
@@ -52,24 +47,21 @@ pub struct Record {
|
|||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||||
pub enum Detail {
|
pub enum Detail {
|
||||||
/// Spoken to over stdio, which outlives the server as files in the
|
/// Spoken to over stdio, which outlives the server as files in the session
|
||||||
/// session directory. `stdout_read` is how many bytes of the stdout
|
/// directory. `stdout_read` is how many bytes of the stdout log have
|
||||||
/// log have already become events: everything before it is in the
|
/// already become events: everything after it is what a reattaching server
|
||||||
/// transcript, everything after it is what a reattaching server owes
|
/// owes the conversation.
|
||||||
/// the conversation.
|
|
||||||
Stdio { stdout_read: u64 },
|
Stdio { stdout_read: u64 },
|
||||||
/// Spoken to over HTTP on a loopback port, which is all it takes to
|
/// Spoken to over HTTP on a loopback port, which is all it takes to find
|
||||||
/// find it again -- there is no stream to be partway through.
|
/// it again -- there is no stream to be partway through.
|
||||||
Http { port: u16 },
|
Http { port: u16 },
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether a recorded process is still there.
|
/// Whether a recorded process is still there.
|
||||||
///
|
///
|
||||||
/// Three answers rather than a boolean, because "I could not find out" is
|
/// Three answers rather than a boolean, because "I could not find out" is a
|
||||||
/// a real one and is not the same as "no". Treating it as "no" is what
|
/// real one and is not the same as "no". Treating it as "no" is what would
|
||||||
/// would start a second process against a conversation that already has
|
/// start a second process against a conversation that already has one.
|
||||||
/// one -- the expensive mistake this whole module exists to prevent -- so
|
|
||||||
/// it has to be sayable.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum Liveness {
|
pub enum Liveness {
|
||||||
Alive,
|
Alive,
|
||||||
@@ -78,9 +70,9 @@ pub enum Liveness {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Record {
|
impl Record {
|
||||||
/// The record for a process this server just started, or `None` when
|
/// The record for a process this server just started, or `None` when the
|
||||||
/// the kernel will not say when it started -- which is the same
|
/// kernel will not say when it started -- which is the same answer as "do
|
||||||
/// answer as "do not adopt this later", and the safe one.
|
/// not adopt this later", and the safe one.
|
||||||
pub fn of(pid: u32, detail: Detail) -> Option<Self> {
|
pub fn of(pid: u32, detail: Detail) -> Option<Self> {
|
||||||
Some(Self {
|
Some(Self {
|
||||||
pid,
|
pid,
|
||||||
@@ -89,12 +81,9 @@ impl Record {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether the process this describes is still the one running under
|
|
||||||
/// that pid.
|
|
||||||
pub fn liveness(&self) -> Liveness {
|
pub fn liveness(&self) -> Liveness {
|
||||||
match stat_of(self.pid) {
|
match stat_of(self.pid) {
|
||||||
// A different start time is a reused pid, which is a different
|
// A different start time is a reused pid, so definitely not ours.
|
||||||
// process and so definitely not ours.
|
|
||||||
Ok(Some(stat)) if stat.started == self.started => {
|
Ok(Some(stat)) if stat.started == self.started => {
|
||||||
if stat.exited {
|
if stat.exited {
|
||||||
Liveness::Dead
|
Liveness::Dead
|
||||||
@@ -112,12 +101,10 @@ fn path(session_dir: &Path) -> PathBuf {
|
|||||||
session_dir.join(RECORD_FILE)
|
session_dir.join(RECORD_FILE)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The recorded process and whether it is still there, or `None` when
|
/// The recorded process and whether it is still there, or `None` when nothing
|
||||||
/// nothing usable is recorded.
|
/// usable is recorded. A record that does not parse reads as no record: the
|
||||||
///
|
/// only way to get one is a crash partway through writing it, and the safe
|
||||||
/// A record that does not parse reads as no record: the only way to get
|
/// reading is that this server has no claim on anything.
|
||||||
/// one is a crash partway through writing it, and the safe reading of that
|
|
||||||
/// is that this server has no claim on anything.
|
|
||||||
pub fn recorded(session_dir: &Path) -> Option<(Record, Liveness)> {
|
pub fn recorded(session_dir: &Path) -> Option<(Record, Liveness)> {
|
||||||
let text = std::fs::read_to_string(path(session_dir)).ok()?;
|
let text = std::fs::read_to_string(path(session_dir)).ok()?;
|
||||||
let record: Record = serde_json::from_str(text.trim_end()).ok()?;
|
let record: Record = serde_json::from_str(text.trim_end()).ok()?;
|
||||||
@@ -125,10 +112,8 @@ pub fn recorded(session_dir: &Path) -> Option<(Record, Liveness)> {
|
|||||||
Some((record, liveness))
|
Some((record, liveness))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The recorded process if it is definitely still running.
|
/// The recorded process if it is definitely still running. One function rather
|
||||||
///
|
/// than a read plus a liveness check at each caller: the caller that forgets
|
||||||
/// One function rather than a read plus a liveness check at each caller:
|
|
||||||
/// every caller wants the same question answered, and the one that forgets
|
|
||||||
/// the second half is the one that starts a duplicate.
|
/// the second half is the one that starts a duplicate.
|
||||||
pub fn live(session_dir: &Path) -> Option<Record> {
|
pub fn live(session_dir: &Path) -> Option<Record> {
|
||||||
match recorded(session_dir) {
|
match recorded(session_dir) {
|
||||||
@@ -137,25 +122,20 @@ pub fn live(session_dir: &Path) -> Option<Record> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Writes `record` where [`live`] will find it, atomically.
|
/// Writes `record` where [`live`] will find it, atomically -- to a neighbouring
|
||||||
|
/// file, renamed over the real name, so a reader sees either the whole old
|
||||||
|
/// record or the whole new one.
|
||||||
///
|
///
|
||||||
/// Written to a neighbouring file and renamed over the real name. The
|
/// Writing in place would not be, and the consequence is severe rather than
|
||||||
/// rename is what makes this safe: a reader sees either the whole old
|
/// untidy. `fs::write` truncates before it fills, so a crash inside that window
|
||||||
/// record or the whole new one, never a partial.
|
/// leaves no readable record -- and a missing record reads as "nothing is
|
||||||
|
/// running", which is the single answer that makes the next launch start a
|
||||||
|
/// *second* process against a conversation that already has one. The window is
|
||||||
|
/// not rare: this runs on every read that makes progress, so many times a
|
||||||
|
/// second while a turn is producing output.
|
||||||
///
|
///
|
||||||
/// Writing in place would not be, and the consequence is severe rather
|
/// Errors are logged rather than returned: this runs on the reading path, and a
|
||||||
/// than untidy. `fs::write` truncates before it fills, so a crash inside
|
/// session that cannot save its position is still worth having.
|
||||||
/// that window leaves no readable record -- and a missing record reads as
|
|
||||||
/// "nothing is running", which is the single answer that makes the next
|
|
||||||
/// launch start a *second* process against a conversation that already has
|
|
||||||
/// one. That is the fault this whole module exists to prevent, and writing
|
|
||||||
/// the record carelessly would reintroduce it at its own save point. The
|
|
||||||
/// window is not rare either: this runs on every read that makes progress,
|
|
||||||
/// so many times a second while a turn is producing output.
|
|
||||||
///
|
|
||||||
/// Errors are logged rather than returned: this runs on the reading path,
|
|
||||||
/// and a session that cannot save its position is still worth having -- it
|
|
||||||
/// just cannot be reattached to, which is what the log says.
|
|
||||||
pub fn write(session_dir: &Path, record: &Record) {
|
pub fn write(session_dir: &Path, record: &Record) {
|
||||||
let path = path(session_dir);
|
let path = path(session_dir);
|
||||||
let text = match serde_json::to_string(record) {
|
let text = match serde_json::to_string(record) {
|
||||||
@@ -165,8 +145,8 @@ pub fn write(session_dir: &Path, record: &Record) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// Beside the real file so the rename stays within one filesystem,
|
// Beside the real file so the rename stays within one filesystem, which is
|
||||||
// which is what makes it atomic.
|
// what makes it atomic.
|
||||||
let temp = path.with_extension("json.new");
|
let temp = path.with_extension("json.new");
|
||||||
let written = std::fs::OpenOptions::new()
|
let written = std::fs::OpenOptions::new()
|
||||||
.create(true)
|
.create(true)
|
||||||
@@ -190,11 +170,8 @@ pub fn write(session_dir: &Path, record: &Record) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How many bytes `path` holds, or 0 if it is not there.
|
/// How many bytes `path` holds, or 0 if it is not there. Exists so a caller
|
||||||
///
|
/// wanting only the length does not have to read the file to find it.
|
||||||
/// Exists so a caller wanting only the length does not have to read the
|
|
||||||
/// file to find it -- [`read_from`] with a large offset answers the
|
|
||||||
/// question, but allocates the whole file on the way.
|
|
||||||
pub fn size_of(path: &Path) -> u64 {
|
pub fn size_of(path: &Path) -> u64 {
|
||||||
std::fs::metadata(path).map(|meta| meta.len()).unwrap_or(0)
|
std::fs::metadata(path).map(|meta| meta.len()).unwrap_or(0)
|
||||||
}
|
}
|
||||||
@@ -211,19 +188,16 @@ pub fn clear(session_dir: &Path) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Grace period between asking a session's process to stop and killing it.
|
/// Grace period between asking a session's process to stop and killing it.
|
||||||
///
|
/// Here rather than beside each caller: two drivers plus the manager had
|
||||||
/// Here rather than beside each caller: it is a property of stopping one of
|
/// written the same five seconds down separately.
|
||||||
/// these, and two drivers plus the manager had written the same five seconds
|
|
||||||
/// down separately, which is three places for it to drift.
|
|
||||||
pub const STOP_GRACE: std::time::Duration = std::time::Duration::from_secs(5);
|
pub const STOP_GRACE: std::time::Duration = std::time::Duration::from_secs(5);
|
||||||
|
|
||||||
/// Asks it to stop, then makes sure. Used where a leaked process must
|
/// Asks it to stop, then makes sure. Used where a leaked process must actually
|
||||||
/// actually end: a deleted session, or one being replaced.
|
/// end: a deleted session, or one being replaced.
|
||||||
///
|
///
|
||||||
/// SIGTERM first because the CLI writes its own session file on the way
|
/// SIGTERM first because the CLI writes its own session file on the way out and
|
||||||
/// out and a SIGKILL would cost whatever it had not flushed; SIGKILL after
|
/// a SIGKILL would cost whatever it had not flushed; SIGKILL after the grace
|
||||||
/// the grace period because a session the phone has deleted must not still
|
/// period because a session the phone has deleted must not still be running.
|
||||||
/// be running when it looks again.
|
|
||||||
pub fn stop(record: &Record, grace: std::time::Duration) {
|
pub fn stop(record: &Record, grace: std::time::Duration) {
|
||||||
if record.liveness() != Liveness::Alive {
|
if record.liveness() != Liveness::Alive {
|
||||||
return;
|
return;
|
||||||
@@ -236,23 +210,20 @@ pub fn stop(record: &Record, grace: std::time::Duration) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Waits for processes already asked to stop, and kills whichever have
|
/// Waits for processes already asked to stop, and kills whichever have not, for
|
||||||
/// not, for a caller that is about to exit.
|
/// a caller that is about to exit.
|
||||||
///
|
///
|
||||||
/// The waiting cannot be [`stop`]'s here, and that is the whole reason
|
/// The waiting cannot be [`stop`]'s here, and that is the whole reason this
|
||||||
/// this exists: the kill it leaves behind is a timer inside the tokio
|
/// exists: the kill it leaves behind is a timer inside the tokio runtime, and a
|
||||||
/// runtime, and a runtime that is shutting down never runs it. That is
|
/// runtime that is shutting down never runs it. That is how the original
|
||||||
/// how the backend's original `shutdown_all` leaked the processes it had
|
/// `shutdown_all` leaked the processes it had just asked to stop -- it reported
|
||||||
/// just asked to stop -- it reported them stopped, too, which is worse
|
/// them stopped, too, which is worse than not asking.
|
||||||
/// than not asking.
|
|
||||||
///
|
///
|
||||||
/// One deadline for all of them rather than one each: they were signalled
|
/// One deadline for all of them rather than one each: they were signalled
|
||||||
/// together, so waiting is bounded by the grace period however many there
|
/// together, so waiting is bounded by the grace period however many there are.
|
||||||
/// are, and a server does not sit for a minute on the way out.
|
|
||||||
pub fn wait_gone(records: &[Record], grace: std::time::Duration) {
|
pub fn wait_gone(records: &[Record], grace: std::time::Duration) {
|
||||||
/// How often to look. Short enough that the ordinary case -- a
|
/// How often to look. Short enough that a process that goes at once costs
|
||||||
/// process that goes at once -- costs nothing noticeable, and long
|
/// nothing noticeable, and long enough not to spin.
|
||||||
/// enough not to spin.
|
|
||||||
const LOOK: std::time::Duration = std::time::Duration::from_millis(20);
|
const LOOK: std::time::Duration = std::time::Duration::from_millis(20);
|
||||||
|
|
||||||
let deadline = std::time::Instant::now() + grace;
|
let deadline = std::time::Instant::now() + grace;
|
||||||
@@ -264,11 +235,10 @@ pub fn wait_gone(records: &[Record], grace: std::time::Duration) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The end of both paths above: a process that was asked to stop and did
|
/// The end of both paths above: a process that was asked to stop and did not is
|
||||||
/// not is killed. Written once because the two callers differ only in how
|
/// killed. Written once because the two callers differ only in how they wait,
|
||||||
/// they wait, and a grace period that means one thing in one of them and
|
/// and a grace period meaning one thing in one and something else in the other
|
||||||
/// something else in the other is exactly the drift `STOP_GRACE` was
|
/// is exactly the drift `STOP_GRACE` was gathered here to prevent.
|
||||||
/// gathered here to prevent.
|
|
||||||
fn kill_if_still_there(record: &Record, grace: std::time::Duration) {
|
fn kill_if_still_there(record: &Record, grace: std::time::Duration) {
|
||||||
if record.liveness() == Liveness::Alive {
|
if record.liveness() == Liveness::Alive {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
@@ -281,61 +251,56 @@ fn kill_if_still_there(record: &Record, grace: std::time::Duration) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn signal(pid: u32, signal: libc::c_int) {
|
fn signal(pid: u32, signal: libc::c_int) {
|
||||||
// SAFETY: `kill` with a positive pid touches only that process, and
|
// SAFETY: `kill` with a positive pid touches only that process, and the pid
|
||||||
// the pid came from a record whose start time was just confirmed to
|
// came from a record whose start time was just confirmed to match -- so it
|
||||||
// match -- so it is still the process this server started, not a
|
// is still the process this server started, not a reused number. A failure
|
||||||
// reused number. A failure (already gone) is nothing to act on.
|
// (already gone) is nothing to act on.
|
||||||
unsafe {
|
unsafe {
|
||||||
libc::kill(pid as libc::pid_t, signal);
|
libc::kill(pid as libc::pid_t, signal);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The kernel's start time for `pid`, in clock ticks since boot.
|
|
||||||
///
|
|
||||||
/// Field 22 of `/proc/<pid>/stat`, counted from the closing parenthesis of
|
|
||||||
/// field 2 rather than from the start of the line: a process's name is
|
|
||||||
/// field 2, it is wrapped in parentheses, and it may itself contain spaces
|
|
||||||
/// and parentheses. Splitting the whole line on whitespace therefore reads
|
|
||||||
/// the wrong field for anything with a space in its name.
|
|
||||||
///
|
|
||||||
/// Three outcomes, and they are not the same: `Ok(None)` is "no such
|
|
||||||
/// process", `Err` is "could not find out". Collapsing the second into the
|
|
||||||
/// first is what would let a machine without a readable `/proc` look like
|
|
||||||
/// a machine with nothing running on it. Linux-specific, like `import`'s
|
|
||||||
/// use of GNU `stat`.
|
|
||||||
/// What `/proc` says about a pid.
|
/// What `/proc` says about a pid.
|
||||||
struct Stat {
|
struct Stat {
|
||||||
/// The kernel's start time in clock ticks since boot -- see
|
/// The kernel's start time in clock ticks since boot -- see
|
||||||
/// [`Record::started`].
|
/// [`Record::started`].
|
||||||
started: u64,
|
started: u64,
|
||||||
/// State `Z`: the process has ended, and the kernel is keeping its
|
/// State `Z`: the process has ended, and the kernel is keeping its entry
|
||||||
/// entry only until somebody collects the exit status.
|
/// only until somebody collects the exit status.
|
||||||
///
|
///
|
||||||
/// Read rather than ignored, because the entry it leaves behind has
|
/// Read rather than ignored, because that entry has the same pid *and* the
|
||||||
/// the same pid *and* the same start time, so a process that has
|
/// same start time, so a finished process goes on answering "still there"
|
||||||
/// plainly finished goes on answering "still there" for as long as
|
/// for as long as nothing reaps it -- which makes `Exited` unsayable: the
|
||||||
/// nothing reaps it. None of this module's callers want that answer: a
|
/// session shows `unknown`, its Start button never appears, and stopping it
|
||||||
/// session whose CLI has exited is over whether or not the status has
|
/// says there is nothing to stop.
|
||||||
/// been collected, and reporting it alive makes `Exited` unsayable --
|
|
||||||
/// the session shows `unknown`, its Start button never appears, and
|
|
||||||
/// stopping it says there is nothing to stop.
|
|
||||||
exited: bool,
|
exited: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The kernel's start time for `pid`, in clock ticks since boot.
|
||||||
|
///
|
||||||
|
/// Field 22 of `/proc/<pid>/stat`, counted from the closing parenthesis of
|
||||||
|
/// field 2 rather than from the start of the line: a process's name is field 2,
|
||||||
|
/// it is wrapped in parentheses, and it may itself contain spaces and
|
||||||
|
/// parentheses. Splitting the whole line on whitespace reads the wrong field
|
||||||
|
/// for anything with a space in its name.
|
||||||
|
///
|
||||||
|
/// Three outcomes, and they are not the same: `Ok(None)` is "no such process",
|
||||||
|
/// `Err` is "could not find out". Collapsing the second into the first is what
|
||||||
|
/// would let a machine without a readable `/proc` look like a machine with
|
||||||
|
/// nothing running on it. Linux-specific, like `import`'s use of GNU `stat`.
|
||||||
fn stat_of(pid: u32) -> std::io::Result<Option<Stat>> {
|
fn stat_of(pid: u32) -> std::io::Result<Option<Stat>> {
|
||||||
let stat = match std::fs::read_to_string(format!("/proc/{pid}/stat")) {
|
let stat = match std::fs::read_to_string(format!("/proc/{pid}/stat")) {
|
||||||
Ok(stat) => stat,
|
Ok(stat) => stat,
|
||||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||||
Err(err) => return Err(err),
|
Err(err) => return Err(err),
|
||||||
};
|
};
|
||||||
// A `/proc` entry that exists but does not have the shape this reads
|
// A `/proc` entry that exists but does not have the shape this reads is not
|
||||||
// is not a process that has gone away; it is a reading this code
|
// a process that has gone away; it is a reading this code cannot make.
|
||||||
// cannot make, which is the other thing entirely.
|
|
||||||
let unreadable =
|
let unreadable =
|
||||||
|| std::io::Error::new(std::io::ErrorKind::InvalidData, "unreadable /proc stat");
|
|| std::io::Error::new(std::io::ErrorKind::InvalidData, "unreadable /proc stat");
|
||||||
let after_name = stat.rsplit_once(')').ok_or_else(unreadable)?.1;
|
let after_name = stat.rsplit_once(')').ok_or_else(unreadable)?.1;
|
||||||
// Field 3 is the first after the name, so the state is the first here
|
// Field 3 is the first after the name, so the state is the first here and
|
||||||
// and field 22 is the 20th.
|
// field 22 is the 20th.
|
||||||
let mut fields = after_name.split_whitespace();
|
let mut fields = after_name.split_whitespace();
|
||||||
let exited = fields.next().ok_or_else(unreadable)? == "Z";
|
let exited = fields.next().ok_or_else(unreadable)? == "Z";
|
||||||
let started = fields
|
let started = fields
|
||||||
@@ -346,9 +311,9 @@ fn stat_of(pid: u32) -> std::io::Result<Option<Stat>> {
|
|||||||
Ok(Some(Stat { started, exited }))
|
Ok(Some(Stat { started, exited }))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reads `path` from `from`, returning what is there and where reading
|
/// Reads `path` from `from`, returning what is there and where reading reached.
|
||||||
/// reached. A file that has been truncated or replaced under us reads from
|
/// A file truncated or replaced under us reads from the start, since the offset
|
||||||
/// the start, since the offset no longer means anything in it.
|
/// no longer means anything in it.
|
||||||
pub fn read_from(path: &Path, from: u64) -> Result<(Vec<u8>, u64)> {
|
pub fn read_from(path: &Path, from: u64) -> Result<(Vec<u8>, u64)> {
|
||||||
use std::io::{Read, Seek, SeekFrom};
|
use std::io::{Read, Seek, SeekFrom};
|
||||||
let mut file = match std::fs::File::open(path) {
|
let mut file = match std::fs::File::open(path) {
|
||||||
@@ -380,8 +345,8 @@ mod tests {
|
|||||||
.expect("this process has a start time");
|
.expect("this process has a start time");
|
||||||
assert_eq!(mine.liveness(), Liveness::Alive);
|
assert_eq!(mine.liveness(), Liveness::Alive);
|
||||||
|
|
||||||
// The same pid with a different start time is a different process
|
// The same pid with a different start time is a different process --
|
||||||
// -- which is the whole reason the start time is recorded.
|
// which is the whole reason the start time is recorded.
|
||||||
let recycled = Record {
|
let recycled = Record {
|
||||||
started: mine.started + 1,
|
started: mine.started + 1,
|
||||||
..mine.clone()
|
..mine.clone()
|
||||||
@@ -416,8 +381,8 @@ mod tests {
|
|||||||
let dir = tempfile::tempdir().expect("tempdir");
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
let mut record =
|
let mut record =
|
||||||
Record::of(std::process::id(), Detail::Stdio { stdout_read: 0 }).expect("start time");
|
Record::of(std::process::id(), Detail::Stdio { stdout_read: 0 }).expect("start time");
|
||||||
// Rewritten the way the reader rewrites it: constantly, as the
|
// Rewritten the way the reader rewrites it: constantly, as the position
|
||||||
// position advances. Each one must land whole.
|
// advances. Each one must land whole.
|
||||||
for read in [1u64, 4096, 2, 999_999] {
|
for read in [1u64, 4096, 2, 999_999] {
|
||||||
record.detail = Detail::Stdio { stdout_read: read };
|
record.detail = Detail::Stdio { stdout_read: read };
|
||||||
write(dir.path(), &record);
|
write(dir.path(), &record);
|
||||||
@@ -427,8 +392,8 @@ mod tests {
|
|||||||
"after offset {read}"
|
"after offset {read}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// The rename is what makes it atomic; a leftover neighbour would
|
// The rename is what makes it atomic; a leftover neighbour would mean it
|
||||||
// mean it had not happened.
|
// had not happened.
|
||||||
let stray: Vec<_> = std::fs::read_dir(dir.path())
|
let stray: Vec<_> = std::fs::read_dir(dir.path())
|
||||||
.expect("read dir")
|
.expect("read dir")
|
||||||
.filter_map(Result::ok)
|
.filter_map(Result::ok)
|
||||||
@@ -468,8 +433,8 @@ mod tests {
|
|||||||
assert_eq!(bytes, b"world");
|
assert_eq!(bytes, b"world");
|
||||||
assert_eq!(read, 11);
|
assert_eq!(read, 11);
|
||||||
|
|
||||||
// An offset past the end means the file was replaced, so the
|
// An offset past the end means the file was replaced, so the offset
|
||||||
// offset describes a file that no longer exists.
|
// describes a file that no longer exists.
|
||||||
std::fs::write(&path, b"new").expect("truncate");
|
std::fs::write(&path, b"new").expect("truncate");
|
||||||
let (bytes, read) = read_from(&path, 11).expect("read");
|
let (bytes, read) = read_from(&path, 11).expect("read");
|
||||||
assert_eq!(bytes, b"new");
|
assert_eq!(bytes, b"new");
|
||||||
|
|||||||
@@ -41,9 +41,8 @@ impl Transcript {
|
|||||||
/// the last line if one exists.
|
/// the last line if one exists.
|
||||||
pub fn open(path: &Path) -> Result<Self> {
|
pub fn open(path: &Path) -> Result<Self> {
|
||||||
// One pass for all three answers. They are wanted at the same moment
|
// 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
|
// by the same caller, and reading the file again for each doubled the
|
||||||
// the cost of starting every session -- which is paid per session,
|
// cost of starting every session.
|
||||||
// at the point a restart is trying to be quick.
|
|
||||||
let existing = read_after(path, 0)?;
|
let existing = read_after(path, 0)?;
|
||||||
let last_seq = existing.last().map(|entry| entry.seq).unwrap_or(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 {
|
let last_status = existing.iter().rev().find_map(|entry| match entry.event {
|
||||||
@@ -63,9 +62,9 @@ impl Transcript {
|
|||||||
next_seq: last_seq + 1,
|
next_seq: last_seq + 1,
|
||||||
last_status,
|
last_status,
|
||||||
last_activity: existing.last().map(|entry| entry.ts),
|
last_activity: existing.last().map(|entry| entry.ts),
|
||||||
// Folded rather than read off the newest usage entry: a clear
|
// Folded rather than read off the newest usage entry: a clear or
|
||||||
// or a compaction after it is what the answer is, and those
|
// a compaction after it is what the answer is, and those events
|
||||||
// events carry no usage of their own.
|
// carry no usage of their own.
|
||||||
context_tokens: existing
|
context_tokens: existing
|
||||||
.iter()
|
.iter()
|
||||||
.fold(None, |current, entry| context_after(current, &entry.event)),
|
.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.
|
/// 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
|
/// Read from the file rather than assumed, because a server that has just
|
||||||
/// just restarted has been told nothing yet and this is the only thing
|
/// restarted has been told nothing. Assuming idle claimed a session was
|
||||||
/// it knows. Assuming idle claimed a session was waiting for you when
|
/// waiting for you when it had exited hours earlier.
|
||||||
/// it had exited hours earlier, and would now also claim it of one
|
|
||||||
/// whose process is still mid-turn.
|
|
||||||
///
|
///
|
||||||
/// `None` for a transcript that never carried a status, which is a new
|
/// `None` for a transcript that never carried a status.
|
||||||
/// session and genuinely has no prior state.
|
|
||||||
pub fn last_status(&self) -> Option<SessionStatus> {
|
pub fn last_status(&self) -> Option<SessionStatus> {
|
||||||
self.last_status
|
self.last_status
|
||||||
}
|
}
|
||||||
|
|
||||||
/// When this session last did anything, as of opening.
|
/// When this session last did anything, as of opening.
|
||||||
///
|
///
|
||||||
/// Read from the file for the same reason [`Transcript::last_status`]
|
/// Read from the file for the reason [`Transcript::last_status`] is, and it
|
||||||
/// is, and it is the same mistake in the other direction: a restarting
|
/// is the same mistake in the other direction: taking the clock instead
|
||||||
/// server has been told nothing, and taking the clock instead said every
|
/// said every session it relaunched had been active this second. On the
|
||||||
/// session it relaunched had been active this second. On the phone that
|
/// phone that is every row reading "just now" and the list -- sorted by
|
||||||
/// is every row reading "just now" and the list -- which is sorted by
|
/// this -- in an order that means nothing.
|
||||||
/// this -- coming back in an order that means nothing, with the
|
|
||||||
/// conversation somebody was in the middle of buried among sessions
|
|
||||||
/// untouched for days.
|
|
||||||
///
|
///
|
||||||
/// `None` for a transcript with no lines in it, which is a session that
|
/// `None` for a transcript with no lines, which is a session that genuinely
|
||||||
/// genuinely has not done anything yet. Its caller answers that with
|
/// has not done anything. Its caller answers with when the session was
|
||||||
/// when the session was created -- not with the clock, which would say
|
/// created, not with the clock.
|
||||||
/// a session nobody has ever sent anything to was active a moment ago,
|
|
||||||
/// every time this server started.
|
|
||||||
pub fn last_activity(&self) -> Option<f64> {
|
pub fn last_activity(&self) -> Option<f64> {
|
||||||
self.last_activity
|
self.last_activity
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How much context the session was holding, as of opening.
|
/// How much context the session was holding, as of opening.
|
||||||
///
|
///
|
||||||
/// `None` for a transcript nothing has been measured in -- a new
|
/// `None` for a transcript nothing has been measured in. That is not zero:
|
||||||
/// session, one whose dialect never reported usage, or one whose last
|
/// a server that has just restarted has been told nothing, and answering
|
||||||
/// word on the subject was a clear. That is not zero, and it is why
|
/// zero would draw an empty context for a conversation that may be nearly
|
||||||
/// this is an option: a server that has just restarted has been told
|
/// full.
|
||||||
/// nothing, and answering zero would draw an empty context for a
|
|
||||||
/// conversation that may be nearly full.
|
|
||||||
pub fn context_tokens(&self) -> Option<u64> {
|
pub fn context_tokens(&self) -> Option<u64> {
|
||||||
self.context_tokens
|
self.context_tokens
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Appends `event`, assigning it the next sequence number. Flushed per
|
/// Appends `event`, assigning it the next sequence number. Flushed per
|
||||||
/// event: each line is tiny, and the transcript is the source of truth
|
/// event: each line is tiny, and the transcript is the source of truth a
|
||||||
/// a crash must not lose the tail of.
|
/// crash must not lose the tail of.
|
||||||
pub fn append(&mut self, event: Event, ts: f64) -> Result<SeqEvent> {
|
pub fn append(&mut self, event: Event, ts: f64) -> Result<SeqEvent> {
|
||||||
let entry = SeqEvent {
|
let entry = SeqEvent {
|
||||||
seq: self.next_seq,
|
seq: self.next_seq,
|
||||||
@@ -139,23 +128,19 @@ impl Transcript {
|
|||||||
|
|
||||||
/// A window of the transcript ending just before `before`, newest-biased.
|
/// 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 screen opens on the end of a conversation, and the end is all it can
|
||||||
/// the end is all it can show at once. Replaying the whole file to get
|
/// show at once. Replaying the whole file to get there costs one network frame
|
||||||
/// there costs one network frame per event -- on an 863-event import that
|
/// per event -- on an 863-event import that was several seconds of messages
|
||||||
/// was several seconds of messages arriving oldest-first, which reads as
|
/// arriving oldest-first, which reads as the app loading top-down.
|
||||||
/// the app loading top-down because that is exactly what it was doing.
|
|
||||||
///
|
///
|
||||||
/// `before` pages backwards for history somebody actually scrolls to. Only
|
/// `before` pages backwards for history somebody actually scrolls to. Only the
|
||||||
/// the window is parsed; see [`Indexed`] for why that is the whole cost of
|
/// window is parsed; see [`Indexed`] for why that is the whole cost.
|
||||||
/// this call.
|
|
||||||
///
|
///
|
||||||
/// `after` is a floor: nothing at or below it is returned, and the page
|
/// `after` is a floor: nothing at or below it is returned, and the page stops
|
||||||
/// stops there rather than at `limit`. A phone holding a cached run of the
|
/// there rather than at `limit`. A phone holding a cached run passes the end of
|
||||||
/// transcript passes the end of what it already has, so the page it gets
|
/// what it already has, so the page is exactly the gap and never overlaps its
|
||||||
/// back is exactly the gap and never overlaps its copy -- an overlap it
|
/// copy -- an overlap it cannot store, since a coalesced event cannot be cut at
|
||||||
/// cannot store, since a coalesced event cannot be cut at a seq inside its
|
/// a seq inside its own delta run.
|
||||||
/// 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>,
|
||||||
@@ -176,11 +161,11 @@ pub fn read_window(
|
|||||||
};
|
};
|
||||||
// A floor above the window is an empty page, not a walk backwards past it.
|
// A floor above the window is an empty page, not a walk backwards past it.
|
||||||
let start = start.min(end);
|
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
|
||||||
// streaming there would fold to one event whose seq is its first delta, and the phone resumes
|
// window: a message still streaming there would fold to one event whose seq
|
||||||
// its live stream from the newest seq it applied -- so the deltas the coalesced event hid
|
// is its first delta, and the phone resumes its live stream from the newest
|
||||||
// would replay and double. Only settled history (`before` set) is safe, and it is the only
|
// seq it applied -- so the deltas the coalesced event hid would replay and
|
||||||
// place the phone asks for it. See `parse_coalesced`.
|
// double. Only settled history (`before` set) is safe.
|
||||||
if coalesce && before.is_some() {
|
if coalesce && before.is_some() {
|
||||||
indexed.parse_coalesced(start, end, limit)
|
indexed.parse_coalesced(start, end, limit)
|
||||||
} else {
|
} else {
|
||||||
@@ -191,41 +176,34 @@ pub fn read_window(
|
|||||||
/// How far behind a reconnecting subscriber can be and still be handed the
|
/// How far behind a reconnecting subscriber can be and still be handed the
|
||||||
/// backlog one event at a time.
|
/// backlog one event at a time.
|
||||||
///
|
///
|
||||||
/// Past this it is served better by rebuilding its view from the newest
|
/// Past this it is served better by rebuilding from the newest window. The
|
||||||
/// window than by receiving everything it missed. The events are the same
|
/// events are the same either way; what differs is that one arrives as a single
|
||||||
/// either way; what differs is that one arrives as a single window and the
|
/// window and the other as thousands of frames a screen renders one by one. Set
|
||||||
/// other as thousands of frames a screen renders one by one. Set well
|
/// well above a screenful so an ordinary blip still streams continuously.
|
||||||
/// 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.
|
|
||||||
pub const CATCH_UP_LIMIT: usize = 200;
|
pub const CATCH_UP_LIMIT: usize = 200;
|
||||||
|
|
||||||
/// What a subscriber asking for "everything after my cursor" gets back.
|
/// What a subscriber asking for "everything after my cursor" gets back.
|
||||||
///
|
///
|
||||||
/// Two answers rather than one list, because they mean different things to
|
/// Two answers rather than one list, because they mean different things to the
|
||||||
/// the screen holding the cursor: one continues what it already has, the
|
/// screen holding the cursor: one continues what it has, the other replaces it.
|
||||||
/// other replaces it. Collapsing them into a list would leave the client
|
/// Collapsing them would leave the client splicing a window onto rows it has no
|
||||||
/// splicing a window onto rows it has no way to know are no longer
|
/// way to know are no longer adjacent -- a seam that looks like ordinary output.
|
||||||
/// adjacent to it -- a seam that looks exactly like ordinary output.
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub enum CatchUp {
|
pub enum CatchUp {
|
||||||
/// The events after the cursor, continuing what the subscriber holds.
|
/// The events after the cursor, continuing what the subscriber holds.
|
||||||
Continue(Vec<SeqEvent>),
|
Continue(Vec<SeqEvent>),
|
||||||
/// The subscriber was further behind than [`CATCH_UP_LIMIT`]: the
|
/// The subscriber was further behind than [`CATCH_UP_LIMIT`]: the newest
|
||||||
/// newest window, replacing whatever it holds. Earlier history is
|
/// window, replacing whatever it holds. Earlier history is still there to be
|
||||||
/// still there to be paged backwards through, exactly as it is when a
|
/// paged backwards through.
|
||||||
/// session is first opened.
|
|
||||||
Restart(Vec<SeqEvent>),
|
Restart(Vec<SeqEvent>),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Everything after `after`, or the newest `limit` when that is more than
|
/// Everything after `after`, or the newest `limit` when that is more.
|
||||||
/// `limit` events.
|
|
||||||
///
|
///
|
||||||
/// The window is chosen before anything is parsed, which matters most in
|
/// The window is chosen before anything is parsed, which matters most in the
|
||||||
/// the case that looks least interesting: a subscriber with no cursor at
|
/// case that looks least interesting: a subscriber with no cursor asks for the
|
||||||
/// all asks for the whole conversation and is going to be handed the last
|
/// whole conversation and is handed the last [`CATCH_UP_LIMIT`] events of it,
|
||||||
/// [`CATCH_UP_LIMIT`] events of it. Parsing the discarded prefix first is
|
/// so parsing the discarded prefix is the whole file's work for a screenful.
|
||||||
/// the whole file's worth of work to produce a screenful.
|
|
||||||
pub fn catch_up(path: &Path, after: u64, limit: usize) -> Result<CatchUp> {
|
pub fn catch_up(path: &Path, after: u64, limit: usize) -> Result<CatchUp> {
|
||||||
let Some(indexed) = Indexed::read(path)? else {
|
let Some(indexed) = Indexed::read(path)? else {
|
||||||
return Ok(CatchUp::Continue(Vec::new()));
|
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())
|
indexed.parse(start..indexed.lines.len())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The transcript's lines located but not read, so that a reader can find
|
/// The transcript's lines located but not read, so a reader can find the range
|
||||||
/// the range it wants and parse only that.
|
/// it wants and parse only that.
|
||||||
///
|
///
|
||||||
/// Both readers above want a *range* of the file -- everything after a
|
/// Both readers above want a *range* of the file, and both used to reach it by
|
||||||
/// cursor, or the window before one -- and both used to reach it by parsing
|
/// parsing every line and discarding the ones outside it -- the cost that grows
|
||||||
/// every line and discarding the ones outside it. That is the cost that
|
/// with the conversation rather than with the answer. Measured on a 21 MB,
|
||||||
/// grows with the conversation rather than with the answer: measured on a
|
/// 24,000-event transcript, one page took **500 ms of server time to return
|
||||||
/// 21 MB, 24,000-event transcript, one page took **500 ms of server time to
|
/// 600 KB**, and the same 500 ms whichever page was asked for. A phone paging
|
||||||
/// return 600 KB**, and it took the same 500 ms whichever page was asked
|
/// back pays it per page, and every stream reconnect pays it again to discover
|
||||||
/// for, since the work was the file rather than the window. A phone paging
|
/// there is nothing new.
|
||||||
/// back through history 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
|
/// Sequence numbers only ever increase, so the boundary of a range is a
|
||||||
/// appended line, continuing from the last on reopen -- so the boundary of
|
/// bisection: this parses one line per halving, and the caller parses only what
|
||||||
/// a range is a bisection. This parses one line per halving, and the caller
|
/// it returns. The file is still read whole, which is a deliberate stop --
|
||||||
/// parses only what it is going to return. The file is still read whole,
|
/// going further means a chunked backwards reader, and locating a line is not
|
||||||
/// which is a deliberate stop: finding the tail without reading forwards
|
/// what the half-second was going to.
|
||||||
/// means a chunked backwards reader, and locating a line is not what the
|
|
||||||
/// half-second was going to.
|
|
||||||
struct Indexed<'a> {
|
struct Indexed<'a> {
|
||||||
path: &'a Path,
|
path: &'a Path,
|
||||||
text: String,
|
text: String,
|
||||||
@@ -302,14 +276,13 @@ impl<'a> Indexed<'a> {
|
|||||||
Ok(Some(Self { path, text, lines }))
|
Ok(Some(Self { path, text, lines }))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The index of the first line numbered `seq` or higher, or the end
|
/// The index of the first line numbered `seq` or higher, or the end when
|
||||||
/// when every line is older than that.
|
/// every line is older than that.
|
||||||
///
|
///
|
||||||
/// A bisection, which is only correct because the file is in sequence
|
/// A bisection, which is only correct because the file is in sequence order.
|
||||||
/// order; it is append-only and nothing else writes it. A line that
|
/// A line that cannot be read is reported here rather than silently treated
|
||||||
/// cannot be read is reported here rather than silently treated as
|
/// as out of range, because the answer would be a window off by however much
|
||||||
/// out of range, because the answer would be a window off by however
|
/// of the file the bad line hid.
|
||||||
/// much of the file the bad line hid.
|
|
||||||
fn first_at_or_after(&self, seq: u64) -> Result<usize> {
|
fn first_at_or_after(&self, seq: u64) -> Result<usize> {
|
||||||
let (mut low, mut high) = (0, self.lines.len());
|
let (mut low, mut high) = (0, self.lines.len());
|
||||||
while low < high {
|
while low < high {
|
||||||
@@ -350,23 +323,20 @@ impl<'a> Indexed<'a> {
|
|||||||
.with_context(|| format!("bad transcript line in {}", self.path.display()))
|
.with_context(|| format!("bad transcript line in {}", self.path.display()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The newest `limit` *rows* ending at line `end`, with each run of consecutive streamed
|
/// The newest `limit` *rows* ending at line `end`, with each run of
|
||||||
/// [`Event::AssistantText`] deltas concatenated into one.
|
/// consecutive [`Event::AssistantText`] deltas concatenated into one.
|
||||||
///
|
///
|
||||||
/// A reply is stored a token at a time -- hundreds of `AssistantText` events for one message --
|
/// A reply is stored a token at a time, so a window counted in events is a
|
||||||
/// so a window counted in events is a fraction of a row for a reply and a whole row for a tool
|
/// fraction of a row for a reply and a whole row for a tool call, and the
|
||||||
/// call, and the phone can neither predict how much a page will show nor fill a screen without
|
/// phone can neither predict how much a page will show nor fill a screen
|
||||||
/// folding a page's worth of near-duplicate events. Counted in rows, a page is a page: this
|
/// without folding a page of near-duplicate events. Counted in rows, a page
|
||||||
/// walks back from `end`, joining each delta run into the single event the phone would fold it
|
/// is a page.
|
||||||
/// into anyway, and stops once `limit` of them are gathered.
|
|
||||||
///
|
///
|
||||||
/// A run takes the seq and time of its *oldest* delta, matching the phone's own rule that a
|
/// A run takes the seq and time of its *oldest* delta, matching the phone's
|
||||||
/// streamed message keeps the seq of its first delta -- so anchors, and the `before` cursor the
|
/// own rule -- so anchors and the `before` cursor land where they always
|
||||||
/// next page pages from, land where they always did. A run cut by the `limit` (its older
|
/// did. A run cut by the `limit` is emitted as the partial it is, and the
|
||||||
/// deltas beyond this page) is emitted as the partial it is; the next page carries the rest and
|
/// phone's `healSplitMessage` welds it to the next page. `start` is the same
|
||||||
/// the phone's `healSplitMessage` welds the two, exactly as it does for a run cut by any page
|
/// kind of cut from the other end.
|
||||||
/// 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.
|
|
||||||
fn parse_coalesced(&self, start: usize, end: usize, limit: usize) -> Result<Vec<SeqEvent>> {
|
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();
|
||||||
@@ -386,9 +356,9 @@ impl<'a> Indexed<'a> {
|
|||||||
};
|
};
|
||||||
let mut index = end;
|
let mut index = end;
|
||||||
while index > start {
|
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
|
||||||
// stopping while one is open would drop the deltas already read. Break only between
|
// being gathered, so stopping while one is open would drop the
|
||||||
// rows, and flush the last run after the loop.
|
// deltas already read. Break only between rows.
|
||||||
if out.len() >= limit && run.is_none() {
|
if out.len() >= limit && run.is_none() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -487,9 +457,9 @@ mod tests {
|
|||||||
assert_eq!(events[0].seq, 6);
|
assert_eq!(events[0].seq, 6);
|
||||||
assert_eq!(events[4].seq, 10);
|
assert_eq!(events[4].seq, 10);
|
||||||
|
|
||||||
// Exactly at the limit is still a continuation: the boundary
|
// Exactly at the limit is still a continuation: the boundary belongs to
|
||||||
// belongs to the cheaper answer, so a client is not reset for
|
// the cheaper answer, so a client is not reset for being one event
|
||||||
// being one event behind the threshold.
|
// behind the threshold.
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
catch_up(&path, 5, 5).expect("catch up"),
|
catch_up(&path, 5, 5).expect("catch up"),
|
||||||
CatchUp::Continue(_)
|
CatchUp::Continue(_)
|
||||||
@@ -501,8 +471,8 @@ mod tests {
|
|||||||
let dir = tempfile::tempdir().expect("tempdir");
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
let path = dir.path().join("transcript.jsonl");
|
let path = dir.path().join("transcript.jsonl");
|
||||||
|
|
||||||
// Nothing recorded yet: no prior state to report, which is not the
|
// Nothing recorded yet: no prior state to report, which is not the same
|
||||||
// same as reporting idle.
|
// as reporting idle.
|
||||||
assert_eq!(Transcript::open(&path).expect("open").last_status(), None);
|
assert_eq!(Transcript::open(&path).expect("open").last_status(), None);
|
||||||
|
|
||||||
let mut transcript = Transcript::open(&path).expect("open");
|
let mut transcript = Transcript::open(&path).expect("open");
|
||||||
|
|||||||
@@ -1,23 +1,19 @@
|
|||||||
//! Where a session's process runs, and the only place that knows how.
|
//! Where a session's process runs, and the only place that knows how.
|
||||||
//!
|
//!
|
||||||
//! A driver says *what* to run -- a [`Launch`] -- and hands it here.
|
//! A driver says *what* to run -- a [`Launch`] -- and hands it here. Whether
|
||||||
//! Whether that becomes a child of this process or an `ssh host …`
|
//! that becomes a child of this process or an `ssh host …` invocation is
|
||||||
//! invocation is settled in this module, so a driver carries no transport
|
//! settled in this module, so a driver carries no transport knowledge and a
|
||||||
//! knowledge and a second one cannot forget to handle the remote case. It
|
//! second one cannot forget to handle the remote case. It also means the
|
||||||
//! also means the wrapping is honest about drivers that run nothing at
|
//! wrapping is honest about drivers that run nothing at all: `EchoDriver`
|
||||||
//! all: `EchoDriver` builds no [`Launch`], so there is nothing to wrap and
|
//! builds no [`Launch`], so there is no host for it to appear to honour.
|
||||||
//! no host for it to appear to honour.
|
|
||||||
//!
|
//!
|
||||||
//! The quoting, the forced ssh options and the remote script are
|
//! The quoting, the forced ssh options and the remote script are
|
||||||
//! `crate::ssh`'s, which this dispatches to. That split is deliberate:
|
//! `crate::ssh`'s: this module decides *which* transport, that one knows what a
|
||||||
//! this module decides *which* transport, that one knows what a correct
|
//! correct ssh invocation is.
|
||||||
//! ssh invocation is.
|
|
||||||
//!
|
//!
|
||||||
//! Known second operation, not built because nothing needs it yet: a
|
//! Known second operation, not built because nothing needs it yet: a managed
|
||||||
//! managed `llama-server` is spawned as a process but then spoken to over
|
//! `llama-server` is spawned as a process but then spoken to over HTTP, so a
|
||||||
//! HTTP, so a remote one needs a forwarded port (`ssh -L`) as well. A
|
//! remote one needs a forwarded port (`ssh -L`) as well.
|
||||||
//! transport is eventually "run this" plus "reach this port", where the
|
|
||||||
//! second is a no-op locally. See PLAN.md's SSH section.
|
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Stdio;
|
use std::process::Stdio;
|
||||||
@@ -27,11 +23,9 @@ use tokio::process::Child;
|
|||||||
|
|
||||||
use crate::config::SshConfig;
|
use crate::config::SshConfig;
|
||||||
|
|
||||||
/// What a driver needs run in order to exist as a process.
|
/// What a driver needs run in order to exist as a process. Deliberately just
|
||||||
///
|
/// the three things every transport can carry; anything a particular machine
|
||||||
/// Deliberately just the three things every transport can carry. Anything
|
/// needs is the transport's own configuration, not something a driver states.
|
||||||
/// a particular machine needs -- a port, a key, extra ssh options -- is
|
|
||||||
/// the transport's own configuration, not something a driver states.
|
|
||||||
pub struct Launch {
|
pub struct Launch {
|
||||||
pub program: String,
|
pub program: String,
|
||||||
pub args: Vec<String>,
|
pub args: Vec<String>,
|
||||||
@@ -51,22 +45,20 @@ impl Launch {
|
|||||||
/// How a launched process's standard streams are connected.
|
/// How a launched process's standard streams are connected.
|
||||||
///
|
///
|
||||||
/// The choice is not the transport's and not the driver's dialect: it is
|
/// The choice is not the transport's and not the driver's dialect: it is
|
||||||
/// whether the process is expected to outlive this server. A probe is
|
/// whether the process is expected to outlive this server. A probe answers
|
||||||
/// asked a question and answers within one call, so pipes this server
|
/// within one call, so pipes this server drains are right. A session is a
|
||||||
/// drains are right and dying with it is right. A session is a
|
|
||||||
/// conversation somebody is having, so its streams live in the session
|
/// conversation somebody is having, so its streams live in the session
|
||||||
/// directory where a later run of this server can pick them up again --
|
/// directory where a later run of this server can pick them up.
|
||||||
/// see `session::process`.
|
|
||||||
pub enum Streams {
|
pub enum Streams {
|
||||||
/// Pipes owned by this server; the child is killed when they drop.
|
/// Pipes owned by this server; the child is killed when they drop.
|
||||||
Piped,
|
Piped,
|
||||||
/// The same, except that stdin is already open on something this
|
/// The same, except that stdin is already open on something this server
|
||||||
/// server holds -- the file being copied to another machine. Bytes
|
/// holds -- the file being copied to another machine. Bytes this process has
|
||||||
/// this process has in memory do not need this: [`Streams::Piped`]
|
/// in memory do not need this: [`Streams::Piped`] gives a pipe to write them
|
||||||
/// gives a pipe to write them into as the child reads.
|
/// into as the child reads.
|
||||||
PipedFrom(Stdio),
|
PipedFrom(Stdio),
|
||||||
/// Files -- and, for stdin, a fifo the child itself holds open so it
|
/// Files -- and, for stdin, a fifo the child itself holds open so it never
|
||||||
/// never reads EOF -- that outlast this process.
|
/// reads EOF -- that outlast this process.
|
||||||
Detached {
|
Detached {
|
||||||
stdin: Stdio,
|
stdin: Stdio,
|
||||||
stdout: Stdio,
|
stdout: Stdio,
|
||||||
@@ -80,8 +72,7 @@ pub enum Transport {
|
|||||||
Here,
|
Here,
|
||||||
/// Reached with the system `ssh` client. Owns its entry rather than
|
/// Reached with the system `ssh` client. Owns its entry rather than
|
||||||
/// borrowing it, so a session keeps working against the config it was
|
/// borrowing it, so a session keeps working against the config it was
|
||||||
/// spawned with even if the setup is edited afterwards. Carries the
|
/// spawned with even if the setup is edited afterwards.
|
||||||
/// setup's name only to say where things are running.
|
|
||||||
Ssh { name: String, ssh: SshConfig },
|
Ssh { name: String, ssh: SshConfig },
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,12 +88,10 @@ impl Transport {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Starts `launch` with its streams connected as `streams` says.
|
/// Starts `launch` with its streams connected as `streams` says. The failure
|
||||||
///
|
/// names what to check, and the two transports fail for genuinely different
|
||||||
/// The failure names what to check, and the two transports fail for
|
/// reasons -- a missing ssh client here versus a program not on the remote
|
||||||
/// genuinely different reasons -- a missing ssh client here versus a
|
/// PATH -- so each says its own thing.
|
||||||
/// program that is not on the remote PATH -- so each says its own
|
|
||||||
/// thing rather than one message hedging between them.
|
|
||||||
pub fn spawn(&self, launch: &Launch, streams: Streams) -> Result<Child> {
|
pub fn spawn(&self, launch: &Launch, streams: Streams) -> Result<Child> {
|
||||||
let host = match self {
|
let host = match self {
|
||||||
Self::Here => None,
|
Self::Here => None,
|
||||||
@@ -135,11 +124,9 @@ impl Transport {
|
|||||||
stderr,
|
stderr,
|
||||||
} => {
|
} => {
|
||||||
command.stdin(stdin).stdout(stdout).stderr(stderr);
|
command.stdin(stdin).stdout(stdout).stderr(stderr);
|
||||||
// No `kill_on_drop`: outliving this server is the point.
|
// No `kill_on_drop`: outliving this server is the point. Its own
|
||||||
// Its own process group as well, so a signal sent to the
|
// process group as well, so a signal sent to the server's group
|
||||||
// server's group -- which is how a terminal or a
|
// does not travel to a session meant to survive being stopped.
|
||||||
// supervisor stops it -- does not travel to a session that
|
|
||||||
// is meant to survive being stopped.
|
|
||||||
command.process_group(0);
|
command.process_group(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -157,13 +144,10 @@ impl Transport {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Runs `launch` to completion and returns its stdout, blocking.
|
/// Runs `launch` to completion and returns its stdout, blocking. The
|
||||||
///
|
/// synchronous twin of `capture`, for callers already on a blocking task that
|
||||||
/// The synchronous twin of `capture`, for callers that are already on a
|
/// would otherwise need a runtime to ask a machine a question. Both build the
|
||||||
/// blocking task and would otherwise need a runtime to ask a machine a
|
/// invocation the same way.
|
||||||
/// question. Both build the invocation the same way -- see
|
|
||||||
/// `crate::ssh::command` -- so there is still only one description of
|
|
||||||
/// what running something on another machine means.
|
|
||||||
pub fn capture_blocking(&self, launch: &Launch) -> Result<String> {
|
pub fn capture_blocking(&self, launch: &Launch) -> Result<String> {
|
||||||
let host = match self {
|
let host = match self {
|
||||||
Self::Here => None,
|
Self::Here => None,
|
||||||
@@ -189,21 +173,19 @@ impl Transport {
|
|||||||
/// Runs `launch` with `input` on its stdin and reports everything it
|
/// Runs `launch` with `input` on its stdin and reports everything it
|
||||||
/// produced -- stdout as bytes, stderr as text, and the exit status.
|
/// produced -- stdout as bytes, stderr as text, and the exit status.
|
||||||
///
|
///
|
||||||
/// The one description of "run this there, with this on stdin", so
|
/// The one description of "run this there, with this on stdin", so that
|
||||||
/// that shipping an attachment and writing a file through the explorer
|
/// shipping an attachment and writing a file through the explorer are the
|
||||||
/// are the same operation rather than two. It is also the only capture
|
/// same operation rather than two. It is also the only capture that hands
|
||||||
/// that hands back the **status**: a script can then answer with an
|
/// back the **status**: a script can answer with an exit code the caller
|
||||||
/// exit code the caller distinguishes (the explorer's write says
|
/// distinguishes (the explorer's write says `exit 3` for "this file is not
|
||||||
/// `exit 3` for "this file is not the one you read"), which
|
/// the one you read"), which [`Transport::capture`] cannot express.
|
||||||
/// [`Transport::capture`] cannot express because it turns every
|
|
||||||
/// failure into one error.
|
|
||||||
///
|
///
|
||||||
/// Bytes rather than a `String`, because a file's contents are not
|
/// Bytes rather than a `String`, because a file's contents are not text
|
||||||
/// text until something has checked, and lossy decoding would replace
|
/// until something has checked, and lossy decoding would replace the
|
||||||
/// the evidence that they are not.
|
/// evidence that they are not.
|
||||||
///
|
///
|
||||||
/// `Err` means the process could not be started at all; a process that
|
/// `Err` means the process could not be started at all; a process that ran
|
||||||
/// ran and failed is a [`Captured`] with a status saying so.
|
/// and failed is a [`Captured`] with a status saying so.
|
||||||
pub async fn capture_with_input(&self, launch: &Launch, input: Input) -> Result<Captured> {
|
pub async fn capture_with_input(&self, launch: &Launch, input: Input) -> Result<Captured> {
|
||||||
let (streams, to_write) = match input {
|
let (streams, to_write) = match input {
|
||||||
Input::None => (Streams::Piped, None),
|
Input::None => (Streams::Piped, None),
|
||||||
@@ -212,13 +194,11 @@ impl Transport {
|
|||||||
};
|
};
|
||||||
let mut child = self.spawn(launch, streams)?;
|
let mut child = self.spawn(launch, streams)?;
|
||||||
if let Some(bytes) = to_write {
|
if let Some(bytes) = to_write {
|
||||||
// Written from a task rather than before the wait, because the
|
// Written from a task rather than before the wait, because the child
|
||||||
// child may not read all of it -- the write script exits
|
// may not read all of it -- the write script exits without reading
|
||||||
// without reading when the file has changed underneath -- and
|
// when the file has changed underneath -- and a caller blocked on
|
||||||
// a caller blocked on filling a pipe nobody is draining would
|
// filling a pipe nobody is draining would deadlock instead of getting
|
||||||
// deadlock instead of getting that answer. The broken pipe is
|
// that answer. The broken pipe is the expected end of this write.
|
||||||
// the expected end of this write, so it is dropped: what
|
|
||||||
// happened is the exit status below.
|
|
||||||
let mut stdin = child.stdin.take().context("the child has no stdin")?;
|
let mut stdin = child.stdin.take().context("the child has no stdin")?;
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
use tokio::io::AsyncWriteExt;
|
use tokio::io::AsyncWriteExt;
|
||||||
@@ -248,11 +228,11 @@ impl Transport {
|
|||||||
|
|
||||||
/// What a command is given on its standard input.
|
/// What a command is given on its standard input.
|
||||||
///
|
///
|
||||||
/// Three cases rather than an `Option<Stdio>` because they are three
|
/// Three cases rather than an `Option<Stdio>` because they are three genuinely
|
||||||
/// genuinely different arrangements and only this knows which: nothing to
|
/// different arrangements and only this knows which: nothing to say, bytes this
|
||||||
/// say, bytes this process is holding, or a file it has open. The last one
|
/// process is holding, or a file it has open. The last is how a
|
||||||
/// is how a several-hundred-megabyte attachment reaches another machine
|
/// several-hundred-megabyte attachment reaches another machine without passing
|
||||||
/// without passing through this server's memory.
|
/// through this server's memory.
|
||||||
pub enum Input {
|
pub enum Input {
|
||||||
None,
|
None,
|
||||||
Bytes(Vec<u8>),
|
Bytes(Vec<u8>),
|
||||||
@@ -263,10 +243,9 @@ pub enum Input {
|
|||||||
pub struct Captured {
|
pub struct Captured {
|
||||||
pub status: std::process::ExitStatus,
|
pub status: std::process::ExitStatus,
|
||||||
pub stdout: Vec<u8>,
|
pub stdout: Vec<u8>,
|
||||||
/// Trimmed, and what a failure is reported as: ssh's own refusals and
|
/// Trimmed, and what a failure is reported as: ssh's own refusals and a
|
||||||
/// a tool's own message about the file it could not open are both the
|
/// tool's own message about the file it could not open are both the useful
|
||||||
/// useful half of why something did not work, and both are written to
|
/// half of why something did not work.
|
||||||
/// name the thing.
|
|
||||||
pub stderr: String,
|
pub stderr: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+53
-70
@@ -1,50 +1,43 @@
|
|||||||
//! Finding out what a machine can run, rather than being told.
|
//! Finding out what a machine can run, rather than being told.
|
||||||
//!
|
//!
|
||||||
//! The phone adds a machine by giving connection details; this asks the
|
//! The phone adds a machine by giving connection details; this asks the machine
|
||||||
//! machine itself which of the known programs it has, and the answer
|
//! itself which of the known programs it has, and the answer becomes its
|
||||||
//! becomes its providers. That is a security property, not a convenience:
|
//! providers. That is a security property, not a convenience: **no route accepts
|
||||||
//! **no route accepts a command from the phone.** If it did, the enrolled
|
//! a command from the phone.** If it did, the enrolled token could introduce
|
||||||
//! token would be able to introduce arbitrary programs to run on every
|
//! arbitrary programs to run on every machine a setup names.
|
||||||
//! machine a setup names, and the transport already reaches those over
|
|
||||||
//! ssh. Here the phone's authority is "add this machine", never "run
|
|
||||||
//! this".
|
|
||||||
//!
|
//!
|
||||||
//! It is also the better interface. Nobody wants to type an absolute path
|
//! It is also the better interface: nobody wants to type an absolute path on a
|
||||||
//! on a phone keyboard, and a machine that has moved its binaries answers
|
//! phone keyboard, and a machine that has moved its binaries answers correctly
|
||||||
//! correctly on the next probe without anyone editing anything.
|
//! on the next probe.
|
||||||
//!
|
//!
|
||||||
//! The cost is that a program somewhere unusual is invisible. That is a
|
//! The cost is that a program somewhere unusual is invisible. The escape hatch
|
||||||
//! deliberate trade rather than an oversight: the escape hatch is editing
|
//! is editing `config.ron` on the backend, which is exactly the authority the
|
||||||
//! `config.ron` on the backend, which is exactly the authority the phone
|
//! phone is not being given.
|
||||||
//! is not being given.
|
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
|
||||||
use crate::config::{DriverKind, ProviderConfig};
|
use crate::config::{DriverKind, ProviderConfig};
|
||||||
use crate::session::transport::{Launch, Transport};
|
use crate::session::transport::{Launch, Transport};
|
||||||
|
|
||||||
/// What is looked for, and what finding it makes.
|
/// What is looked for, and what finding it makes. Extending this is how a new
|
||||||
///
|
/// driver becomes discoverable -- one row, not a branch anywhere. The name is
|
||||||
/// Extending this is how a new driver becomes discoverable -- one row, not
|
/// what the provider gets called, so it is what the phone shows and what a
|
||||||
/// a branch anywhere. The name is what the provider gets called, so it is
|
/// session stores.
|
||||||
/// what the phone shows and what a session stores.
|
|
||||||
const PROBES: &[(&str, &str, DriverKind)] = &[
|
const PROBES: &[(&str, &str, DriverKind)] = &[
|
||||||
("claude-cli", "claude", DriverKind::ClaudeCli),
|
("claude-cli", "claude", DriverKind::ClaudeCli),
|
||||||
("local-llama", "llama-server", DriverKind::LlamaCpp),
|
("local-llama", "llama-server", DriverKind::LlamaCpp),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Models offered for a discovered Claude CLI. A shortcut list for the
|
/// Models offered for a discovered Claude CLI. A shortcut list for the spawn
|
||||||
/// spawn screen, not a restriction -- the field stays free text.
|
/// screen, not a restriction -- the field stays free text.
|
||||||
const CLAUDE_MODELS: &[&str] = &["fable", "opus", "sonnet", "haiku"];
|
const CLAUDE_MODELS: &[&str] = &["fable", "opus", "sonnet", "haiku"];
|
||||||
|
|
||||||
/// Asks `transport`'s machine which of [`PROBES`] it has.
|
/// Asks `transport`'s machine which of [`PROBES`] it has.
|
||||||
///
|
///
|
||||||
/// One round trip rather than one per program: over ssh each would be a
|
/// One round trip rather than one per program: over ssh each would be a separate
|
||||||
/// separate connection and handshake, and a person waiting on "test this
|
/// connection and handshake. `command -v` is POSIX and a shell builtin, so it
|
||||||
/// setup" notices. `command -v` is POSIX and a shell builtin, so it works
|
/// works whatever is installed -- and `|| true` keeps a missing program from
|
||||||
/// whatever is installed -- and `|| true` keeps a missing program from
|
/// ending the loop, since the caller wants the whole answer.
|
||||||
/// ending the loop, since the caller wants the whole answer rather than
|
|
||||||
/// the first failure.
|
|
||||||
pub async fn discover(transport: &Transport) -> Result<Vec<ProviderConfig>> {
|
pub async fn discover(transport: &Transport) -> Result<Vec<ProviderConfig>> {
|
||||||
let wanted: Vec<&str> = PROBES.iter().map(|(_, binary, _)| *binary).collect();
|
let wanted: Vec<&str> = PROBES.iter().map(|(_, binary, _)| *binary).collect();
|
||||||
let script = format!(
|
let script = format!(
|
||||||
@@ -55,9 +48,9 @@ pub async fn discover(transport: &Transport) -> Result<Vec<ProviderConfig>> {
|
|||||||
let found = transport.capture(&launch).await.map_err(explain)?;
|
let found = transport.capture(&launch).await.map_err(explain)?;
|
||||||
|
|
||||||
let mut providers = Vec::new();
|
let mut providers = Vec::new();
|
||||||
// Echo runs inside this server, so it exists exactly where this server
|
// Echo runs inside this server, so it exists exactly where this server does
|
||||||
// does and nowhere else. Nothing to probe for, and offering it on a
|
// and nowhere else. Offering it on a remote machine would be a choice that
|
||||||
// remote machine would be a choice that changes nothing.
|
// changes nothing.
|
||||||
if matches!(transport, Transport::Here) {
|
if matches!(transport, Transport::Here) {
|
||||||
providers.push(ProviderConfig {
|
providers.push(ProviderConfig {
|
||||||
name: crate::config::ECHO_PROVIDER.to_string(),
|
name: crate::config::ECHO_PROVIDER.to_string(),
|
||||||
@@ -78,8 +71,8 @@ pub async fn discover(transport: &Transport) -> Result<Vec<ProviderConfig>> {
|
|||||||
name: (*name).to_string(),
|
name: (*name).to_string(),
|
||||||
kind: *kind,
|
kind: *kind,
|
||||||
// The resolved path rather than the bare name: PATH under a
|
// The resolved path rather than the bare name: PATH under a
|
||||||
// non-interactive ssh session is not the one a person sees
|
// non-interactive ssh session is not the one a person sees when they
|
||||||
// when they log in, so "it is on my PATH" is not enough.
|
// log in, so "it is on my PATH" is not enough.
|
||||||
command: Some(path.to_string()),
|
command: Some(path.to_string()),
|
||||||
models: match kind {
|
models: match kind {
|
||||||
DriverKind::ClaudeCli => CLAUDE_MODELS.iter().map(|m| (*m).to_string()).collect(),
|
DriverKind::ClaudeCli => CLAUDE_MODELS.iter().map(|m| (*m).to_string()).collect(),
|
||||||
@@ -92,16 +85,14 @@ pub async fn discover(transport: &Transport) -> Result<Vec<ProviderConfig>> {
|
|||||||
|
|
||||||
/// Adds what to do to failures whose own wording does not say.
|
/// Adds what to do to failures whose own wording does not say.
|
||||||
///
|
///
|
||||||
/// ssh's messages are written for someone at a terminal on the backend,
|
/// ssh's messages are written for someone at a terminal on the backend, which is
|
||||||
/// which is exactly who is not reading this one. Host key verification is
|
/// exactly who is not reading this one. Host key verification is the case that
|
||||||
/// the case that matters: **every** machine fails it the first time,
|
/// matters: **every** machine fails it the first time, so without this, adding a
|
||||||
/// because its key is not in `known_hosts` yet -- so without this, adding
|
/// machine from the phone looks broken rather than unfinished.
|
||||||
/// a machine from the phone looks broken rather than unfinished.
|
|
||||||
///
|
///
|
||||||
/// Deliberately not fixed by relaxing the check. `StrictHostKeyChecking`
|
/// Deliberately not fixed by relaxing the check. `StrictHostKeyChecking` stays
|
||||||
/// stays at its default, so a first connection is a decision somebody
|
/// at its default, so a first connection is a decision somebody makes on the
|
||||||
/// makes on the backend with the key in front of them, rather than
|
/// backend with the key in front of them.
|
||||||
/// something this app quietly accepts on their behalf.
|
|
||||||
fn explain(err: anyhow::Error) -> anyhow::Error {
|
fn explain(err: anyhow::Error) -> anyhow::Error {
|
||||||
let message = format!("{err:#}");
|
let message = format!("{err:#}");
|
||||||
if message.contains("Host key verification failed") {
|
if message.contains("Host key verification failed") {
|
||||||
@@ -120,11 +111,9 @@ fn explain(err: anyhow::Error) -> anyhow::Error {
|
|||||||
err
|
err
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A short, stable, filename-safe id derived from a label.
|
/// A short, stable, filename-safe id derived from a label. Derived once when a
|
||||||
///
|
/// setup is added and then fixed, so the label stays editable. Collisions are
|
||||||
/// Derived once when a setup is added and then fixed, so the label stays
|
/// resolved by the caller, which is the only place that knows what exists.
|
||||||
/// editable. Collisions are resolved by the caller, which is the only
|
|
||||||
/// place that knows what already exists.
|
|
||||||
pub fn id_from(label: &str) -> String {
|
pub fn id_from(label: &str) -> String {
|
||||||
let slug: String = label
|
let slug: String = label
|
||||||
.chars()
|
.chars()
|
||||||
@@ -160,25 +149,20 @@ pub fn tidy(value: &str) -> Option<String> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The inverse of [`tidy`]'s expansion: an absolute path under this
|
/// The inverse of [`tidy`]'s expansion: an absolute path under this machine's
|
||||||
/// machine's home, written back as `~/…`.
|
/// home, written back as `~/…`, so that a working directory reads on a phone the
|
||||||
|
/// way it is written by hand.
|
||||||
///
|
///
|
||||||
/// So that a working directory reads on a phone the way it is written by
|
/// Applied only to paths on **this** machine. `$HOME` here says nothing about
|
||||||
/// hand. `/home/bob/repos/ai-app-2` is most of a line on that screen and
|
/// the home directory of a machine reached over ssh, so a remote path is stored
|
||||||
/// almost all of it is the part nobody is reading.
|
/// exactly as it was typed and the remote shell is what expands it.
|
||||||
///
|
|
||||||
/// Applied only to paths on **this** machine. `$HOME` here says nothing
|
|
||||||
/// about the home directory of a machine reached over ssh, so a remote
|
|
||||||
/// path is stored exactly as it was typed -- where a `~` somebody wrote
|
|
||||||
/// stays a `~`, and the remote shell is what expands it
|
|
||||||
/// (`ssh::quote_path`).
|
|
||||||
pub fn shorten_home(path: &str) -> String {
|
pub fn shorten_home(path: &str) -> String {
|
||||||
let Some(home) = std::env::home_dir() else {
|
let Some(home) = std::env::home_dir() else {
|
||||||
return path.to_string();
|
return path.to_string();
|
||||||
};
|
};
|
||||||
let home = home.to_string_lossy();
|
let home = home.to_string_lossy();
|
||||||
// The separator has to be part of the match, or `/home/bobby` would be
|
// The separator has to be part of the match, or `/home/bobby` would be read
|
||||||
// read as a path inside `/home/bob`.
|
// as a path inside `/home/bob`.
|
||||||
match path.strip_prefix(home.as_ref()) {
|
match path.strip_prefix(home.as_ref()) {
|
||||||
Some("") => "~".to_string(),
|
Some("") => "~".to_string(),
|
||||||
Some(rest) if rest.starts_with('/') => format!("~{rest}"),
|
Some(rest) if rest.starts_with('/') => format!("~{rest}"),
|
||||||
@@ -188,11 +172,10 @@ pub fn shorten_home(path: &str) -> String {
|
|||||||
|
|
||||||
/// Runs a launch to completion and returns its stdout as text.
|
/// Runs a launch to completion and returns its stdout as text.
|
||||||
///
|
///
|
||||||
/// The common case of [`Transport::capture_with_input`]: nothing on stdin,
|
/// The common case of [`Transport::capture_with_input`]: nothing on stdin, a
|
||||||
/// a failure reported as the machine's own words (ssh's "Permission
|
/// failure reported as the machine's own words (ssh's "Permission denied" is the
|
||||||
/// denied" or "Could not resolve hostname" is the useful half of why a
|
/// useful half of why a setup cannot be reached), and the output read as text
|
||||||
/// setup cannot be reached), and the output read as text because every
|
/// because every caller here is asking a question whose answer is words.
|
||||||
/// caller here is asking a question whose answer is words.
|
|
||||||
impl Transport {
|
impl Transport {
|
||||||
pub async fn capture(&self, launch: &Launch) -> Result<String> {
|
pub async fn capture(&self, launch: &Launch) -> Result<String> {
|
||||||
let captured = self
|
let captured = self
|
||||||
@@ -206,9 +189,9 @@ impl Transport {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
/// The two halves of a home-relative path, which have to be inverses:
|
/// The two halves of a home-relative path, which have to be inverses: what
|
||||||
/// what is stored is what the phone draws, and what the phone sends
|
/// is stored is what the phone draws, and what the phone sends back is what
|
||||||
/// back is what a process is started in.
|
/// a process is started in.
|
||||||
#[test]
|
#[test]
|
||||||
fn a_home_path_shortens_and_expands_back() {
|
fn a_home_path_shortens_and_expands_back() {
|
||||||
let Some(home) = std::env::home_dir() else {
|
let Some(home) = std::env::home_dir() else {
|
||||||
@@ -220,8 +203,8 @@ mod tests {
|
|||||||
assert_eq!(shorten_home(&home.to_string_lossy()), "~");
|
assert_eq!(shorten_home(&home.to_string_lossy()), "~");
|
||||||
assert_eq!(tidy("~/repos/ai-app-2").as_deref(), Some(full.as_ref()));
|
assert_eq!(tidy("~/repos/ai-app-2").as_deref(), Some(full.as_ref()));
|
||||||
|
|
||||||
// Not a prefix match on the characters: a sibling directory whose
|
// Not a prefix match on the characters: a sibling directory whose name
|
||||||
// name merely starts with the home directory's is not inside it.
|
// merely starts with the home directory's is not inside it.
|
||||||
let sibling = format!("{}-backup/notes", home.to_string_lossy());
|
let sibling = format!("{}-backup/notes", home.to_string_lossy());
|
||||||
assert_eq!(shorten_home(&sibling), sibling);
|
assert_eq!(shorten_home(&sibling), sibling);
|
||||||
assert_eq!(shorten_home("/etc/hosts"), "/etc/hosts");
|
assert_eq!(shorten_home("/etc/hosts"), "/etc/hosts");
|
||||||
|
|||||||
+69
-92
@@ -1,45 +1,39 @@
|
|||||||
//! Building the command a driver actually spawns -- locally, or wrapped in
|
//! Building the command a driver actually spawns -- locally, or wrapped in
|
||||||
//! `ssh` when the session names a host to run on.
|
//! `ssh` when the session names a host to run on.
|
||||||
//!
|
//!
|
||||||
//! The whole point of the session design is that a driver speaks JSONL over
|
//! A driver speaks JSONL over a child process's stdio and doesn't care what
|
||||||
//! a child process's stdio and doesn't care what that child is. A remote
|
//! that child is, so a remote session is the identical command with `ssh host …`
|
||||||
//! session is therefore the identical command with `ssh host …` in front:
|
//! in front.
|
||||||
//! stdio doesn't care, so nothing downstream of here changes.
|
|
||||||
//!
|
//!
|
||||||
//! Uses the system `ssh` client rather than a Rust SSH library, so
|
//! Uses the system `ssh` client rather than a Rust SSH library, so
|
||||||
//! `~/.ssh/config`, agents, and jump hosts all keep working and there is
|
//! `~/.ssh/config`, agents and jump hosts all keep working and there is only
|
||||||
//! only one place to configure connections (PLAN.md, rule 23).
|
//! one place to configure connections.
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
use crate::config::SshConfig;
|
use crate::config::SshConfig;
|
||||||
|
|
||||||
/// Options forced onto every connection. `BatchMode` makes a missing key
|
/// Options forced onto every connection. `BatchMode` makes a missing key fail
|
||||||
/// fail immediately with a readable message instead of hanging on a
|
/// immediately with a readable message instead of hanging on a password prompt
|
||||||
/// password prompt that nothing can answer; the keepalives turn a silently
|
/// nothing can answer; the keepalives turn a silently dropped link into a
|
||||||
/// dropped link into a process exit, which the session reports as `exited`
|
/// process exit, which the session reports as `exited` rather than hanging.
|
||||||
/// rather than appearing to hang forever.
|
|
||||||
const SSH_OPTIONS: [&str; 3] = [
|
const SSH_OPTIONS: [&str; 3] = [
|
||||||
"BatchMode=yes",
|
"BatchMode=yes",
|
||||||
"ServerAliveInterval=30",
|
"ServerAliveInterval=30",
|
||||||
"ServerAliveCountMax=3",
|
"ServerAliveCountMax=3",
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Builds the child process for `program args…`, run in `cwd`, either on
|
/// Builds the child process for `program args…`, run in `cwd`, either on this
|
||||||
/// this machine (`ssh` absent) or on the machine it describes.
|
/// machine (`ssh` absent) or on the machine it describes.
|
||||||
///
|
///
|
||||||
/// Stdio is left alone: how the streams are connected is the caller's
|
/// Stdio is left alone: how the streams are connected is the caller's decision
|
||||||
/// decision and differs by more than the transport does -- a probe wants
|
/// and differs by more than the transport does -- a probe wants pipes it will
|
||||||
/// pipes it will drain, a session wants files that outlive this server --
|
/// drain, a session wants files that outlive this server.
|
||||||
/// so `Transport::spawn` applies it rather than this.
|
|
||||||
///
|
///
|
||||||
/// A plain [`std::process::Command`], which `tokio` converts from, because
|
/// A plain [`std::process::Command`], which `tokio` converts from, because not
|
||||||
/// not every caller is async: the usage fetch is blocking by nature (it
|
/// every caller is async: the usage fetch is blocking by nature and should not
|
||||||
/// makes a blocking HTTP call) and reads a file from the same machine on
|
/// have to build an ssh invocation of its own.
|
||||||
/// the way, and it should not have to build an ssh invocation of its own
|
|
||||||
/// to do that. One place knows what a correct invocation is; how it is run
|
|
||||||
/// is the caller's business.
|
|
||||||
pub fn command(
|
pub fn command(
|
||||||
remote: Option<&SshConfig>,
|
remote: Option<&SshConfig>,
|
||||||
program: &str,
|
program: &str,
|
||||||
@@ -50,22 +44,19 @@ pub fn command(
|
|||||||
let mut command = Command::new(program);
|
let mut command = Command::new(program);
|
||||||
command.args(args);
|
command.args(args);
|
||||||
if let Some(cwd) = cwd {
|
if let Some(cwd) = cwd {
|
||||||
// Expanded here for the same reason `quote_path` expands it on
|
// Expanded here for the same reason `quote_path` expands it on the
|
||||||
// the far side: a working directory typed as `~/repos/ai-app`
|
// far side: a working directory typed as `~/repos/ai-app` has to
|
||||||
// has to mean the same thing whichever machine runs it. There
|
// mean the same thing whichever machine runs it. There is no shell
|
||||||
// is no shell in this branch, so nothing else would --
|
// in this branch, so nothing else would -- `current_dir` would be
|
||||||
// `current_dir` would be handed the literal one-character
|
// handed the literal one-character directory `~`.
|
||||||
// directory `~`, and the session would fail to start with an
|
|
||||||
// error naming a path nobody typed. Only the cwd, matching
|
|
||||||
// the remote side, where arguments stay literal.
|
|
||||||
command.current_dir(expand_home(cwd));
|
command.current_dir(expand_home(cwd));
|
||||||
}
|
}
|
||||||
return command;
|
return command;
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut command = Command::new("ssh");
|
let mut command = Command::new("ssh");
|
||||||
// -T: no pty. This carries JSONL, and a pty would rewrite it (echo,
|
// -T: no pty. This carries JSONL, and a pty would rewrite it (echo, CRLF
|
||||||
// CRLF translation, ^C handling) into something the parser can't read.
|
// translation, ^C handling) into something the parser can't read.
|
||||||
command.arg("-T");
|
command.arg("-T");
|
||||||
for option in SSH_OPTIONS {
|
for option in SSH_OPTIONS {
|
||||||
command.args(["-o", option]);
|
command.args(["-o", option]);
|
||||||
@@ -78,9 +69,8 @@ pub fn command(
|
|||||||
}
|
}
|
||||||
if let Some(identity) = &ssh.identity_file {
|
if let Some(identity) = &ssh.identity_file {
|
||||||
command.arg("-i").arg(identity);
|
command.arg("-i").arg(identity);
|
||||||
// Without this, ssh may offer an agent key first and authenticate
|
// Without this, ssh may offer an agent key first and authenticate as
|
||||||
// as somebody else entirely -- silently, and with different
|
// somebody else entirely -- silently, and with different permissions.
|
||||||
// permissions than intended.
|
|
||||||
command.args(["-o", "IdentitiesOnly=yes"]);
|
command.args(["-o", "IdentitiesOnly=yes"]);
|
||||||
}
|
}
|
||||||
command.arg(&ssh.address);
|
command.arg(&ssh.address);
|
||||||
@@ -88,11 +78,10 @@ pub fn command(
|
|||||||
command
|
command
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The single argument handed to the remote login shell.
|
/// The single argument handed to the remote login shell. `exec` so the CLI
|
||||||
///
|
/// replaces that shell: the process the connection is attached to is then the
|
||||||
/// `exec` so the CLI replaces that shell: the process the connection is
|
/// CLI itself, and dropping the connection takes it down rather than leaving an
|
||||||
/// attached to is then the CLI itself, and dropping the connection takes
|
/// orphan behind a live wrapper.
|
||||||
/// it down rather than leaving an orphan behind a live wrapper.
|
|
||||||
fn remote_script(program: &str, args: &[String], cwd: Option<&Path>) -> String {
|
fn remote_script(program: &str, args: &[String], cwd: Option<&Path>) -> String {
|
||||||
let mut script = String::new();
|
let mut script = String::new();
|
||||||
if let Some(cwd) = cwd {
|
if let Some(cwd) = cwd {
|
||||||
@@ -112,11 +101,9 @@ fn remote_script(program: &str, args: &[String], cwd: Option<&Path>) -> String {
|
|||||||
/// A path with a leading `~` replaced by this machine's home directory.
|
/// A path with a leading `~` replaced by this machine's home directory.
|
||||||
///
|
///
|
||||||
/// The local half of the rule [`quote_path`] states for the remote one, and
|
/// The local half of the rule [`quote_path`] states for the remote one, and
|
||||||
/// the two are deliberately the same shape: the tilde is expanded, `~user`
|
/// deliberately the same shape: the tilde is expanded, `~user` is not, and
|
||||||
/// is not (there is no portable expansion for another account's home), and
|
/// nothing else in the path gains a meaning. A machine with no home directory
|
||||||
/// nothing else in the path gains a meaning. A machine with no home
|
/// leaves the path alone, which fails with the operating system's own message.
|
||||||
/// directory at all leaves the path alone, which fails with the operating
|
|
||||||
/// system's own message rather than with a guess.
|
|
||||||
pub(crate) fn expand_home(path: &Path) -> PathBuf {
|
pub(crate) fn expand_home(path: &Path) -> PathBuf {
|
||||||
let Some(rest) = path.to_str().and_then(|p| {
|
let Some(rest) = path.to_str().and_then(|p| {
|
||||||
if p == "~" {
|
if p == "~" {
|
||||||
@@ -136,23 +123,19 @@ pub(crate) fn expand_home(path: &Path) -> PathBuf {
|
|||||||
/// Quotes a path, expanding a leading `~` and nothing else.
|
/// Quotes a path, expanding a leading `~` and nothing else.
|
||||||
///
|
///
|
||||||
/// [`quote`] is right for every other word crossing to the remote side and
|
/// [`quote`] is right for every other word crossing to the remote side and
|
||||||
/// wrong for exactly one character. `~` means "expand me", and single
|
/// wrong for exactly one character. `~` means "expand me", and single quotes
|
||||||
/// quotes are what stop expansion -- so a working directory typed as
|
/// are what stop expansion -- so a working directory typed as `~/repos/ai-app`
|
||||||
/// `~/repos/ai-app` arrived as the literal four-character directory `~`,
|
/// arrived as the literal four-character directory `~`, and the remote shell
|
||||||
/// and the remote shell said it did not exist. Which is true, and reads
|
/// said it did not exist, which reads like the path being wrong.
|
||||||
/// like the path being wrong rather than the quoting.
|
|
||||||
///
|
///
|
||||||
/// `"$HOME"` rather than handing the tilde to the shell unquoted: the
|
/// `"$HOME"` rather than handing the tilde to the shell unquoted: the variable
|
||||||
/// variable is expanded, the expansion is not re-split or globbed because
|
/// is expanded, the expansion is not re-split or globbed because it is
|
||||||
/// it is double-quoted, and everything after it stays single-quoted and
|
/// double-quoted, and everything after it stays single-quoted and literal.
|
||||||
/// literal. So the one character that has to mean something keeps meaning
|
/// `$HOME` is set by every shell this can land in, including the fish login
|
||||||
/// it, and nothing else gains a meaning. `$HOME` is set by every shell
|
/// shell on the dev VM, so this does not depend on the remote shell being POSIX.
|
||||||
/// this can land in, including the fish login shell on the dev VM, which
|
|
||||||
/// is why this does not depend on the remote shell being POSIX.
|
|
||||||
///
|
///
|
||||||
/// `~user` is deliberately not handled: there is no portable expansion for
|
/// `~user` is deliberately not handled: there is no portable expansion for it,
|
||||||
/// it, and inventing one would mean guessing another account's home
|
/// and inventing one would mean guessing another account's home directory.
|
||||||
/// directory. It stays literal and fails with the shell's own message.
|
|
||||||
pub(crate) fn quote_path(path: &str) -> String {
|
pub(crate) fn quote_path(path: &str) -> String {
|
||||||
if path == "~" {
|
if path == "~" {
|
||||||
return "\"$HOME\"".to_string();
|
return "\"$HOME\"".to_string();
|
||||||
@@ -163,15 +146,13 @@ pub(crate) fn quote_path(path: &str) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Single-quotes one word for a POSIX shell.
|
/// Single-quotes one word for a POSIX shell. Everything crossing to the remote
|
||||||
///
|
/// side goes through here: paths, model names and prompts-as-arguments are all
|
||||||
/// Everything crossing to the remote side goes through here: paths, model
|
/// attacker-adjacent input in a server whose whole job is running commands, and
|
||||||
/// names, and prompts-as-arguments are all attacker-adjacent input in a
|
/// unquoted they would be shell syntax rather than data.
|
||||||
/// server whose whole job is running commands, and unquoted they would be
|
|
||||||
/// shell syntax rather than data.
|
|
||||||
pub(crate) fn quote(word: &str) -> String {
|
pub(crate) fn quote(word: &str) -> String {
|
||||||
// Inside single quotes every character is literal except `'` itself,
|
// Inside single quotes every character is literal except `'` itself, which
|
||||||
// which is closed, escaped, and reopened.
|
// is closed, escaped, and reopened.
|
||||||
format!("'{}'", word.replace('\'', r"'\''"))
|
format!("'{}'", word.replace('\'', r"'\''"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,8 +173,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// A host with nothing configured but a name to dial, so `~/.ssh/config`
|
/// A host with nothing configured but a name to dial, so `~/.ssh/config`
|
||||||
/// decides everything else -- the case that proves this adds no flags of
|
/// decides everything else -- the case that proves this adds no flags of its
|
||||||
/// its own when it was not told to.
|
/// own when it was not told to.
|
||||||
fn bare_host() -> SshConfig {
|
fn bare_host() -> SshConfig {
|
||||||
SshConfig {
|
SshConfig {
|
||||||
address: "vm".to_string(),
|
address: "vm".to_string(),
|
||||||
@@ -256,19 +237,17 @@ mod tests {
|
|||||||
assert!(!rendered.contains(&"IdentitiesOnly=yes".to_string()));
|
assert!(!rendered.contains(&"IdentitiesOnly=yes".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The one character quoting must not swallow.
|
/// The one character quoting must not swallow. A working directory typed as
|
||||||
///
|
/// `~/repos/ai-app` was arriving as the literal directory `~`, and the
|
||||||
/// A working directory typed as `~/repos/ai-app` was arriving as the
|
/// remote shell reported it missing -- which reads as the path being wrong
|
||||||
/// literal directory `~`, and the remote shell reported it missing --
|
/// rather than the quoting being wrong, and cost an evening.
|
||||||
/// which reads as the path being wrong rather than the quoting being
|
|
||||||
/// wrong, and cost an evening on exactly that misreading.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_leading_tilde_expands_and_nothing_else_does() {
|
fn a_leading_tilde_expands_and_nothing_else_does() {
|
||||||
assert_eq!(quote_path("~"), "\"$HOME\"");
|
assert_eq!(quote_path("~"), "\"$HOME\"");
|
||||||
assert_eq!(quote_path("~/repos/ai-app"), "\"$HOME\"/'repos/ai-app'");
|
assert_eq!(quote_path("~/repos/ai-app"), "\"$HOME\"/'repos/ai-app'");
|
||||||
// Only leading, and only its own segment: a tilde anywhere else is
|
// Only leading, and only its own segment: a tilde anywhere else is an
|
||||||
// an ordinary character in a filename, and `~user` has no portable
|
// ordinary character in a filename, and `~user` has no portable
|
||||||
// expansion so it stays literal and fails with the shell's message.
|
// expansion so it stays literal.
|
||||||
assert_eq!(quote_path("/tmp/~/x"), "'/tmp/~/x'");
|
assert_eq!(quote_path("/tmp/~/x"), "'/tmp/~/x'");
|
||||||
assert_eq!(quote_path("~user/x"), "'~user/x'");
|
assert_eq!(quote_path("~user/x"), "'~user/x'");
|
||||||
|
|
||||||
@@ -279,13 +258,11 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The same character, on the transport with no shell to expand it.
|
/// The same character, on the transport with no shell to expand it. The
|
||||||
///
|
/// local branch runs the program directly, so a working directory of
|
||||||
/// The local branch runs the program directly, so a working directory
|
/// `~/repos/ai-app` would reach `current_dir` as the literal one-character
|
||||||
/// of `~/repos/ai-app` would reach `current_dir` as the literal
|
/// directory `~`. The two transports have to agree about what a tilde means
|
||||||
/// one-character directory `~` -- a session that fails to start,
|
/// or a path is only portable by accident.
|
||||||
/// naming a path nobody typed. The two transports have to agree about
|
|
||||||
/// what a tilde means or a path is only portable by accident.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_local_cwd_expands_its_tilde_the_same_way() {
|
fn a_local_cwd_expands_its_tilde_the_same_way() {
|
||||||
let Some(home) = std::env::home_dir() else {
|
let Some(home) = std::env::home_dir() else {
|
||||||
@@ -306,9 +283,9 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn shell_metacharacters_cross_as_data_not_syntax() {
|
fn shell_metacharacters_cross_as_data_not_syntax() {
|
||||||
// Expanding $HOME must not open a door for anything else: the rest
|
// Expanding $HOME must not open a door for anything else: the rest stays
|
||||||
// stays single-quoted, so this remains one absurd path rather than
|
// single-quoted, so this remains one absurd path rather than three
|
||||||
// three commands.
|
// commands.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
quote_path("~/'; touch /tmp/pwned; '"),
|
quote_path("~/'; touch /tmp/pwned; '"),
|
||||||
r#""$HOME"/''\''; touch /tmp/pwned; '\'''"#,
|
r#""$HOME"/''\''; touch /tmp/pwned; '\'''"#,
|
||||||
@@ -320,8 +297,8 @@ mod tests {
|
|||||||
assert_eq!(quote("$(whoami)"), "'$(whoami)'");
|
assert_eq!(quote("$(whoami)"), "'$(whoami)'");
|
||||||
assert_eq!(quote("it's"), r"'it'\''s'");
|
assert_eq!(quote("it's"), r"'it'\''s'");
|
||||||
|
|
||||||
// The end-to-end version of the same worry: a working directory
|
// The end-to-end version of the same worry: a working directory that
|
||||||
// that tries to close the quote and start a new command.
|
// tries to close the quote and start a new command.
|
||||||
let ssh = bare_host();
|
let ssh = bare_host();
|
||||||
let evil = Path::new("/tmp/'; touch /tmp/pwned; '");
|
let evil = Path::new("/tmp/'; touch /tmp/pwned; '");
|
||||||
let rendered = argv(&command(Some(&ssh), "claude", &[], Some(evil)));
|
let rendered = argv(&command(Some(&ssh), "claude", &[], Some(evil)));
|
||||||
|
|||||||
+90
-109
@@ -3,36 +3,31 @@
|
|||||||
//! Polls `https://api.anthropic.com/api/oauth/usage` with the OAuth access
|
//! Polls `https://api.anthropic.com/api/oauth/usage` with the OAuth access
|
||||||
//! token from Claude Code's local credential store. The endpoint is
|
//! token from Claude Code's local credential store. The endpoint is
|
||||||
//! undocumented and has changed before, so everything here is best-effort:
|
//! undocumented and has changed before, so everything here is best-effort:
|
||||||
//! every field is optional, and failure degrades to an "unavailable"
|
//! every field is optional, and failure degrades to an "unavailable" snapshot
|
||||||
//! snapshot with the reason, never an error that breaks the screen.
|
//! with the reason, never an error that breaks the screen.
|
||||||
//!
|
//!
|
||||||
//! Two rules learned from others hitting this endpoint (see PLAN.md's
|
//! Two rules learned from others hitting this endpoint: send `User-Agent:
|
||||||
//! references): send `User-Agent: claude-code/<version>` (without it,
|
//! claude-code/<version>` (without it, requests land in an aggressively
|
||||||
//! requests land in an aggressively rate-limited bucket) and poll no more
|
//! rate-limited bucket) and poll no more often than every 180 s. The cache
|
||||||
//! often than every 180 s. The cache below enforces the latter across any
|
//! below enforces the latter across any number of phone refreshes; there is no
|
||||||
//! number of phone refreshes; there is no background poll at all -- the
|
//! background poll at all.
|
||||||
//! screen's fetch is the trigger, so no session activity means no traffic.
|
|
||||||
//!
|
//!
|
||||||
//! One [`UsageProvider`] per paid service, so a second service later is a
|
//! One [`UsageProvider`] per paid service, so a second service later is a new
|
||||||
//! new impl behind the same snapshot shape, not a parallel screen.
|
//! impl behind the same snapshot shape, not a parallel screen.
|
||||||
//!
|
//!
|
||||||
//! **Asked of the machine that spends the tokens, not of this one.** A
|
//! **Asked of the machine that spends the tokens, not of this one.** A session
|
||||||
//! session runs wherever its setup says, so the account being billed is
|
//! runs wherever its setup says, so the account being billed is that machine's.
|
||||||
//! that machine's, and reading this machine's credentials reports on an
|
//! In the layout this project aims at, `ai-server` is on the host, the host has
|
||||||
//! account that may have run nothing. In the layout this project is aiming
|
//! no `claude` CLI, and the CLI machine is a remote -- so the one set of numbers
|
||||||
//! at that is not a rounding error: `ai-server` belongs on the host, the
|
//! the screen could show would be an account with no sessions. Credentials are
|
||||||
//! host has no `claude` CLI, and the CLI machine is a remote -- so the one
|
//! read through the session `Transport`, one snapshot per setup that offers
|
||||||
//! set of numbers the screen could show would be the numbers of an account
|
//! Claude.
|
||||||
//! with no sessions. Credentials are therefore read through the session
|
|
||||||
//! `Transport`, one snapshot per setup that offers Claude.
|
|
||||||
//!
|
//!
|
||||||
//! The token is read *to* the backend and the HTTP call is made from here,
|
//! The token is read *to* the backend and the HTTP call is made from here, so
|
||||||
//! rather than running the request on the far machine: it needs no tooling
|
//! the far machine needs nothing beyond a shell and the wire format stays in
|
||||||
//! there beyond a shell, and it keeps the one place that knows the wire
|
//! one place. The cost is that a remote machine's token is in this process's
|
||||||
//! format in one place. The cost is that a remote machine's token is in
|
//! memory for the length of a fetch, which is the same trust the backend
|
||||||
//! this process's memory for the length of a fetch, which is the same
|
//! already has over that machine.
|
||||||
//! trust the backend already has over that machine (it can start processes
|
|
||||||
//! on it).
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
@@ -54,15 +49,13 @@ const USER_AGENT: &str = "claude-code/2.1.237";
|
|||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct UsageWindow {
|
pub struct UsageWindow {
|
||||||
/// The API's own word for which window this is -- `session` for the
|
/// The API's own word for which window this is -- `session` for the
|
||||||
/// five-hour one, `weekly_all`, `weekly_scoped`, or whatever new kind
|
/// five-hour one, `weekly_all`, `weekly_scoped`, or whatever new kind it
|
||||||
/// it starts sending.
|
/// starts sending.
|
||||||
///
|
///
|
||||||
/// Carried beside the label because a caller that wants one
|
/// Carried beside the label because a caller that wants one particular
|
||||||
/// particular window has to be able to ask for it without matching on
|
/// window has to ask for it without matching on display text: the label is
|
||||||
/// display text: the label is written for a person, is translated the
|
/// written for a person and would silently select nothing the day it
|
||||||
/// moment anybody translates this app, and would silently select
|
/// changes.
|
||||||
/// nothing the day it changes. The session screen's bar picks
|
|
||||||
/// `session` by this field.
|
|
||||||
pub kind: String,
|
pub kind: String,
|
||||||
pub label: String,
|
pub label: String,
|
||||||
/// 0-100.
|
/// 0-100.
|
||||||
@@ -77,13 +70,11 @@ pub struct UsageWindow {
|
|||||||
/// What came back when a machine was asked about its limits.
|
/// What came back when a machine was asked about its limits.
|
||||||
///
|
///
|
||||||
/// Four answers rather than a flag and a message, because the screen has to
|
/// Four answers rather than a flag and a message, because the screen has to
|
||||||
/// treat them differently and a reader has to. "Nobody is logged in here"
|
/// treat them differently. "Nobody is logged in here" is a machine working
|
||||||
/// is a machine working exactly as configured -- somebody chose not to put
|
/// exactly as configured, while "I could not reach it" is a fault worth
|
||||||
/// an account on it -- while "I could not reach it" is a fault worth
|
/// chasing, and "the endpoint refused me" says nothing about the machine at
|
||||||
/// chasing, and "the endpoint refused me" is a third thing that says
|
/// all. Collapsing them into one `error` string made the first look like the
|
||||||
/// nothing about the machine at all. Collapsing them into one `error`
|
/// last, so a perfectly healthy setup read as broken.
|
||||||
/// string made the first look like the last, so a perfectly healthy setup
|
|
||||||
/// read as broken.
|
|
||||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||||
#[serde(tag = "state", rename_all = "camelCase")]
|
#[serde(tag = "state", rename_all = "camelCase")]
|
||||||
pub enum UsageState {
|
pub enum UsageState {
|
||||||
@@ -102,11 +93,11 @@ pub enum UsageState {
|
|||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct UsageSnapshot {
|
pub struct UsageSnapshot {
|
||||||
pub provider: String,
|
pub provider: String,
|
||||||
/// Which machine these are the numbers for. The point of the whole
|
/// Which machine these are the numbers for. The point of the whole module:
|
||||||
/// module: they belong to an account on a particular box.
|
/// they belong to an account on a particular box.
|
||||||
pub setup: String,
|
pub setup: String,
|
||||||
/// That machine's current label, resolved when the snapshot is built,
|
/// That machine's current label, resolved when the snapshot is built, so
|
||||||
/// so renaming a setup renames it here too.
|
/// renaming a setup renames it here too.
|
||||||
pub setup_name: String,
|
pub setup_name: String,
|
||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
pub state: UsageState,
|
pub state: UsageState,
|
||||||
@@ -122,9 +113,9 @@ pub trait UsageProvider: Send + Sync {
|
|||||||
fn fetch(&self) -> UsageSnapshot;
|
fn fetch(&self) -> UsageSnapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reads the numbers behind Claude Code's `/usage` from one machine, using
|
/// Reads the numbers behind Claude Code's `/usage` from one machine, using the
|
||||||
/// the credentials that machine stores -- nothing to configure, and it
|
/// credentials that machine stores -- nothing to configure, and it reports on
|
||||||
/// reports on exactly the account whose CLI runs the sessions there.
|
/// exactly the account whose CLI runs the sessions there.
|
||||||
pub struct ClaudeUsage {
|
pub struct ClaudeUsage {
|
||||||
pub setup: String,
|
pub setup: String,
|
||||||
pub setup_name: String,
|
pub setup_name: String,
|
||||||
@@ -132,9 +123,9 @@ pub struct ClaudeUsage {
|
|||||||
pub transport: Transport,
|
pub transport: Transport,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Where Claude Code keeps its credentials, as a shell word rather than a
|
/// Where Claude Code keeps its credentials, as a shell word rather than a path:
|
||||||
/// path: `$HOME` is expanded by the shell on the machine being asked,
|
/// `$HOME` is expanded by the shell on the machine being asked, which is the
|
||||||
/// which is the only place that knows what it is.
|
/// only place that knows what it is.
|
||||||
const CREDENTIALS: &str = "$HOME/.claude/.credentials.json";
|
const CREDENTIALS: &str = "$HOME/.claude/.credentials.json";
|
||||||
|
|
||||||
impl ClaudeUsage {
|
impl ClaudeUsage {
|
||||||
@@ -149,12 +140,9 @@ impl ClaudeUsage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The machine's stored OAuth token, or which of the two ways of not
|
/// The machine's stored OAuth token, or which of the two ways of not having
|
||||||
/// having one this is.
|
/// one this is. Read through `sh -c` so `$HOME` resolves on the far machine;
|
||||||
///
|
/// a path built here would be this machine's home directory.
|
||||||
/// Read through `sh -c` so `$HOME` resolves on the far machine; a path
|
|
||||||
/// built here would be this machine's home directory, which over ssh
|
|
||||||
/// is somebody else's.
|
|
||||||
fn access_token(&self) -> Result<String, UsageState> {
|
fn access_token(&self) -> Result<String, UsageState> {
|
||||||
let launch = Launch::new(
|
let launch = Launch::new(
|
||||||
"sh",
|
"sh",
|
||||||
@@ -174,8 +162,8 @@ impl ClaudeUsage {
|
|||||||
.as_str()
|
.as_str()
|
||||||
.map(String::from)
|
.map(String::from)
|
||||||
})
|
})
|
||||||
// A file that exists but carries no token is the same situation
|
// A file that exists but carries no token is the same situation as
|
||||||
// as no file: nobody has logged in here yet.
|
// no file: nobody has logged in here yet.
|
||||||
.ok_or(UsageState::NotLoggedIn)
|
.ok_or(UsageState::NotLoggedIn)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -225,19 +213,17 @@ impl UsageProvider for ClaudeUsage {
|
|||||||
|
|
||||||
/// Which kind of "no credentials" a failed read was.
|
/// Which kind of "no credentials" a failed read was.
|
||||||
///
|
///
|
||||||
/// The distinction is the point of having both states. `cat` failing
|
/// The distinction is the point of having both states. `cat` failing because
|
||||||
/// because the file is not there is a machine nobody has logged in on --
|
/// the file is not there is a machine nobody has logged in on -- a decision
|
||||||
/// a decision somebody made, with nothing to fix. Anything else is a
|
/// somebody made, with nothing to fix. Anything else is a machine this server
|
||||||
/// machine this server could not ask, which is a fault and reads as one.
|
/// could not ask, which is a fault and reads as one.
|
||||||
///
|
///
|
||||||
/// Matched on the shell's own words rather than an exit status because
|
/// Matched on the shell's own words rather than an exit status because there is
|
||||||
/// there is only one: `cat` exits 1 for a missing file and ssh exits 255
|
/// only one that survives being wrapped in `sh -c` and passed back through ssh.
|
||||||
/// for a connection it could not make, but the message is what survives
|
|
||||||
/// being wrapped in `sh -c` and passed back through ssh.
|
|
||||||
fn why_no_credentials(detail: &str) -> UsageState {
|
fn why_no_credentials(detail: &str) -> UsageState {
|
||||||
// "No such file or directory" is GNU and BSD coreutils; busybox says
|
// "No such file or directory" is GNU and BSD coreutils; busybox says "can't
|
||||||
// "can't open". Anything unrecognised is treated as unreachable,
|
// open". Anything unrecognised is treated as unreachable, which is the
|
||||||
// which is the answer that gets looked at rather than ignored.
|
// answer that gets looked at rather than ignored.
|
||||||
let missing = ["No such file", "no such file", "can't open", "cannot open"];
|
let missing = ["No such file", "no such file", "can't open", "cannot open"];
|
||||||
if missing.iter().any(|phrase| detail.contains(phrase)) {
|
if missing.iter().any(|phrase| detail.contains(phrase)) {
|
||||||
UsageState::NotLoggedIn
|
UsageState::NotLoggedIn
|
||||||
@@ -248,10 +234,9 @@ fn why_no_credentials(detail: &str) -> UsageState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pulls the `limits` array apart, defensively: entries with no percent
|
/// Pulls the `limits` array apart, defensively: entries with no percent are
|
||||||
/// are skipped, unknown kinds keep their raw name as the label rather
|
/// skipped, and unknown kinds keep their raw name as the label rather than
|
||||||
/// than being dropped -- a new window appearing should show up, not
|
/// being dropped -- a new window appearing should show up, not vanish.
|
||||||
/// vanish.
|
|
||||||
fn parse_windows(body: &Value) -> Vec<UsageWindow> {
|
fn parse_windows(body: &Value) -> Vec<UsageWindow> {
|
||||||
let Some(limits) = body.get("limits").and_then(Value::as_array) else {
|
let Some(limits) = body.get("limits").and_then(Value::as_array) else {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
@@ -295,10 +280,10 @@ fn parse_windows(body: &Value) -> Vec<UsageWindow> {
|
|||||||
|
|
||||||
/// Which paid services a machine can be asked about.
|
/// Which paid services a machine can be asked about.
|
||||||
///
|
///
|
||||||
/// Derived from what the setup says it can run, so a machine with no
|
/// Derived from what the setup says it can run, so a machine with no Claude
|
||||||
/// Claude provider is not asked about Claude limits -- it has none, and a
|
/// provider is not asked about Claude limits -- it has none, and a row saying
|
||||||
/// row saying so would be a fact about nothing. A second service later
|
/// so would be a fact about nothing. A second service later adds a branch here
|
||||||
/// adds a branch here and an impl beside [`ClaudeUsage`], not a screen.
|
/// and an impl beside [`ClaudeUsage`], not a screen.
|
||||||
fn providers_for(setup: &SetupConfig) -> Vec<Box<dyn UsageProvider>> {
|
fn providers_for(setup: &SetupConfig) -> Vec<Box<dyn UsageProvider>> {
|
||||||
let mut found: Vec<Box<dyn UsageProvider>> = Vec::new();
|
let mut found: Vec<Box<dyn UsageProvider>> = Vec::new();
|
||||||
if setup
|
if setup
|
||||||
@@ -315,19 +300,17 @@ fn providers_for(setup: &SetupConfig) -> Vec<Box<dyn UsageProvider>> {
|
|||||||
found
|
found
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The cache in front of whatever machines exist: at most one real fetch
|
|
||||||
/// per machine per service per [`MIN_POLL_INTERVAL`], no matter how often
|
|
||||||
/// the phone asks.
|
|
||||||
///
|
|
||||||
/// One machine's numbers for one service, and when they were fetched.
|
/// One machine's numbers for one service, and when they were fetched.
|
||||||
///
|
///
|
||||||
/// Keyed by the machine and the service rather than by position: the set
|
/// Keyed by the machine and the service rather than by position: the set is not
|
||||||
/// is no longer fixed at startup -- setups are added, renamed and removed
|
/// fixed at startup -- setups are added, renamed and removed from the phone --
|
||||||
/// from the phone -- and a positional cache would hand one machine's
|
/// and a positional cache would hand one machine's numbers to another the
|
||||||
/// numbers to another the moment the list shifted.
|
/// moment the list shifted.
|
||||||
type Cached = HashMap<(String, &'static str), (Instant, UsageSnapshot)>;
|
type Cached = HashMap<(String, &'static str), (Instant, UsageSnapshot)>;
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
|
/// The cache in front of whatever machines exist: at most one real fetch per
|
||||||
|
/// machine per service per [`MIN_POLL_INTERVAL`], however often the phone asks.
|
||||||
pub struct UsageMonitor {
|
pub struct UsageMonitor {
|
||||||
cache: Mutex<Cached>,
|
cache: Mutex<Cached>,
|
||||||
}
|
}
|
||||||
@@ -337,12 +320,12 @@ impl UsageMonitor {
|
|||||||
Self::default()
|
Self::default()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One snapshot per machine that offers a paid service, in the order
|
/// One snapshot per machine that offers a paid service, in the order the
|
||||||
/// the machines are configured.
|
/// machines are configured.
|
||||||
///
|
///
|
||||||
/// Blocking -- call via `spawn_blocking`. Takes the setups rather than
|
/// Blocking -- call via `spawn_blocking`. Takes the setups rather than
|
||||||
/// holding the manager, so this module stays below the session layer
|
/// holding the manager, so this module stays below the session layer rather
|
||||||
/// rather than reaching up into it.
|
/// than reaching up into it.
|
||||||
pub fn snapshots(&self, setups: &[SetupConfig]) -> Vec<UsageSnapshot> {
|
pub fn snapshots(&self, setups: &[SetupConfig]) -> Vec<UsageSnapshot> {
|
||||||
let mut fresh = Vec::new();
|
let mut fresh = Vec::new();
|
||||||
for setup in setups {
|
for setup in setups {
|
||||||
@@ -351,19 +334,17 @@ impl UsageMonitor {
|
|||||||
if let Some((fetched, snapshot)) = self.cache.lock().unwrap().get(&key)
|
if let Some((fetched, snapshot)) = self.cache.lock().unwrap().get(&key)
|
||||||
&& fetched.elapsed() < MIN_POLL_INTERVAL
|
&& fetched.elapsed() < MIN_POLL_INTERVAL
|
||||||
{
|
{
|
||||||
// Cached numbers, but the machine's *name* is read
|
// Cached numbers, but the machine's *name* is read fresh: a
|
||||||
// fresh: a rename should show immediately rather than
|
// rename should show immediately rather than waiting out a
|
||||||
// waiting out the poll interval it has nothing to do
|
// poll interval it has nothing to do with.
|
||||||
// with.
|
|
||||||
let mut snapshot = snapshot.clone();
|
let mut snapshot = snapshot.clone();
|
||||||
snapshot.setup_name = setup.name.clone();
|
snapshot.setup_name = setup.name.clone();
|
||||||
fresh.push(snapshot);
|
fresh.push(snapshot);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Fetched without the lock held: this makes a network call
|
// Fetched without the lock held: this makes a network call per
|
||||||
// per machine, and holding the cache across them would
|
// machine, and holding the cache across them would serialise
|
||||||
// serialise every phone asking for the screen behind the
|
// every phone asking for the screen behind the slowest ssh.
|
||||||
// slowest ssh connection.
|
|
||||||
let snapshot = provider.fetch();
|
let snapshot = provider.fetch();
|
||||||
self.cache
|
self.cache
|
||||||
.lock()
|
.lock()
|
||||||
@@ -412,8 +393,8 @@ mod tests {
|
|||||||
assert_eq!(windows[3].resets_at, None);
|
assert_eq!(windows[3].resets_at, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A setup naming a machine that cannot be dialled, so nothing here
|
/// A setup naming a machine that cannot be dialled, so nothing here touches
|
||||||
/// touches the network beyond ssh failing to resolve it.
|
/// the network beyond ssh failing to resolve it.
|
||||||
fn unreachable_setup() -> SetupConfig {
|
fn unreachable_setup() -> SetupConfig {
|
||||||
SetupConfig {
|
SetupConfig {
|
||||||
id: "far".to_string(),
|
id: "far".to_string(),
|
||||||
@@ -442,9 +423,9 @@ mod tests {
|
|||||||
transport: Transport::for_setup(&unreachable_setup()),
|
transport: Transport::for_setup(&unreachable_setup()),
|
||||||
};
|
};
|
||||||
let snapshot = provider.fetch();
|
let snapshot = provider.fetch();
|
||||||
// The distinction the old single `error` string could not make:
|
// The distinction the old single `error` string could not make: this
|
||||||
// this machine was never reached, which is not the same as a
|
// machine was never reached, which is not the same as a machine that
|
||||||
// machine that answered and has nobody logged in.
|
// answered and has nobody logged in.
|
||||||
assert!(
|
assert!(
|
||||||
matches!(snapshot.state, UsageState::Unreachable { .. }),
|
matches!(snapshot.state, UsageState::Unreachable { .. }),
|
||||||
"{:?}",
|
"{:?}",
|
||||||
@@ -457,8 +438,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_missing_credential_file_is_a_choice_and_anything_else_is_a_fault() {
|
fn a_missing_credential_file_is_a_choice_and_anything_else_is_a_fault() {
|
||||||
// What a real shell says when nobody has logged in on that
|
// What a real shell says when nobody has logged in on that machine.
|
||||||
// machine. Nothing to fix, so it must not read as an error.
|
// Nothing to fix, so it must not read as an error.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
why_no_credentials("cat: /home/x/.claude/.credentials.json: No such file or directory"),
|
why_no_credentials("cat: /home/x/.claude/.credentials.json: No such file or directory"),
|
||||||
UsageState::NotLoggedIn
|
UsageState::NotLoggedIn
|
||||||
@@ -468,16 +449,16 @@ mod tests {
|
|||||||
UsageState::NotLoggedIn
|
UsageState::NotLoggedIn
|
||||||
);
|
);
|
||||||
|
|
||||||
// What ssh says when the machine is not there. Worth chasing, and
|
// What ssh says when the machine is not there. Worth chasing, and the
|
||||||
// the detail is carried so somebody can.
|
// detail is carried so somebody can.
|
||||||
let refused = why_no_credentials("ssh: connect to host vm port 22: Connection refused");
|
let refused = why_no_credentials("ssh: connect to host vm port 22: Connection refused");
|
||||||
assert!(
|
assert!(
|
||||||
matches!(&refused, UsageState::Unreachable { detail } if detail.contains("refused")),
|
matches!(&refused, UsageState::Unreachable { detail } if detail.contains("refused")),
|
||||||
"{refused:?}"
|
"{refused:?}"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Anything unrecognised errs towards the state that gets looked
|
// Anything unrecognised errs towards the state that gets looked at,
|
||||||
// at, rather than silently claiming nobody is logged in.
|
// rather than silently claiming nobody is logged in.
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
why_no_credentials("something nobody has seen before"),
|
why_no_credentials("something nobody has seen before"),
|
||||||
UsageState::Unreachable { .. }
|
UsageState::Unreachable { .. }
|
||||||
|
|||||||
Reference in new issue
Block a user