Condense the documentation and thin the server's comments
The markdown had accumulated a lot that was stale rather than wrong. PLAN.md still described pi as the llama.cpp harness, a refcounted LlamaServerManager, and a providers-by-hosts cross-product, all of which were superseded or never built; it also carried a second copy of the HTTP table that routes.rs owns. EXPLORER.md and TRANSCRIPT_CACHE.md held implementation checklists for work that has since landed. AGENTS.md restated most of PLAN.md's design instead of being the working-notes layer it says it is. 3225 lines of markdown to 2180, with the stale sections gone rather than reworded. On the server, comments explaining what the code already says are out and the ones recording a constraint, a measurement or an incident are kept but cut to a few lines each: 5504 comment lines to 4586. Four doc comments in session/mod.rs, and one each in process.rs and usage.rs, had drifted onto the item above the one they describe -- functions were reordered without them, so `stop_session`'s doc sat on `set_session_cwd`, `stat_of`'s on `struct Stat`, and `UsageMonitor`'s on `type Cached`. Each is back on its own item. routes.rs's module table also claimed later phases would add `/hosts`, which setups replaced. cargo test (127 passed), clippy --all-targets and fmt are clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e3e02d55f7
commit
79682f03a7
24 files changed
+4572
-6821
No files matched your search
+8
-10
@@ -1,17 +1,15 @@
|
||||
//! Bearer-token auth for the entire HTTP surface.
|
||||
//!
|
||||
//! This server's API *is* remote code execution, so the token gates every
|
||||
//! route with zero unauthenticated endpoints -- the middleware is applied
|
||||
//! once around the whole router (including the fallback) in `main.rs`,
|
||||
//! never per-route, so a new route can't forget it. See PLAN.md's security
|
||||
//! section for the threat model; the short version is that the token gates
|
||||
//! LAN/tunnel-reachable RCE and is rotatable, and WireGuard makes it
|
||||
//! defense in depth rather than the sole gate.
|
||||
//! This server's API *is* remote code execution, so the token gates every route
|
||||
//! with zero unauthenticated endpoints -- the middleware is applied once around
|
||||
//! the whole router (including the fallback) in `main.rs`, never per-route, so a
|
||||
//! new route can't forget it. See PLAN.md's security section for the threat
|
||||
//! model.
|
||||
//!
|
||||
//! Nothing in this module -- and nothing anywhere else -- may log the
|
||||
//! Authorization header or the token; the test below holds a tripwire
|
||||
//! against a logging change silently starting to. It is one test covering
|
||||
//! both gating and logging on purpose -- see the note in it.
|
||||
//! Authorization header or the token; the test below is a tripwire against a
|
||||
//! logging change silently starting to. It is one test covering both gating and
|
||||
//! logging on purpose -- see the note in it.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
|
||||
+114
-171
@@ -1,20 +1,17 @@
|
||||
//! The server's persistent state: the enrolled token hashes and the
|
||||
//! sessions that exist.
|
||||
//! The server's persistent state: the enrolled token hashes and the sessions
|
||||
//! that exist.
|
||||
//!
|
||||
//! Written whole and atomically (temp file + rename) rather than appended
|
||||
//! to: it is small, and a half-written config would take the server down on
|
||||
//! next start with no obvious way to recover from a phone. Every mutation
|
||||
//! funnels through `SessionManager` (the registry pattern), so in-memory
|
||||
//! and on-disk state can't come apart.
|
||||
//! Written whole and atomically (temp file + rename) rather than appended to:
|
||||
//! it is small, and a half-written config would take the server down on next
|
||||
//! start with no obvious way to recover from a phone. Every mutation funnels
|
||||
//! through `SessionManager`, so in-memory and on-disk state can't come apart.
|
||||
//!
|
||||
//! The file is RON, in the shape [`wg_app_link::format`] describes -- the
|
||||
//! same format, and the same two house rules, as the sibling dev-updater
|
||||
//! project's config, because both are written and read by hand, and both
|
||||
//! now read and write them through the one module.
|
||||
//! The file is RON, in the shape [`wg_app_link::format`] describes -- the same
|
||||
//! two house rules as dev-updater's config, because both are read and written
|
||||
//! by hand.
|
||||
//!
|
||||
//! Transcripts do NOT live here -- each session's events are an append-only
|
||||
//! JSONL file in its own directory (see `session::transcript`); this file
|
||||
//! holds only the metadata needed to list and respawn sessions.
|
||||
//! Transcripts do NOT live here: each session's events are an append-only JSONL
|
||||
//! file in its own directory.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -27,24 +24,20 @@ use wg_app_link::format;
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct Config {
|
||||
/// Enrolled device tokens, hashes only -- a leaked config doesn't leak
|
||||
/// the credential. A list (of one, today) so per-device tokens with
|
||||
/// individual revocation are a config entry later, not a migration.
|
||||
/// Enrolled device tokens, hashes only -- a leaked config doesn't leak the
|
||||
/// credential. A list (of one, today) so per-device tokens with individual
|
||||
/// revocation are a config entry later, not a migration.
|
||||
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 sessions: Vec<SessionConfig>,
|
||||
}
|
||||
|
||||
/// A machine, and the things it can run.
|
||||
///
|
||||
/// This is the unit a session is spawned against: pick a setup, then one
|
||||
/// of its providers. Grouping them this way is what stops the spawn
|
||||
/// screen offering combinations that cannot work -- a provider only
|
||||
/// exists on a machine where that program is installed, and the previous
|
||||
/// model, which let any provider be paired with any host, offered the
|
||||
/// whole cross-product including the impossible parts of it.
|
||||
/// This is the unit a session is spawned against. Grouping providers under the
|
||||
/// machine they exist on is what stops the spawn screen offering combinations
|
||||
/// that cannot work; the previous model let any provider be paired with any
|
||||
/// host and offered the whole cross-product.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SetupConfig {
|
||||
@@ -53,15 +46,12 @@ pub struct SetupConfig {
|
||||
/// renaming a machine on the phone does not orphan its sessions --
|
||||
/// which is the whole reason the two are separate fields.
|
||||
pub id: String,
|
||||
/// The label a person reads and may edit.
|
||||
pub name: String,
|
||||
/// How to reach it, absent for this machine. A setup with no `ssh` is
|
||||
/// where the server itself runs.
|
||||
/// How to reach it, absent for this machine.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub ssh: Option<SshConfig>,
|
||||
/// 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
|
||||
/// point.
|
||||
/// within it: two machines may each have a `claude-cli`, which is the point.
|
||||
#[serde(default)]
|
||||
pub providers: Vec<ProviderConfig>,
|
||||
}
|
||||
@@ -88,12 +78,10 @@ pub struct ProviderConfig {
|
||||
pub models: Vec<String>,
|
||||
}
|
||||
|
||||
/// How to reach a setup that isn't this machine, with the system `ssh`
|
||||
/// client -- so `~/.ssh/config`, agents, and jump hosts all keep working,
|
||||
/// and there is one place to configure connections (PLAN.md, rule 23).
|
||||
///
|
||||
/// A remote session is the identical command with `ssh host …` in front,
|
||||
/// and nothing downstream of the spawn knows the difference.
|
||||
/// How to reach a setup that isn't this machine, with the system `ssh` client
|
||||
/// -- so `~/.ssh/config`, agents and jump hosts all keep working, and there is
|
||||
/// one place to configure connections. A remote session is the identical
|
||||
/// command with `ssh host …` in front, and nothing downstream knows.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SshConfig {
|
||||
@@ -107,59 +95,49 @@ pub struct SshConfig {
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub options: Vec<String>,
|
||||
/// 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
|
||||
/// directory, or the login home for a session that has none. A `~`
|
||||
/// prefix is the remote home.
|
||||
/// session can read it. Absent means the session's own working directory,
|
||||
/// or the login home for a session that has none. A `~` prefix is the
|
||||
/// remote home.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub attachments_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// Which translator runs a session. A new one is a new driver behind the
|
||||
/// same trait -- never a branch in shared code.
|
||||
/// Which translator runs a session. A new one is a new driver behind the same
|
||||
/// trait -- never a branch in shared code.
|
||||
///
|
||||
/// Snake case, which is both Rust's and RON's: this is written into a
|
||||
/// config a person edits by hand, and a hyphen is not a RON identifier, so
|
||||
/// kebab case cost the file a `kind: r#claude-cli` escape to say a name
|
||||
/// nobody would type that way. The same string is what the phone compares
|
||||
/// against (`SpawnScreen.kt`), so the two move together.
|
||||
/// Snake case, which is both Rust's and RON's: this is written into a config a
|
||||
/// person edits by hand, and a hyphen is not a RON identifier, so kebab case
|
||||
/// cost the file a `kind: r#claude-cli` escape. The same string is what the
|
||||
/// phone compares against, so the two move together.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DriverKind {
|
||||
/// The phase-1 fake: echoes messages back as streamed events. Proves
|
||||
/// the pipe (spawn, SSE, transcript cursors, questions) with no AI
|
||||
/// involved, and stays useful as a connectivity check that costs no
|
||||
/// tokens. Always available as a built-in provider.
|
||||
/// The fake driver: echoes messages back as streamed events, proving the
|
||||
/// pipe with no AI involved. Always available as a built-in provider.
|
||||
Echo,
|
||||
/// A GGUF model served by llama.cpp's `llama-server` (see
|
||||
/// `session::llama`). The model itself is one this machine has
|
||||
/// downloaded; the provider's command is the server binary.
|
||||
/// A GGUF model served by llama.cpp's `llama-server`. The model is one this
|
||||
/// machine has downloaded; the provider's command is the server binary.
|
||||
LlamaCpp,
|
||||
/// The Claude Code CLI over stream-json (see `session::claude`).
|
||||
/// Named for the CLI specifically: bare "claude" would suggest the
|
||||
/// credit-billed API, which this is not.
|
||||
/// The Claude Code CLI over stream-json. Named for the CLI specifically:
|
||||
/// bare "claude" would suggest the credit-billed API, which this is not.
|
||||
ClaudeCli,
|
||||
}
|
||||
|
||||
impl DriverKind {
|
||||
/// The longest edge, in pixels, an image should have when it reaches
|
||||
/// this kind of session -- `None` where nothing here has a limit worth
|
||||
/// enforcing.
|
||||
/// The longest edge, in pixels, an image should have when it reaches this
|
||||
/// kind of session -- `None` where nothing here has a limit worth enforcing.
|
||||
///
|
||||
/// 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
|
||||
/// photo is several megabytes and twelve megapixels, and every one of
|
||||
/// those bytes was being uploaded over WireGuard only to be rejected at
|
||||
/// the other end. What decides the number is the provider, which is why
|
||||
/// it lives beside the kind rather than in the app -- a phone that knew
|
||||
/// each provider's limits would be a second place to update when one
|
||||
/// changes.
|
||||
/// small before they cross the tunnel instead of after: a modern phone photo
|
||||
/// is several megabytes, and every one of them was being uploaded over
|
||||
/// WireGuard only to be rejected at the other end. What decides the number
|
||||
/// is the provider, which is why it lives beside the kind rather than in the
|
||||
/// app.
|
||||
///
|
||||
/// 1568 for the Claude CLI because that is the longest edge the API
|
||||
/// itself resizes to; anything larger is charged the same and spends the
|
||||
/// upload for nothing, and far larger is refused outright, which is what
|
||||
/// "sending an image is broken" turned out to be. The others take images
|
||||
/// through no path that cares, so they get no limit rather than a made-up
|
||||
/// one.
|
||||
/// 1568 for the Claude CLI because that is the longest edge the API itself
|
||||
/// resizes to; anything larger is charged the same and spends the upload for
|
||||
/// nothing, and far larger is refused outright -- which is what "sending an
|
||||
/// image is broken" turned out to be.
|
||||
pub fn max_image_edge(self) -> Option<u32> {
|
||||
match self {
|
||||
DriverKind::ClaudeCli => Some(1568),
|
||||
@@ -167,21 +145,17 @@ impl DriverKind {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the conversation exists outside this app, so that deleting
|
||||
/// the session here does not end it.
|
||||
/// Whether the conversation exists outside this app, so that deleting the
|
||||
/// session here does not end it.
|
||||
///
|
||||
/// The Claude Code CLI owns its own transcript under
|
||||
/// `~/.claude/projects/` and is resumable from it whatever started
|
||||
/// it -- so a session this app spawned is every bit as recoverable as
|
||||
/// one it imported, and the difference between those two is only how
|
||||
/// 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.
|
||||
/// The Claude Code CLI owns its own transcript and is resumable from it
|
||||
/// whatever started it, so a session this app spawned is every bit as
|
||||
/// recoverable as one it imported. Echo has nothing to keep, and a llama
|
||||
/// session's conversation is folded out of *this* app's transcript.
|
||||
///
|
||||
/// Asked before warning somebody that a deletion cannot be undone,
|
||||
/// which is the one sentence that has to be true: said of a session
|
||||
/// that can in fact be brought back, it spends the credibility the
|
||||
/// warning needs on the sessions where it is real.
|
||||
/// Asked before warning somebody that a deletion cannot be undone, which is
|
||||
/// the one sentence that has to be true: said of a session that can in fact
|
||||
/// be brought back, it spends the credibility the warning needs.
|
||||
pub fn keeps_own_transcript(self) -> bool {
|
||||
match self {
|
||||
Self::ClaudeCli => true,
|
||||
@@ -193,11 +167,9 @@ impl DriverKind {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TokenEntry {
|
||||
/// Which device this token belongs to, for the human rotating it.
|
||||
pub name: String,
|
||||
/// Hex SHA-256 of the token. A plain hash is enough: the token is 256
|
||||
/// bits from the OS CSPRNG, so there is nothing to dictionary-attack
|
||||
/// and no stretching needed.
|
||||
/// Hex SHA-256 of the token. A plain hash is enough: the token is 256 bits
|
||||
/// from the OS CSPRNG, so there is nothing to dictionary-attack.
|
||||
pub sha256: String,
|
||||
}
|
||||
|
||||
@@ -206,73 +178,56 @@ pub struct TokenEntry {
|
||||
pub struct SessionConfig {
|
||||
/// Stable identifier; names the session's directory and its routes.
|
||||
pub id: String,
|
||||
/// Id of the [`SetupConfig`] this session runs on -- the id, not the
|
||||
/// label, so the machine can be renamed without losing its sessions.
|
||||
/// Id of the [`SetupConfig`] this session runs on -- the id, not the label,
|
||||
/// so the machine can be renamed without losing its sessions.
|
||||
pub setup: String,
|
||||
/// Name of the provider within that setup. Both stored by name rather
|
||||
/// than resolved, so an edited setup (a new command path, another
|
||||
/// model) takes effect on the next relaunch; a session whose setup or
|
||||
/// provider is gone reports as exited and can still be deleted.
|
||||
/// Name of the provider within that setup. Both stored by name rather than
|
||||
/// resolved, so an edited setup takes effect on the next relaunch; a session
|
||||
/// whose setup or provider is gone reports as exited and can still be
|
||||
/// deleted.
|
||||
pub provider: String,
|
||||
pub title: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
/// Working directory the session's process runs in.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cwd: Option<PathBuf>,
|
||||
/// Claude permission mode chosen at spawn. Meaningless for other
|
||||
/// kinds, and kept as a string because it is passed straight to the
|
||||
/// CLI's `--permission-mode` rather than interpreted here -- so the
|
||||
/// CLI stays the one authority on which modes exist, and a new one
|
||||
/// needs no change on this side.
|
||||
/// Claude permission mode chosen at spawn. Kept as a string because it is
|
||||
/// passed straight to `--permission-mode` rather than interpreted here, so
|
||||
/// the CLI stays the one authority on which modes exist.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub permission_mode: Option<String>,
|
||||
/// Settings the driver interprets, chosen at spawn.
|
||||
///
|
||||
/// Deliberately untyped here: what a temperature or a context size
|
||||
/// means is the driver's business, and giving this schema a field per
|
||||
/// driver is how a shared model starts carrying one dialect's
|
||||
/// vocabulary. `permission_mode` above predates this and should fold
|
||||
/// 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.
|
||||
/// Deliberately untyped: what a temperature or a context size means is the
|
||||
/// driver's business, and a field per driver is how a shared model starts
|
||||
/// carrying one dialect's vocabulary. `permission_mode` above predates this
|
||||
/// and should fold into it. BTreeMap so the file's order is stable.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub params: BTreeMap<String, String>,
|
||||
/// 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
|
||||
/// session: one that runs unattended overnight should be quiet on
|
||||
/// every device, and answering that question again on each new phone
|
||||
/// is how two devices come to disagree about which sessions matter.
|
||||
/// session: one that runs unattended overnight should be quiet on every
|
||||
/// device.
|
||||
///
|
||||
/// Defaults to on, and on for a config written before this field
|
||||
/// existed. The alternative -- silent unless asked -- makes the
|
||||
/// feature invisible to anyone who does not go looking for it, and a
|
||||
/// notification nobody wanted is turned off in one tap where one that
|
||||
/// never arrived is not diagnosable at all.
|
||||
/// Defaults to on. Silent-unless-asked makes the feature invisible to
|
||||
/// anyone who does not go looking, and a notification nobody wanted is
|
||||
/// turned off in one tap where one that never arrived is not diagnosable.
|
||||
#[serde(default = "notify_default")]
|
||||
pub notify: bool,
|
||||
/// Whether this session's process is stopped when the server exits,
|
||||
/// instead of being left running for the next start to adopt.
|
||||
/// Whether this session's process is stopped when the server exits, instead
|
||||
/// of being left running for the next start to adopt.
|
||||
///
|
||||
/// A fact about the session rather than about the run that spawned it,
|
||||
/// which is why it is persisted: whichever server is running when the
|
||||
/// time comes is the one that has to act on it, and a session nobody
|
||||
/// meant to keep should not depend on the same server still being up
|
||||
/// to clean it away.
|
||||
/// A fact about the session rather than about the run that spawned it, which
|
||||
/// is why it is persisted: whichever server is running when the time comes
|
||||
/// is the one that has to act on it.
|
||||
///
|
||||
/// Written by a server started with `--throwaway-sessions`, which is
|
||||
/// the default in a debug build. A session spawned while testing is
|
||||
/// one nobody means to keep, and under the ordinary rule its `claude`
|
||||
/// outlives every server that ever knew about it -- twelve of them
|
||||
/// 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.
|
||||
/// Written by a server started with `--throwaway-sessions`, the default in a
|
||||
/// debug build. Under the ordinary rule a test session's `claude` outlives
|
||||
/// every server that ever knew about it -- twelve accumulated on this
|
||||
/// machine in a day. Absent means false.
|
||||
#[serde(default, skip_serializing_if = "not_set")]
|
||||
pub throwaway: bool,
|
||||
/// Epoch seconds when the session was spawned.
|
||||
pub created: f64,
|
||||
}
|
||||
|
||||
@@ -281,29 +236,25 @@ fn notify_default() -> bool {
|
||||
}
|
||||
|
||||
/// Keeps the ordinary case out of the file entirely -- see
|
||||
/// [`SessionConfig::throwaway`], which is false for every session a
|
||||
/// production build writes.
|
||||
/// [`SessionConfig::throwaway`].
|
||||
fn not_set(flag: &bool) -> bool {
|
||||
!*flag
|
||||
}
|
||||
|
||||
/// The name of the echo provider, and of the setup this machine gets on
|
||||
/// first run.
|
||||
/// The name of the echo provider, and of the setup this machine gets on first
|
||||
/// run.
|
||||
///
|
||||
/// Echo is seeded into the config rather than conjured at read time the
|
||||
/// way it used to be. An implicit provider is one a person cannot see in
|
||||
/// the file or edit from the phone, and the point of this app is that
|
||||
/// configuration is visible and editable; if somebody deletes it, that was
|
||||
/// a choice.
|
||||
/// Echo is seeded into the config rather than conjured at read time. An
|
||||
/// implicit provider is one a person cannot see in the file or edit from the
|
||||
/// phone; if somebody deletes it, that was a choice.
|
||||
pub const ECHO_PROVIDER: &str = "echo";
|
||||
pub const LOCAL_SETUP: &str = "this machine";
|
||||
/// The id of the setup a fresh install seeds. Fixed rather than random so
|
||||
/// a hand-written config can name it without looking one up.
|
||||
/// The id of the setup a fresh install seeds. Fixed rather than random so a
|
||||
/// hand-written config can name it without looking one up.
|
||||
pub const LOCAL_SETUP_ID: &str = "local";
|
||||
|
||||
/// Where `ai-server --enroll-link` leaves a token for the running server
|
||||
/// to adopt: beside the config, since it is config in transit. See
|
||||
/// `wg_app_link::enroll::spool_pending`.
|
||||
/// Where `ai-server --enroll-link` leaves a token for the running server to
|
||||
/// adopt: beside the config, since it is config in transit.
|
||||
pub fn pending_enrollments_dir(config_path: &Path) -> PathBuf {
|
||||
config_path.with_file_name("pending-enrollments")
|
||||
}
|
||||
@@ -313,22 +264,19 @@ impl Config {
|
||||
self.setups.iter().find(|setup| setup.id == id)
|
||||
}
|
||||
|
||||
/// A setup by the label a person sees, for messages and for the one
|
||||
/// place a name still arrives from outside: nothing else should look
|
||||
/// one up this way, since labels are editable and ids are not.
|
||||
/// A setup by the label a person sees, for messages and for the one place a
|
||||
/// name still arrives from outside. Nothing else should look one up this
|
||||
/// way, since labels are editable and ids are not.
|
||||
pub fn setup_named(&self, name: &str) -> Option<&SetupConfig> {
|
||||
self.setups.iter().find(|setup| setup.name == name)
|
||||
}
|
||||
|
||||
/// This machine, offering whatever was found on it.
|
||||
///
|
||||
/// The providers are passed in rather than written here because they
|
||||
/// have to be *discovered*: a hardcoded list is a claim about what is
|
||||
/// installed, and this one was wrong -- every fresh install asserted a
|
||||
/// `claude-cli` provider whether or not `claude` existed, which on a
|
||||
/// 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.
|
||||
/// The providers are passed in rather than written here because they have to
|
||||
/// be *discovered*: a hardcoded list is a claim about what is installed, and
|
||||
/// this one was wrong -- every fresh install asserted a `claude-cli`
|
||||
/// provider whether or not `claude` existed.
|
||||
pub fn seed(providers: Vec<ProviderConfig>) -> SetupConfig {
|
||||
SetupConfig {
|
||||
id: LOCAL_SETUP_ID.to_string(),
|
||||
@@ -338,12 +286,9 @@ impl Config {
|
||||
}
|
||||
}
|
||||
|
||||
/// The one provider that needs no discovery, and the floor to fall
|
||||
/// back to when discovery itself fails.
|
||||
///
|
||||
/// 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.
|
||||
/// The one provider that needs no discovery, and the floor to fall back to
|
||||
/// when discovery itself fails. Echo runs in-process, so it exists exactly
|
||||
/// where this server does and nowhere else.
|
||||
pub fn echo_provider() -> ProviderConfig {
|
||||
ProviderConfig {
|
||||
name: ECHO_PROVIDER.to_string(),
|
||||
@@ -357,8 +302,8 @@ impl Config {
|
||||
match std::fs::read_to_string(path) {
|
||||
Ok(text) => format::parse(&text)
|
||||
.with_context(|| format!("{} is not valid config RON", path.display())),
|
||||
// A first run has no config -- the normal starting state; a
|
||||
// token is generated and saved on that first start.
|
||||
// A first run has no config -- the normal starting state; a token is
|
||||
// generated and saved on that first start.
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
|
||||
warn_about_a_config_left_behind(path);
|
||||
Ok(Self::default())
|
||||
@@ -369,12 +314,10 @@ impl Config {
|
||||
|
||||
/// Writes the config, owner-readable only.
|
||||
///
|
||||
/// The token hashes here are verifiers, not secrets -- a 256-bit
|
||||
/// 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 running, which is nobody else's business on a shared machine.
|
||||
/// The mode is set on the temporary file *before* the rename, so the
|
||||
/// config is never briefly world-readable at its real path.
|
||||
/// The token hashes here are verifiers rather than secrets, but the file
|
||||
/// also names every host this backend can reach and every session it is
|
||||
/// running. 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<()> {
|
||||
format::write(path, self)
|
||||
}
|
||||
|
||||
+104
-130
@@ -1,71 +1,58 @@
|
||||
//! Reading and changing files on the machine a setup names.
|
||||
//!
|
||||
//! Every operation here is one small POSIX shell script handed to
|
||||
//! `Transport`, exactly the way `setups::discover` and `import::list`
|
||||
//! already ask a machine a question. That is what makes the local and the
|
||||
//! ssh case one implementation: a second one written against `std::fs`
|
||||
//! would be the one that gets tested, and the remote half -- the ordering
|
||||
//! of entries, what a symlink reports, how a permission error reads --
|
||||
//! would drift until it shipped broken. The cost is an `sh` process per
|
||||
//! operation on this machine, which is under a millisecond.
|
||||
//! Every operation here is one small POSIX shell script handed to `Transport`,
|
||||
//! the way `setups::discover` and `import::list` already ask a machine a
|
||||
//! question. That is what makes the local and the ssh case one implementation:
|
||||
//! a second one written against `std::fs` would be the one that gets tested,
|
||||
//! and the remote half -- the ordering of entries, what a symlink reports, how
|
||||
//! a permission error reads -- would drift until it shipped broken. The cost is
|
||||
//! an `sh` process per operation here, which is under a millisecond.
|
||||
//!
|
||||
//! The scripts assume GNU coreutils and findutils (`find -printf`,
|
||||
//! `stat -c`, `sha256sum`, `chmod --reference`), which is what
|
||||
//! `session::import` already assumes and what both machines here run. One
|
||||
//! without them fails with that tool's own message, which names what is
|
||||
//! missing.
|
||||
//! The scripts assume GNU coreutils and findutils, which is what
|
||||
//! `session::import` already assumes. A machine 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
|
||||
//! Security section. The enrolled token already spawns an agent in any
|
||||
//! directory on any machine a setup names, and that agent reads and writes
|
||||
//! every file its user can; this is a shorter path to authority the token
|
||||
//! 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.
|
||||
//! **The phone names a path, and that is deliberate** -- see PLAN.md's Security
|
||||
//! section. 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 serde::Serialize;
|
||||
|
||||
use crate::session::transport::{Input, Launch, Transport};
|
||||
|
||||
/// 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 than a transfer. A file over it is reported
|
||||
/// as [`FileRead::TooBig`] with its size, because "we did not read this"
|
||||
/// and "this is empty" must not look the same on the phone.
|
||||
/// 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
|
||||
/// than a transfer. A file over it is [`FileRead::TooBig`] with its size,
|
||||
/// because "we did not read this" and "this is empty" must not look the same.
|
||||
pub const FILE_LIMIT: u64 = 1024 * 1024;
|
||||
|
||||
/// The prelude every script here starts with: the path arrives as `$1`,
|
||||
/// and this is where a leading `~` becomes that machine's own home.
|
||||
/// The prelude every script here starts with: the path arrives as `$1`, and
|
||||
/// this is where a leading `~` becomes that machine's own home.
|
||||
///
|
||||
/// The path is a **positional argument** and never text spliced into the
|
||||
/// script -- the rule `import::find` follows with `"$1"`, for the reason
|
||||
/// `ssh::quote` exists: a path is attacker-adjacent input in a server
|
||||
/// whose job is running commands, and interpolated it would be syntax
|
||||
/// rather than data.
|
||||
/// The path is a **positional argument** and never text spliced into the script
|
||||
/// -- the rule `import::find` follows, for the reason `ssh::quote` exists: a
|
||||
/// path is attacker-adjacent input in a server whose job is running commands,
|
||||
/// and interpolated it would be syntax rather than data.
|
||||
///
|
||||
/// `~` 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
|
||||
/// literal one; expanding it here, once, gives it the same meaning
|
||||
/// `ssh::quote_path` and `ssh::expand_home` give it everywhere else, and
|
||||
/// it is the *far* machine's `$HOME` -- the only one that could be right.
|
||||
/// `~user` stays literal here too, and fails with the shell's own message.
|
||||
/// tilde in *text*, so a path handed over as an argument arrives with a literal
|
||||
/// one; expanding it here gives it the same meaning `ssh::quote_path` gives it
|
||||
/// everywhere else, and it is the *far* machine's `$HOME`. `~user` stays
|
||||
/// literal and fails with the shell's own message.
|
||||
///
|
||||
/// Everything below uses `$p` for the path and `$2` for whatever else it
|
||||
/// was given.
|
||||
/// Everything below uses `$p` for the path and `$2` for whatever else.
|
||||
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.
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Listing {
|
||||
/// `pwd -P` of the directory that was listed.
|
||||
///
|
||||
/// Answered by the machine rather than worked out here, so the phone
|
||||
/// navigates on a resolved absolute path: the parent of one of these
|
||||
/// is a string operation, and a `~` a session was spawned with is
|
||||
/// shown as what it turned out to be.
|
||||
/// `pwd -P` of the directory that was listed. Answered by the machine
|
||||
/// rather than worked out here, so the phone navigates on a resolved
|
||||
/// absolute path: the parent of one of these is a string operation, and a
|
||||
/// `~` a session was spawned with is shown as what it turned out to be.
|
||||
pub path: String,
|
||||
pub entries: Vec<Entry>,
|
||||
}
|
||||
@@ -74,14 +61,13 @@ pub struct Listing {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Entry {
|
||||
pub name: String,
|
||||
/// What tapping it does, which for a symlink is decided by its
|
||||
/// *target* -- a link to a directory navigates.
|
||||
/// What tapping it does, which for a symlink is decided by its *target* --
|
||||
/// a link to a directory navigates.
|
||||
pub kind: EntryKind,
|
||||
pub size: u64,
|
||||
/// Seconds since the epoch.
|
||||
pub modified: i64,
|
||||
/// Whether the entry itself is a symlink, whatever [`Entry::kind`]
|
||||
/// says its target is.
|
||||
/// Whether the entry itself is a symlink, whatever [`Entry::kind`] says
|
||||
/// its target is.
|
||||
pub link: bool,
|
||||
}
|
||||
|
||||
@@ -90,18 +76,18 @@ pub struct Entry {
|
||||
pub enum EntryKind {
|
||||
Directory,
|
||||
File,
|
||||
/// A socket, a device, a fifo -- and a symlink whose target is missing
|
||||
/// or loops, which `find` reports the same way. Shown, because a
|
||||
/// directory that hid what it held would be lying about being empty.
|
||||
/// A socket, a device, a fifo -- and a symlink whose target is missing or
|
||||
/// loops, which `find` reports the same way. Shown, because a directory
|
||||
/// that hid what it held would be lying about being empty.
|
||||
Other,
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// wrong in ways the reader cannot see, and "couldn't read it" must not
|
||||
/// look like "it is empty". A file that is genuinely empty is
|
||||
/// [`FileRead::Text`] with nothing in it, which is what it is.
|
||||
/// A binary file drawn as text and a big file cut off silently are both wrong
|
||||
/// in ways the reader cannot see, and "couldn't read it" must not look like
|
||||
/// "it is empty". A genuinely empty file is [`FileRead::Text`] with nothing in
|
||||
/// it, which is what it is.
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
pub enum FileRead {
|
||||
@@ -114,8 +100,8 @@ pub enum FileRead {
|
||||
},
|
||||
/// Not UTF-8. Its size is reported; nothing is shown.
|
||||
Binary { size: u64, modified: i64 },
|
||||
/// Over [`FILE_LIMIT`]. Its size is reported, so the reader knows what
|
||||
/// they are looking at rather than only that they cannot have it.
|
||||
/// Over [`FILE_LIMIT`]. Its size is reported, so the reader knows what they
|
||||
/// are looking at rather than only that they cannot have it.
|
||||
TooBig { size: u64, modified: i64 },
|
||||
}
|
||||
|
||||
@@ -129,18 +115,16 @@ pub struct Written {
|
||||
pub sha256: String,
|
||||
}
|
||||
|
||||
/// The exit code the write script uses for "this is not the file you
|
||||
/// read", which the route turns into a 409. Distinct from every other
|
||||
/// failure, which is a message from the machine.
|
||||
/// The exit code the write script uses for "this is not the file you read",
|
||||
/// which the route turns into a 409. Distinct from every other failure, which
|
||||
/// is a message from the machine.
|
||||
pub const STALE: i32 = 3;
|
||||
|
||||
/// 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
|
||||
/// the same reason -- a relative path is relative to something nobody
|
||||
/// looking at the screen can see, so it is refused rather than resolved
|
||||
/// against a guess. Returns the path with the whitespace a phone keyboard
|
||||
/// adds taken off.
|
||||
/// Shared with `POST /sessions/{id}/cwd`, which asks the same question for the
|
||||
/// same reason -- a relative path is relative to something nobody looking at
|
||||
/// the screen can see, so it is refused rather than resolved against a guess.
|
||||
pub fn check_path(path: &str) -> Result<String> {
|
||||
let path = path.trim();
|
||||
if path.is_empty() {
|
||||
@@ -160,8 +144,8 @@ fn launch(script: String, path: &str, extra: Option<&str>) -> Launch {
|
||||
let mut args = vec![
|
||||
"-c".to_string(),
|
||||
script,
|
||||
// `$0`, which is what `sh` names itself in a message about the
|
||||
// script; the path is `$1`.
|
||||
// `$0`, which is what `sh` names itself in a message about the script;
|
||||
// the path is `$1`.
|
||||
"sh".to_string(),
|
||||
path.to_string(),
|
||||
];
|
||||
@@ -169,11 +153,10 @@ fn launch(script: String, path: &str, extra: Option<&str>) -> Launch {
|
||||
Launch::new("sh", args, None)
|
||||
}
|
||||
|
||||
/// 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 survives -- both are legal, and a listing
|
||||
/// that lost one would quietly show the wrong thing.
|
||||
/// 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
|
||||
/// survives -- both are legal, and a listing that lost one would quietly show
|
||||
/// the wrong thing.
|
||||
pub async fn list(transport: &Transport, path: &str) -> Result<Listing> {
|
||||
let script = format!(
|
||||
"{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.
|
||||
///
|
||||
/// A record that does not have all five fields is dropped rather than
|
||||
/// guessed at: it can only come from a `find` that printed something else,
|
||||
/// and half a row is worse than no row.
|
||||
/// 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
|
||||
/// something else, and half a row is worse than no row.
|
||||
fn parse_entries(text: &str) -> Vec<Entry> {
|
||||
text.split('\0')
|
||||
.filter(|record| !record.is_empty())
|
||||
@@ -208,8 +189,7 @@ fn parse_entries(text: &str) -> Vec<Entry> {
|
||||
let own = fields.next()?;
|
||||
let target = fields.next()?;
|
||||
let size = fields.next()?.parse().ok()?;
|
||||
// `%T@` is seconds with a fractional part; the phone shows a
|
||||
// date, so the fraction is dropped rather than carried.
|
||||
// `%T@` is seconds with a fractional part; the phone shows a date.
|
||||
let modified = fields.next()?.split('.').next()?.parse().ok()?;
|
||||
let name = fields.next()?;
|
||||
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.
|
||||
///
|
||||
/// The size is checked on the far machine *before* anything reads the
|
||||
/// file, so a file over [`FILE_LIMIT`] costs a `stat` rather than a
|
||||
/// transfer. `stat -L` and `sha256sum` both follow symlinks, as `cat`
|
||||
/// does, so a link to a file reports the file.
|
||||
/// The size is checked on the far machine *before* anything reads the file, so
|
||||
/// a file over [`FILE_LIMIT`] costs a `stat` rather than a transfer. `stat -L`
|
||||
/// and `sha256sum` both follow symlinks, as `cat` does.
|
||||
pub async fn read(transport: &Transport, path: &str) -> Result<FileRead> {
|
||||
// Two header lines, then the bytes: `<size> <mtime>`, then either
|
||||
// `tooBig` or the digest. A header rather than a JSON envelope because
|
||||
// the content is bytes and may not be text at all.
|
||||
// Two header lines, then the bytes: `<size> <mtime>`, then either `tooBig`
|
||||
// or the digest. A header rather than a JSON envelope because the content
|
||||
// is bytes and may not be text at all.
|
||||
let script = format!(
|
||||
"{PATH_PRELUDE}set -e; \
|
||||
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
|
||||
/// `expected`.
|
||||
/// Replaces `path`'s contents, but only while it still hashes to `expected`.
|
||||
///
|
||||
/// Agents edit files while people read them, so a stale copy landing on
|
||||
/// top of somebody else's edit is the common case rather than the exotic
|
||||
/// one. The digest the reader was shown is compared on the machine, and a
|
||||
/// file that has moved on comes back as [`STALE`] rather than being
|
||||
/// overwritten.
|
||||
/// Agents edit files while people read them, so a stale copy landing on top of
|
||||
/// somebody else's edit is the common case rather than the exotic one. The
|
||||
/// digest the reader was shown is compared on the machine, and a file that has
|
||||
/// moved on comes back as [`STALE`] rather than being overwritten.
|
||||
///
|
||||
/// A temp file and a rename, so a connection dropped mid-write leaves the
|
||||
/// old file whole rather than a truncated one, and `chmod --reference` so
|
||||
/// the mode survives -- an executable script written as a fresh file would
|
||||
/// stop being one. What that trades away: the inode changes, so a hard
|
||||
/// link elsewhere stops being the same file. Editors do the same.
|
||||
/// A temp file and a rename, so a connection dropped mid-write leaves the old
|
||||
/// file whole, and `chmod --reference` so the mode survives -- an executable
|
||||
/// script written as a fresh file would stop being one. What that trades away:
|
||||
/// the inode changes, so a hard link elsewhere stops being the same file.
|
||||
///
|
||||
/// The check and the write are **not** atomic against a writer landing
|
||||
/// between them -- a window of microseconds on that machine. Accepted: the
|
||||
/// alternative is a lock this has no way to make every other writer take.
|
||||
/// The check and the write are **not** atomic against a writer landing between
|
||||
/// them -- a window of microseconds on that machine. Accepted: the alternative
|
||||
/// is a lock this has no way to make every other writer take.
|
||||
pub async fn write(
|
||||
transport: &Transport,
|
||||
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
|
||||
/// error string, because the route answers it with a different status and
|
||||
/// the phone with a different question.
|
||||
/// The file is not the one that was read. Its own type rather than an error
|
||||
/// string, because the route answers it with a different status and the phone
|
||||
/// with a different question.
|
||||
#[derive(Debug)]
|
||||
pub struct Stale;
|
||||
|
||||
/// 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 shell's own message rather than with a check that could race the
|
||||
/// redirection it is guarding.
|
||||
/// `set -C` is the shell's own noclobber, so an existing name fails with the
|
||||
/// shell's own message rather than with a check that could race the redirection
|
||||
/// it is guarding.
|
||||
pub async fn create_file(transport: &Transport, path: &str) -> Result<()> {
|
||||
let script = format!("{PATH_PRELUDE}set -C; : > \"$p\"");
|
||||
transport
|
||||
@@ -354,9 +329,9 @@ pub async fn create_file(transport: &Transport, path: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Creates a directory. Plain `mkdir`, not `-p`, for the same reason
|
||||
/// [`create_file`] sets noclobber: a name that exists is something the
|
||||
/// person typing it should be told about.
|
||||
/// Creates a directory. Plain `mkdir`, not `-p`, for the reason
|
||||
/// [`create_file`] sets noclobber: a name that exists is something the person
|
||||
/// typing it should be told about.
|
||||
pub async fn create_dir(transport: &Transport, path: &str) -> Result<()> {
|
||||
let script = format!("{PATH_PRELUDE}mkdir -- \"$p\"");
|
||||
transport
|
||||
@@ -376,8 +351,8 @@ fn text(captured: crate::session::transport::Captured) -> Result<String> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The names a listing has to survive. All four are legal, and each
|
||||
/// one broke a listing somewhere before it was separated with `\0`.
|
||||
/// The names a listing has to survive. All four are legal, and each one
|
||||
/// broke a listing somewhere before it was separated with `\0`.
|
||||
#[test]
|
||||
fn a_listing_survives_the_names_a_filesystem_allows() {
|
||||
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[2].kind, EntryKind::Directory);
|
||||
assert_eq!(entries[2].size, 4096);
|
||||
// The kind is the target's, so a link to a directory navigates --
|
||||
// and one whose target is gone is neither a file nor a directory.
|
||||
// The kind is the target's, so a link to a directory navigates -- and
|
||||
// one whose target is gone is neither a file nor a directory.
|
||||
assert!(entries[3].link);
|
||||
assert_eq!(entries[3].kind, EntryKind::Directory);
|
||||
assert_eq!(entries[4].kind, EntryKind::Other);
|
||||
@@ -431,9 +406,9 @@ mod tests {
|
||||
assert!(refused.contains("start it with / or ~"), "{refused}");
|
||||
}
|
||||
|
||||
/// The scripts, against a real tree, through the transport that runs
|
||||
/// them here -- which is cheap, because `sh` is wherever `cargo test`
|
||||
/// is. The remote transport runs the identical text.
|
||||
/// The scripts, against a real tree, through the transport that runs them
|
||||
/// here -- cheap, because `sh` is wherever `cargo test` is. The remote
|
||||
/// transport runs the identical text.
|
||||
fn tree() -> tempfile::TempDir {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("hello.txt"), "one\ntwo\n").unwrap();
|
||||
@@ -454,8 +429,7 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
// `pwd -P`, so a temp directory reached through a symlinked /tmp
|
||||
// answers with what it really is -- which is the path the phone
|
||||
// then navigates on.
|
||||
// answers with what it really is.
|
||||
assert!(listing.path.starts_with('/'), "{}", listing.path);
|
||||
let mut names: Vec<&str> = listing.entries.iter().map(|e| e.name.as_str()).collect();
|
||||
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();
|
||||
assert!(matches!(read_at("big").await, FileRead::TooBig { .. }));
|
||||
|
||||
// Empty is text with nothing in it, which is what it is -- not a
|
||||
// fourth state and not the same as any of the three above.
|
||||
// Empty is text with nothing in it -- not a fourth state, and not the
|
||||
// same as any of the three above.
|
||||
std::fs::write(dir.path().join("empty"), "").unwrap();
|
||||
assert!(matches!(
|
||||
read_at("empty").await,
|
||||
@@ -592,9 +566,9 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A path that tries to close the quote and start a command of its
|
||||
/// own. It is an argument rather than syntax, so it stays one absurd
|
||||
/// filename -- the same property `ssh.rs` tests for the remote side.
|
||||
/// A path that tries to close the quote and start a command of its own. It
|
||||
/// is an argument rather than syntax, so it stays one absurd filename -- the
|
||||
/// same property `ssh.rs` tests for the remote side.
|
||||
#[tokio::test]
|
||||
async fn a_path_full_of_shell_crosses_as_data() {
|
||||
let dir = tree();
|
||||
@@ -614,8 +588,8 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The tilde is the one character the prelude gives a meaning, and it
|
||||
/// is the *machine's* home -- here, this one.
|
||||
/// The tilde is the one character the prelude gives a meaning, and it is the
|
||||
/// *machine's* home -- here, this one.
|
||||
#[tokio::test]
|
||||
async fn a_leading_tilde_means_the_machine_s_own_home() {
|
||||
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
|
||||
//! the whole picture; this is the entry point: config + session registry,
|
||||
//! token bootstrap, and the one TLS listener.
|
||||
//! A phone interface to AI coding sessions -- the backend. See PLAN.md for the
|
||||
//! whole picture; this is the entry point: config + session registry, token
|
||||
//! bootstrap, and the one TLS listener.
|
||||
//!
|
||||
//! The listener binds the WireGuard interface's address only, and fails
|
||||
//! closed -- if `wg0` is down the server refuses to start rather than
|
||||
//! falling back to `0.0.0.0`, because this API *is* remote code execution
|
||||
//! and the tunnel is what keeps its pre-auth surface (TLS handshake, HTTP
|
||||
//! parsing, auth middleware) off the open internet. `--bind` overrides
|
||||
//! explicitly for development; that is a deliberate, logged choice, never a
|
||||
//! fallback.
|
||||
//! The listener binds the WireGuard interface's address only, and fails closed
|
||||
//! -- if `wg0` is down the server refuses to start rather than falling back to
|
||||
//! `0.0.0.0`, because this API *is* remote code execution and the tunnel is
|
||||
//! what keeps its pre-auth surface off the open internet. `--bind` overrides
|
||||
//! explicitly for development; a deliberate, logged choice, never a fallback.
|
||||
//!
|
||||
//! There is no plaintext listener at all, so the bearer token can't travel
|
||||
//! unencrypted by misconfiguration -- even inside the tunnel.
|
||||
@@ -50,10 +48,9 @@ struct Args {
|
||||
#[arg(long, default_value_t = DEFAULT_PORT)]
|
||||
port: u16,
|
||||
|
||||
/// Address to bind instead of the wg0 interface's -- a development
|
||||
/// override (e.g. 127.0.0.1 for curl, or a LAN address for a phone
|
||||
/// before the tunnel exists). Production runs without it and fails
|
||||
/// closed when wg0 is absent.
|
||||
/// Address to bind instead of the wg0 interface's -- a development override
|
||||
/// (127.0.0.1 for curl, or a LAN address for a phone before the tunnel
|
||||
/// exists). Production runs without it and fails closed when wg0 is absent.
|
||||
#[arg(long)]
|
||||
bind: Option<IpAddr>,
|
||||
|
||||
@@ -83,44 +80,34 @@ struct Args {
|
||||
rotate_token: bool,
|
||||
|
||||
/// Enroll one more device without touching the running server: mint a
|
||||
/// token, print its enrollment link (one line, stdout, nothing else)
|
||||
/// and exit. The server adopts the token the first time that device
|
||||
/// uses it. For a tool -- Dev Updater -- that opens the link on the
|
||||
/// phone, where a QR printed here cannot be scanned.
|
||||
/// token, print its enrollment link (one line, stdout, nothing else) and
|
||||
/// exit. The server adopts the token the first time that device uses it.
|
||||
/// For a tool that opens the link on the phone, where a QR printed here
|
||||
/// cannot be scanned.
|
||||
#[arg(long)]
|
||||
enroll_link: bool,
|
||||
|
||||
/// Hold every response back by this many milliseconds.
|
||||
///
|
||||
/// A development aid, and a specific one: over the tunnel a phone's
|
||||
/// requests take tens to hundreds of milliseconds, and several faults
|
||||
/// live entirely in what the app does *while* one is outstanding --
|
||||
/// a page of history landing mid-fling, a screen drawn before its
|
||||
/// first answer arrives. On a loopback server every response is back
|
||||
/// 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.
|
||||
/// A development aid, and a specific one: over the tunnel a phone's requests
|
||||
/// take tens to hundreds of milliseconds, and several faults live entirely
|
||||
/// in what the app does *while* one is outstanding. On a loopback server
|
||||
/// those windows close before anything can be observed and the bug looks
|
||||
/// like it is not there.
|
||||
#[arg(long, default_value_t = 0, value_name = "MS")]
|
||||
delay: u64,
|
||||
|
||||
/// Mark every session spawned here as throwaway: its process is
|
||||
/// stopped when this server exits, instead of being left running for
|
||||
/// the next start to adopt. On by default in a debug build.
|
||||
/// Mark every session spawned here as throwaway: its process is stopped
|
||||
/// when this server exits, instead of being left running for the next start
|
||||
/// to adopt. On by default in a debug build.
|
||||
///
|
||||
/// Sessions outlive the backend on purpose, which is right for the
|
||||
/// ones somebody is using and wrong for the ones a test made: a
|
||||
/// session spawned to check something leaves a `claude` behind that
|
||||
/// 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.
|
||||
/// Sessions outlive the backend on purpose, which is right for the ones
|
||||
/// somebody is using and wrong for the ones a test made -- twelve of those
|
||||
/// accumulated on this machine in a day, each holding a conversation open.
|
||||
///
|
||||
/// The flag decides only what *new* sessions are marked as. What
|
||||
/// happens on the way out is decided by the mark, which is written
|
||||
/// into the session and outlives the server that made it -- so
|
||||
/// sessions spawned without it keep running, whichever server is up
|
||||
/// when one exits.
|
||||
/// The flag decides only what *new* sessions are marked as. What happens on
|
||||
/// the way out is decided by the mark, which outlives the server that made
|
||||
/// it.
|
||||
#[arg(
|
||||
long,
|
||||
default_value_t = cfg!(debug_assertions),
|
||||
@@ -134,18 +121,16 @@ struct Args {
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
// Both rustls crypto providers are in the dependency graph (ureq
|
||||
// brings ring, axum-server brings aws-lc-rs), so rustls refuses to
|
||||
// pick one itself; choose before anything touches TLS.
|
||||
// Both rustls crypto providers are in the dependency graph (ureq brings
|
||||
// ring, axum-server brings aws-lc-rs), so rustls refuses to pick one itself.
|
||||
rustls::crypto::aws_lc_rs::default_provider()
|
||||
.install_default()
|
||||
.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,
|
||||
// because `with_env_filter("info")` is a fixed directive that never reads the environment --
|
||||
// so the per-request diagnostics that AGENTS.md tells you to turn on with
|
||||
// `RUST_LOG=ai_server=debug` printed nothing, and the switch looked like the code it was
|
||||
// meant to instrument being wrong.
|
||||
// `info` unless RUST_LOG says otherwise. Written as a *fallback* rather than
|
||||
// as the filter, because `with_env_filter("info")` is a fixed directive that
|
||||
// never reads the environment -- so `RUST_LOG=ai_server=debug` printed
|
||||
// nothing, and the switch looked like the code it was meant to instrument.
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
@@ -157,11 +142,10 @@ async fn main() -> Result<()> {
|
||||
let config_path = args
|
||||
.config
|
||||
.unwrap_or_else(|| config_home("ai-app").join("config.ron"));
|
||||
// Before the manager exists, on purpose: constructing it and seeding
|
||||
// setups touches sessions and subprocesses this invocation has no
|
||||
// business with while another instance is serving. Only the hash
|
||||
// reaches disk, in the spool `auth.rs` reads; the link itself goes to
|
||||
// stdout alone, because the caller opens whatever this prints.
|
||||
// Before the manager exists, on purpose: constructing it and seeding setups
|
||||
// touches sessions and subprocesses this invocation has no business with
|
||||
// while another instance is serving. Only the hash reaches disk, in the
|
||||
// spool `auth.rs` reads; the link goes to stdout alone.
|
||||
if args.enroll_link {
|
||||
let bind_ip = match args.bind {
|
||||
Some(ip) => ip,
|
||||
@@ -182,9 +166,9 @@ async fn main() -> Result<()> {
|
||||
let data_dir = args
|
||||
.data_dir
|
||||
.unwrap_or_else(|| data_home("ai-app").join("sessions"));
|
||||
// Beside the session data rather than under it: models outlive every
|
||||
// session and are shared by all of them, so deleting a session must
|
||||
// never take a multi-gigabyte download with it.
|
||||
// Beside the session data rather than under it: models outlive every session
|
||||
// and are shared by all of them, so deleting a session must never take a
|
||||
// multi-gigabyte download with it.
|
||||
let models_dir = args
|
||||
.models_dir
|
||||
.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)"
|
||||
);
|
||||
}
|
||||
// After construction rather than inside it: seeding asks this machine
|
||||
// what it has, which is I/O, and a constructor that quietly runs a
|
||||
// subprocess is a surprise to every caller including the tests.
|
||||
// After construction rather than inside it: seeding asks this machine what
|
||||
// it has, and a constructor that quietly runs a subprocess is a surprise to
|
||||
// every caller including the tests.
|
||||
manager.seed_setup().await?;
|
||||
|
||||
tracing::info!("config: {}", config_path.display());
|
||||
@@ -210,9 +194,9 @@ async fn main() -> Result<()> {
|
||||
for setup in manager.setups() {
|
||||
match &setup.ssh {
|
||||
Some(ssh) => tracing::info!(" setup \"{}\" -> {}", setup.name, ssh.address),
|
||||
// No parenthetical naming the local machine: the default
|
||||
// setup is *called* "this machine", and the line read
|
||||
// "setup this machine (this machine)".
|
||||
// No parenthetical naming the local machine: the default setup is
|
||||
// *called* "this machine", and the line read "setup this machine
|
||||
// (this machine)".
|
||||
None => tracing::info!(" setup \"{}\" runs here", setup.name),
|
||||
}
|
||||
for provider in &setup.providers {
|
||||
@@ -228,10 +212,9 @@ async fn main() -> Result<()> {
|
||||
);
|
||||
}
|
||||
|
||||
// Before the interface check below, deliberately: the certificates are
|
||||
// also what the phone app embeds at build time, so they need to be
|
||||
// obtainable on a machine whose tunnel isn't up yet. The leaf is
|
||||
// reissued on every start, so once wg0 exists the next start covers it.
|
||||
// Before the interface check below, deliberately: the certificates are also
|
||||
// what the phone app embeds at build time, so they need to be obtainable on
|
||||
// a machine whose tunnel isn't up yet. The leaf is reissued on every start.
|
||||
let certs_dir = args
|
||||
.certs
|
||||
.unwrap_or_else(|| config_home("ai-app").join("certs"));
|
||||
@@ -256,9 +239,8 @@ async fn main() -> Result<()> {
|
||||
None => netif::wg_address("ai-server")?,
|
||||
};
|
||||
|
||||
// Token bootstrap: first run generates one; --rotate-token replaces
|
||||
// whatever exists. Either way the plaintext appears exactly once, in
|
||||
// the QR printed here.
|
||||
// Token bootstrap: first run generates one; --rotate-token replaces whatever
|
||||
// exists. Either way the plaintext appears exactly once, in the QR.
|
||||
if args.rotate_token || manager.tokens().is_empty() {
|
||||
let rotating = args.rotate_token && !manager.tokens().is_empty();
|
||||
let token = enroll::generate_token();
|
||||
@@ -279,16 +261,13 @@ async fn main() -> Result<()> {
|
||||
.await
|
||||
.context("failed to load TLS cert/key")?;
|
||||
|
||||
// No providers listed here any more: which machines can be asked, and
|
||||
// about what, comes from the setups at the moment the screen is opened
|
||||
// -- so a machine added from the phone reports its limits without a
|
||||
// restart, and the backend's own account stops standing in for every
|
||||
// machine's.
|
||||
// No providers listed here any more: which machines can be asked, and about
|
||||
// what, comes from the setups at the moment the screen is opened -- so a
|
||||
// machine added from the phone reports its limits without a restart.
|
||||
let monitor = Arc::new(usage::UsageMonitor::new());
|
||||
|
||||
// The bearer-token middleware wraps the entire router -- routes and
|
||||
// fallback alike -- here and only here, so a new route can't forget
|
||||
// auth. Zero unauthenticated endpoints.
|
||||
// The bearer-token middleware wraps the entire router -- routes and fallback
|
||||
// alike -- here and only here, so a new route can't forget auth.
|
||||
let app = routes::router(Arc::clone(&manager))
|
||||
.merge(routes::usage_router(monitor, Arc::clone(&manager)))
|
||||
.merge(routes::models_router(Arc::clone(&models)))
|
||||
@@ -297,9 +276,9 @@ async fn main() -> Result<()> {
|
||||
auth::require_token,
|
||||
));
|
||||
|
||||
// Outside the auth layer, so an unauthenticated request is refused at
|
||||
// the speed it always was: this is here to slow the app down, not to
|
||||
// widen the window on anything guessing at tokens.
|
||||
// Outside the auth layer, so an unauthenticated request is refused at the
|
||||
// speed it always was: this is here to slow the app down, not to widen the
|
||||
// window on anything guessing at tokens.
|
||||
let app = match args.delay {
|
||||
0 => app,
|
||||
ms => {
|
||||
@@ -316,13 +295,11 @@ async fn main() -> Result<()> {
|
||||
let addr = SocketAddr::new(bind_ip, args.port);
|
||||
tracing::info!("serving https://{addr}");
|
||||
|
||||
// Let go of the sessions on the way out rather than stopping them:
|
||||
// their processes are meant to outlive this one, so restarting the
|
||||
// backend does not end a turn somebody is waiting on. Each is recorded
|
||||
// in its session directory and adopted again on the way back up (see
|
||||
// `session::process`). The exception is the sessions marked throwaway,
|
||||
// which are stopped first -- see `--throwaway-sessions`. Both signals,
|
||||
// because systemd and OpenRC send TERM while a terminal sends INT.
|
||||
// Let go of the sessions on the way out rather than stopping them: their
|
||||
// processes are meant to outlive this one. Each is recorded in its session
|
||||
// directory and adopted again on the way back up. The exception is the
|
||||
// sessions marked throwaway, which are stopped first. Both signals, because
|
||||
// systemd and OpenRC send TERM while a terminal sends INT.
|
||||
let serving = axum_server::bind_rustls(addr, tls_config)
|
||||
.serve(app.into_make_service_with_connect_info::<SocketAddr>());
|
||||
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"),
|
||||
_ = 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
|
||||
// select above: a throwaway session is one nobody meant to keep, and
|
||||
// the whole point is that nothing has to remember to clean it up.
|
||||
// Stopped before the rest are let go of, and on every way out of the select
|
||||
// above: a throwaway session is one nobody meant to keep, and the whole point
|
||||
// is that nothing has to remember to clean it up.
|
||||
manager.stop_throwaway_sessions();
|
||||
manager.detach_all();
|
||||
|
||||
|
||||
+84
-118
@@ -1,28 +1,22 @@
|
||||
//! GGUF models on this machine, and the downloads that produce them.
|
||||
//!
|
||||
//! The registry pattern again (see `session`): one owner, one lock, so what
|
||||
//! is on disk and what this server believes cannot come apart.
|
||||
//!
|
||||
//! Three things shape the design, all of them consequences of a model file
|
||||
//! being gigabytes rather than kilobytes:
|
||||
//! The registry pattern again: one owner, one lock, so what is on disk and what
|
||||
//! this server believes cannot come apart. Three things shape the design, all
|
||||
//! consequences of a model file being gigabytes rather than kilobytes:
|
||||
//!
|
||||
//! **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
|
||||
//! it -- including one that did not start it, and one that opened the app
|
||||
//! after it finished. State in a per-connection channel would not survive
|
||||
//! the phone locking its screen, which for an hour-long download is the
|
||||
//! normal case rather than an edge one.
|
||||
//! keyed by the model it produces and lives here, so any device can watch it --
|
||||
//! including one that did not start it. State in a per-connection channel would
|
||||
//! not survive the phone locking its screen, which for an hour-long download is
|
||||
//! the normal case.
|
||||
//!
|
||||
//! **Every run has an id, and its outcome outlives it.** Without those,
|
||||
//! "not downloading" is three different answers at once -- it finished,
|
||||
//! it never started, or a 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.
|
||||
//! **Every run has an id, and its outcome outlives it.** Without those, "not
|
||||
//! downloading" is three answers at once -- it finished, it never started, or a
|
||||
//! different run finished while you were away.
|
||||
//!
|
||||
//! **Progress is measured, never estimated.** `total` is whatever
|
||||
//! `Content-Length` said and nothing else; when the server does not send
|
||||
//! one it stays `None` and the phone shows that it does not know, rather
|
||||
//! than a bar drawn from how long the last download took.
|
||||
//! `Content-Length` said and nothing else; when the server does not send one it
|
||||
//! stays `None` and the phone shows that it does not know.
|
||||
|
||||
use std::collections::HashMap;
|
||||
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"));
|
||||
|
||||
/// Read size per loop iteration. Big enough that the syscall overhead is
|
||||
/// nothing against a multi-gigabyte file, small enough that a cancel is
|
||||
/// noticed promptly -- the flag is only checked between chunks.
|
||||
/// nothing against a multi-gigabyte file, small enough that a cancel is noticed
|
||||
/// promptly -- the flag is only checked between chunks.
|
||||
const CHUNK: usize = 256 * 1024;
|
||||
|
||||
/// A model file sitting on this machine, ready to run.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LocalModel {
|
||||
/// `owner/repo/file.gguf` -- the HuggingFace coordinates, which are
|
||||
/// already unique, so nothing has to invent an id.
|
||||
/// `owner/repo/file.gguf` -- the HuggingFace coordinates, which are already
|
||||
/// unique, so nothing has to invent an id.
|
||||
pub key: String,
|
||||
pub repo: String,
|
||||
pub file: String,
|
||||
pub bytes: u64,
|
||||
}
|
||||
|
||||
/// 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 easier to render than a
|
||||
/// variant it has to destructure.
|
||||
/// 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
|
||||
/// easier to render than a variant it has to destructure.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DownloadState {
|
||||
Running,
|
||||
/// Reading the finished file back to check it against the hash
|
||||
/// HuggingFace publishes. Its own state because it takes real time on
|
||||
/// a multi-gigabyte file and "still working" is the honest thing to
|
||||
/// show, rather than a bar sitting at 100% for half a minute.
|
||||
/// Reading the finished file back to check it against the hash HuggingFace
|
||||
/// publishes. Its own state because it takes real time on a multi-gigabyte
|
||||
/// file and "still working" is the honest thing to show, rather than a bar
|
||||
/// sitting at 100% for half a minute.
|
||||
Verifying,
|
||||
Finished,
|
||||
Failed,
|
||||
@@ -80,20 +72,17 @@ pub enum DownloadState {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DownloadStatus {
|
||||
pub key: String,
|
||||
/// Distinguishes this run from any earlier one for the same model.
|
||||
/// A device that was watching run 3 can tell that what it is looking
|
||||
/// at now is run 4 rather than assuming its own run ended.
|
||||
/// Distinguishes this run from any earlier one for the same model, so a
|
||||
/// device that was watching run 3 can tell it is now looking at run 4.
|
||||
pub run: u64,
|
||||
pub repo: String,
|
||||
pub file: String,
|
||||
pub state: DownloadState,
|
||||
/// Bytes on disk, including any carried over from a resumed attempt.
|
||||
pub done: u64,
|
||||
/// 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")]
|
||||
pub total: Option<u64>,
|
||||
/// Present only when [`DownloadState::Failed`], and it is the reason.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
pub started: f64,
|
||||
@@ -152,9 +141,8 @@ impl Run {
|
||||
/// Every model this machine has, and every download in flight or finished.
|
||||
pub struct ModelStore {
|
||||
dir: PathBuf,
|
||||
/// Keyed by model key: one run per model at a time, and the last run
|
||||
/// for a model stays here after it ends so its outcome can still be
|
||||
/// read. Bounded by how many distinct models have been asked for.
|
||||
/// Keyed by model key: one run per model at a time, and the last run for a
|
||||
/// model stays here after it ends so its outcome can still be read.
|
||||
runs: Mutex<HashMap<String, Arc<Run>>>,
|
||||
next_run: AtomicU64,
|
||||
}
|
||||
@@ -171,11 +159,10 @@ impl ModelStore {
|
||||
/// Where a model's file lives, refusing anything that would escape the
|
||||
/// models directory.
|
||||
///
|
||||
/// The repo and file come from a phone, and this server runs as the
|
||||
/// user who started it, so they are treated as hostile: every
|
||||
/// component must be an ordinary name. Rejecting is deliberate rather
|
||||
/// than sanitising, since a silently rewritten path would download the
|
||||
/// right bytes to the wrong place.
|
||||
/// The repo and file come from a phone, so they are treated as hostile:
|
||||
/// every component must be an ordinary name. Rejecting rather than
|
||||
/// sanitising, since a silently rewritten path would download the right
|
||||
/// bytes to the wrong place.
|
||||
fn path_for(&self, repo: &str, file: &str) -> Result<PathBuf> {
|
||||
let mut path = self.dir.clone();
|
||||
for part in repo.split('/').chain(file.split('/')) {
|
||||
@@ -191,11 +178,9 @@ impl ModelStore {
|
||||
format!("{repo}/{file}")
|
||||
}
|
||||
|
||||
/// 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 being offered, and the directory is small enough
|
||||
/// that walking it costs nothing next to loading a model.
|
||||
/// 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
|
||||
/// being offered.
|
||||
pub fn list(&self) -> Vec<LocalModel> {
|
||||
let mut found = Vec::new();
|
||||
collect(&self.dir, &self.dir, &mut found);
|
||||
@@ -211,12 +196,10 @@ impl ModelStore {
|
||||
all
|
||||
}
|
||||
|
||||
/// Starts fetching `file` from `repo`, or returns the run already
|
||||
/// doing so.
|
||||
///
|
||||
/// Idempotent on purpose: a phone that lost its connection and came
|
||||
/// back will press the button again, and that must join the existing
|
||||
/// run rather than start a second one writing the same file.
|
||||
/// Starts fetching `file` from `repo`, or returns the run already 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
|
||||
/// second one writing the same file.
|
||||
pub fn start(self: &Arc<Self>, repo: &str, file: &str) -> Result<DownloadStatus> {
|
||||
let key = Self::key_for(repo, file);
|
||||
let target = self.path_for(repo, file)?;
|
||||
@@ -251,8 +234,8 @@ impl ModelStore {
|
||||
drop(runs);
|
||||
|
||||
// A dedicated thread rather than the blocking pool: this holds its
|
||||
// thread for as long as the download takes, which is minutes to
|
||||
// hours, and the pool exists for short work.
|
||||
// thread for as long as the download takes, which is minutes to hours,
|
||||
// and the pool exists for short work.
|
||||
let store = Arc::clone(self);
|
||||
std::thread::spawn(move || {
|
||||
let outcome = store.fetch(&run, &target);
|
||||
@@ -275,8 +258,8 @@ impl ModelStore {
|
||||
Ok(status)
|
||||
}
|
||||
|
||||
/// Asks a running download to stop. The partial file stays, so
|
||||
/// starting again resumes rather than refetching.
|
||||
/// Asks a running download to stop. The partial file stays, so starting
|
||||
/// again resumes rather than refetching.
|
||||
pub fn cancel(&self, key: &str) -> Result<DownloadStatus> {
|
||||
let runs = self.runs.lock().unwrap();
|
||||
let Some(run) = runs.get(key) else {
|
||||
@@ -303,7 +286,6 @@ impl ModelStore {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The download loop: resume where a partial left off, write, report.
|
||||
fn fetch(&self, run: &Run, target: &Path) -> Result<()> {
|
||||
let partial = partial_of(target);
|
||||
let identity = identity_of(target);
|
||||
@@ -311,9 +293,8 @@ impl ModelStore {
|
||||
private::create_dir(parent)?;
|
||||
}
|
||||
|
||||
// What we have, and what it was part of. A partial with no
|
||||
// recorded identity is not resumable -- it could be a fragment of
|
||||
// any revision -- so it is refetched rather than guessed at.
|
||||
// What we have, and what it was part of. A partial with no recorded
|
||||
// identity is not resumable -- it could be a fragment of any revision.
|
||||
let known = std::fs::read_to_string(&identity)
|
||||
.ok()
|
||||
.map(|s| s.trim().to_string());
|
||||
@@ -331,13 +312,11 @@ impl ModelStore {
|
||||
let mut etag = etag_of(&response);
|
||||
|
||||
// HuggingFace's CDN ignores `If-Range` -- probed 2026-08-28: a
|
||||
// deliberately stale validator still answers 206 with the ranged
|
||||
// bytes rather than 200 with the whole file. So the header cannot
|
||||
// be relied on to restart us, and the check is done here instead:
|
||||
// if what arrived is not the revision our partial belongs to,
|
||||
// resuming would splice two files into something of exactly the
|
||||
// right length and the wrong contents. Throw the partial away and
|
||||
// ask again from zero.
|
||||
// deliberately stale validator still answers 206 with the ranged bytes.
|
||||
// So the header cannot be relied on to restart us, and the check is done
|
||||
// here instead: if what arrived is not the revision our partial belongs
|
||||
// to, resuming would splice two files into something of exactly the
|
||||
// right length and the wrong contents.
|
||||
if resumed && etag.is_some() && etag != known {
|
||||
tracing::info!(
|
||||
"{} changed upstream since the partial was written -- starting again",
|
||||
@@ -349,11 +328,9 @@ impl ModelStore {
|
||||
etag = etag_of(&response);
|
||||
}
|
||||
|
||||
// On a 206, Content-Length is the length of the *range*, not of
|
||||
// the file -- it answers a different question than the one a
|
||||
// progress bar asks, and taken at face value it would fill the bar
|
||||
// at 72 MB of a 234 MB model. The whole size is the last field of
|
||||
// Content-Range (`bytes 162000000-234074815/234074816`), which has
|
||||
// On a 206, Content-Length is the length of the *range*, not of the file
|
||||
// -- taken at face value it would fill the bar at 72 MB of a 234 MB
|
||||
// model. The whole size is the last field of Content-Range, which has
|
||||
// the further merit of not depending on where the range began.
|
||||
let total: Option<u64> = if resumed {
|
||||
response
|
||||
@@ -378,12 +355,10 @@ impl ModelStore {
|
||||
p.total = total;
|
||||
}
|
||||
|
||||
// `truncate(false)` is the whole resume story: the file is opened
|
||||
// to be seeked into and appended to, and truncating here would
|
||||
// throw away exactly the bytes the Range request just asked the
|
||||
// server not to send again. Stated rather than left to the
|
||||
// default, because the default is what a reader would have to
|
||||
// remember.
|
||||
// `truncate(false)` is the whole resume story: the file is opened to be
|
||||
// seeked into and appended to, and truncating would throw away exactly
|
||||
// the bytes the Range request just asked the server not to send again.
|
||||
// Stated rather than left to the default.
|
||||
let mut file = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
@@ -397,9 +372,8 @@ impl ModelStore {
|
||||
file.set_len(0)
|
||||
.context("truncate a partial we cannot resume onto")?;
|
||||
}
|
||||
// Written before the body, so an interrupted download leaves a
|
||||
// partial that can still say which revision it belongs to. That is
|
||||
// what makes it safe to keep one across a restart of this server.
|
||||
// Written before the body, so an interrupted download leaves a partial
|
||||
// that can still say which revision it belongs to.
|
||||
if let Some(etag) = &etag {
|
||||
std::fs::write(&identity, etag).ok();
|
||||
}
|
||||
@@ -425,12 +399,10 @@ impl ModelStore {
|
||||
file.flush().context("flushing the model file")?;
|
||||
drop(file);
|
||||
|
||||
// Checked before the rename, so a file that fails never gets the
|
||||
// real name and `list` never offers it. With the identity check
|
||||
// above this should not fire; it is here because a download of
|
||||
// this size has too many ways to go subtly wrong to take on
|
||||
// trust, and because a wrong model is the kind of failure that
|
||||
// surfaces as bad output rather than as an error.
|
||||
// Checked before the rename, so a file that fails never gets the real
|
||||
// name and `list` never offers it. With the identity check above this
|
||||
// should not fire; it is here 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) {
|
||||
run.progress.lock().unwrap().state = DownloadState::Verifying;
|
||||
let actual = sha256_of(&partial)?;
|
||||
@@ -446,8 +418,8 @@ impl ModelStore {
|
||||
}
|
||||
}
|
||||
|
||||
// Renamed only once complete, so a file at its real name is always
|
||||
// a whole model -- `list` needs no other way to tell.
|
||||
// Renamed only once complete, so a file at its real name is always a
|
||||
// whole model -- `list` needs no other way to tell.
|
||||
std::fs::rename(&partial, target)
|
||||
.with_context(|| format!("finish {}", target.display()))?;
|
||||
std::fs::remove_file(&identity).ok();
|
||||
@@ -455,9 +427,8 @@ impl ModelStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// The sha256 of a file, read in chunks -- these are gigabytes, and
|
||||
/// reading one into memory to hash it would be the largest allocation this
|
||||
/// server ever makes.
|
||||
/// The sha256 of a file, read in chunks -- these are gigabytes, and reading one
|
||||
/// into memory to hash it would be the largest allocation this server makes.
|
||||
fn sha256_of(path: &Path) -> Result<String> {
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut file =
|
||||
@@ -471,8 +442,8 @@ fn sha256_of(path: &Path) -> Result<String> {
|
||||
}
|
||||
hasher.update(&buffer[..read]);
|
||||
}
|
||||
// Hex by hand, as wg_app_link::enroll::token_hash_hex also has to,
|
||||
// since this sha2 version's output type does not implement LowerHex.
|
||||
// Hex by hand, as `wg_app_link::enroll::token_hash_hex` also has to, since
|
||||
// this sha2 version's output type does not implement LowerHex.
|
||||
Ok(hasher
|
||||
.finalize()
|
||||
.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}-"));
|
||||
}
|
||||
let response = get.call().with_context(|| format!("GET {url}"))?;
|
||||
// Trust the status, not the request: a server that ignores Range
|
||||
// answers 200 with the whole file, and appending to that would
|
||||
// corrupt it.
|
||||
// Trust the status, not the request: a server that ignores Range answers
|
||||
// 200 with the whole file, and appending to that would corrupt it.
|
||||
let resumed = response.status() == 206;
|
||||
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
|
||||
/// beside it is a piece of.
|
||||
/// `x.gguf` -> `x.gguf.part.etag`, holding which revision the partial beside it
|
||||
/// is a piece of.
|
||||
fn identity_of(target: &Path) -> PathBuf {
|
||||
let mut name = target.as_os_str().to_os_string();
|
||||
name.push(".part.etag");
|
||||
@@ -567,18 +537,17 @@ pub struct RemoteRepo {
|
||||
pub struct RemoteFile {
|
||||
pub path: String,
|
||||
pub bytes: u64,
|
||||
/// Already on this machine, so the phone can say so rather than
|
||||
/// offering to fetch it again.
|
||||
/// Already on this machine, so the phone can say so rather than offering to
|
||||
/// fetch it again.
|
||||
pub have: bool,
|
||||
}
|
||||
|
||||
/// Searches HuggingFace for GGUF repositories matching `query`.
|
||||
///
|
||||
/// Proxied through this server rather than called from the phone, for two
|
||||
/// reasons that both matter: the app trusts exactly one certificate --
|
||||
/// this server's -- and has no general internet trust to spend on
|
||||
/// huggingface.co, and the machine that has to do the downloading is this
|
||||
/// one, so it is also the one whose view of what exists is relevant.
|
||||
/// reasons that both matter: the app trusts exactly one certificate -- this
|
||||
/// server's -- and has no general internet trust to spend on huggingface.co,
|
||||
/// and the machine that has to do the downloading is this one.
|
||||
pub fn search(query: &str) -> Result<Vec<RemoteRepo>> {
|
||||
let url = format!(
|
||||
"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())
|
||||
}
|
||||
|
||||
/// 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 -- so it is a free integrity check on a download rather
|
||||
/// than something we would have to compute a second source of truth for.
|
||||
/// 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
|
||||
/// -- so it is a free integrity check rather than a second source of truth.
|
||||
fn published_sha256(repo: &str, file: &str) -> Option<String> {
|
||||
let url = format!("https://huggingface.co/api/models/{repo}/tree/main?expand=true");
|
||||
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"))
|
||||
}
|
||||
|
||||
/// Percent-encodes a query string. Deliberately minimal -- this escapes
|
||||
/// what a model search actually contains rather than implementing the
|
||||
/// whole rule set, and anything unexpected becomes `%XX` rather than
|
||||
/// being passed through.
|
||||
/// Percent-encodes a query string. Deliberately minimal -- this escapes what a
|
||||
/// model search actually contains rather than implementing the whole rule set,
|
||||
/// and anything unexpected becomes `%XX` rather than being passed through.
|
||||
fn urlencode(value: &str) -> String {
|
||||
value
|
||||
.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.
|
||||
//!
|
||||
//! 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
|
||||
//! message subtype, a field that changed shape -- and that is what the
|
||||
//! tests at the bottom pin, replaying recorded lines. The driver half
|
||||
//! moves when spawning, resuming or shutting down changes, and never
|
||||
//! reads a line itself.
|
||||
//! reasons. This half moves when the CLI's wire format does, which is what the
|
||||
//! tests at the bottom pin by replaying recorded lines; the driver half moves
|
||||
//! when spawning, resuming or shutting down changes.
|
||||
//!
|
||||
//! The one side effect here is saving images a tool result carries into
|
||||
//! the session directory (they would bloat the transcript as base64);
|
||||
//! everything else is pure, which is what makes the mapping testable
|
||||
//! without a process.
|
||||
//! The one side effect here is saving images a tool result carries into the
|
||||
//! session directory; everything else is pure, which is what makes the mapping
|
||||
//! testable without a process.
|
||||
|
||||
use std::collections::HashMap;
|
||||
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.
|
||||
///
|
||||
/// `message_start` begins one assistant message, and the CLI sends the
|
||||
/// previous call's tool results back before it opens the next -- so this
|
||||
/// is the first moment at which anything written since the last one can
|
||||
/// have been read. Nothing earlier will do: the text deltas and the
|
||||
/// `tool_use` block of a message *already in flight* keep arriving after
|
||||
/// a steer is written, and none of them saw it.
|
||||
/// `message_start` begins one assistant message, and the CLI sends the previous
|
||||
/// call's tool results back before opening the next -- so this is the first
|
||||
/// moment at which anything written since the last one can have been read.
|
||||
/// Nothing earlier will do: the deltas and `tool_use` of a message *already in
|
||||
/// flight* keep arriving after a steer is written, and none of them saw it.
|
||||
///
|
||||
/// Only present because the driver passes `--include-partial-messages`.
|
||||
/// Without it there are no `stream_event` lines at all and this is never
|
||||
/// true, which is why the caller keeps a fallback that does not depend on
|
||||
/// it.
|
||||
/// Only present because the driver passes `--include-partial-messages`, which
|
||||
/// is why the caller keeps a fallback that does not depend on it.
|
||||
pub(super) fn starts_a_model_call(message: &Value) -> bool {
|
||||
message.get("type").and_then(Value::as_str) == Some("stream_event")
|
||||
&& message["event"].get("type").and_then(Value::as_str) == Some("message_start")
|
||||
@@ -46,71 +40,56 @@ pub(super) enum AnswerOutcome {
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// A setting a control request asked for, held until the CLI says
|
||||
/// whether it took.
|
||||
///
|
||||
/// The CLI answers `set_model` with a bare success -- no value -- so the
|
||||
/// A setting a control request asked for, held until the CLI says whether it
|
||||
/// took. 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.
|
||||
/// `set_permission_mode` does echo its mode back, and so does a
|
||||
/// `system/status` line a moment later; both are handled where they
|
||||
/// arrive, and this covers the one that says nothing.
|
||||
/// `set_permission_mode` does echo its mode back.
|
||||
pub(super) enum Setting {
|
||||
Model(String),
|
||||
PermissionMode(String),
|
||||
}
|
||||
|
||||
/// A `can_use_tool` request we've surfaced to the phone and not yet
|
||||
/// answered. For plain permissions there is one implicit question
|
||||
/// (Allow/Deny); for AskUserQuestion, one per entry in `questions`.
|
||||
/// A `can_use_tool` request we've surfaced to the phone and not yet answered.
|
||||
/// For plain permissions there is one implicit question (Allow/Deny); for
|
||||
/// AskUserQuestion, one per entry in `questions`.
|
||||
struct PendingRequest {
|
||||
request_id: String,
|
||||
input: Value,
|
||||
/// Question text per sub-question, in order -- the keys the answers
|
||||
/// map uses. Empty for a plain permission request.
|
||||
/// Question text per sub-question, in order -- the keys the answers map
|
||||
/// uses. Empty for a plain permission request.
|
||||
questions: Vec<String>,
|
||||
answers: HashMap<String, String>,
|
||||
}
|
||||
|
||||
/// Translation state: stream-json lines in, common events out. The one
|
||||
/// 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.
|
||||
/// Translation state: stream-json lines in, common events out.
|
||||
pub(super) struct Translator {
|
||||
pub(super) session_id: Option<String>,
|
||||
pending: HashMap<String, PendingRequest>,
|
||||
/// Settings asked for and not yet answered, by request id. Its path
|
||||
/// out is the response: every entry is removed when one arrives,
|
||||
/// whether it succeeded or failed.
|
||||
/// Settings asked for and not yet answered, by request id. Its path out is
|
||||
/// the response: every entry is removed when one arrives, whether it
|
||||
/// succeeded or failed.
|
||||
asked: HashMap<String, Setting>,
|
||||
/// Whether this side asked the turn to stop.
|
||||
///
|
||||
/// 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
|
||||
/// tell them apart, and a person who pressed Stop was shown "the turn
|
||||
/// ended with an error" for doing exactly what the button says. What
|
||||
/// 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.
|
||||
/// broke -- a `result` with `is_error` set -- so the line cannot tell them
|
||||
/// apart, and somebody who pressed Stop was shown "the turn ended with an
|
||||
/// error". What separates them is that *we* asked.
|
||||
///
|
||||
/// Its path out is that result -- set by `expect_interrupt`, cleared by
|
||||
/// the next `result` whichever way it went, so a genuine failure in a
|
||||
/// later turn is still reported.
|
||||
/// Its path out is that result, so a genuine failure in a later turn is
|
||||
/// still reported.
|
||||
interrupting: bool,
|
||||
/// The input side of the newest assistant message, waiting for the
|
||||
/// `result` that ends the turn to carry it out.
|
||||
/// The input side of the newest assistant message, waiting for the `result`
|
||||
/// that ends the turn to carry it out.
|
||||
///
|
||||
/// Read from the assistant message rather than from the result's own
|
||||
/// usage, which is the whole turn added up: measured on 2026-08-30
|
||||
/// against CLI 2.1.237, a two-message turn reported
|
||||
/// `cache_read_input_tokens` of 40,211 in its result, being 14,259 and
|
||||
/// 25,952 from the two messages -- the same conversation counted
|
||||
/// 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.
|
||||
/// Read from the assistant message rather than the result's own usage,
|
||||
/// which is the whole turn added up: measured on 2026-08-30 against 2.1.237,
|
||||
/// a two-message turn reported `cache_read_input_tokens` of 40,211, being
|
||||
/// 14,259 and 25,952 -- the same conversation counted twice. The model held
|
||||
/// 26,131. A turn with ten tool calls would overstate it tenfold.
|
||||
///
|
||||
/// Its path out is that result, which takes it -- so a turn whose
|
||||
/// messages carried no usage reports none rather than repeating the
|
||||
/// previous turn's.
|
||||
/// Its path out is that result, so a turn whose messages carried no usage
|
||||
/// reports none rather than repeating the previous turn's.
|
||||
context: Option<u64>,
|
||||
session_dir: PathBuf,
|
||||
}
|
||||
@@ -128,17 +107,15 @@ impl Translator {
|
||||
}
|
||||
|
||||
/// Remembers what a control request was for, so its answer can say so.
|
||||
///
|
||||
/// Called before the request goes out, not after: the reader thread is
|
||||
/// already running and a fast CLI can answer before this side gets
|
||||
/// back to it.
|
||||
/// Called before the request goes out: the reader thread is 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) {
|
||||
self.asked.insert(request_id, setting);
|
||||
}
|
||||
|
||||
/// Says that the turn about to end was stopped on purpose -- see
|
||||
/// [`Translator::interrupting`]. Called before the request goes out,
|
||||
/// for the reason [`Translator::expect_setting`] gives.
|
||||
/// Says that the turn about to end was stopped on purpose. Called before
|
||||
/// the request goes out, for the reason [`Translator::expect_setting`]
|
||||
/// gives.
|
||||
pub(super) fn expect_interrupt(&mut self) {
|
||||
self.interrupting = true;
|
||||
}
|
||||
@@ -154,14 +131,11 @@ impl Translator {
|
||||
}
|
||||
match message.get("type").and_then(Value::as_str) {
|
||||
Some("system") => self.translate_system(message),
|
||||
// The CLI's own announcement that `/clear` took effect, sent
|
||||
// just before the fresh `init` that carries the new
|
||||
// session_id. Measured against 2.1.237 rather than inferred:
|
||||
// this used to watch for the id being *replaced*, which is the
|
||||
// same event seen through one of its side effects. Taking the
|
||||
// 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.
|
||||
// The CLI's own announcement that `/clear` took effect, sent just
|
||||
// before the fresh `init` carrying the new session_id. Measured
|
||||
// against 2.1.237: this used to watch for the id being *replaced*,
|
||||
// which is the same event seen through a side effect. The
|
||||
// announcement lands before the new init rather than after it.
|
||||
Some("conversation_reset") => vec![Event::Cleared],
|
||||
Some("stream_event") => self.translate_stream_event(&message["event"]),
|
||||
Some("assistant") => self.translate_assistant(&message["message"]),
|
||||
@@ -170,8 +144,8 @@ impl Translator {
|
||||
Some("control_response") => {
|
||||
let response = &message["response"];
|
||||
// Answered either way, so the request stops being pending
|
||||
// either way -- a rejected setting that stayed here would
|
||||
// be applied by the next request that reused its id.
|
||||
// either way -- a rejected setting that stayed here would be
|
||||
// applied by the next request that reused its id.
|
||||
let asked = response
|
||||
.get("request_id")
|
||||
.and_then(Value::as_str)
|
||||
@@ -185,9 +159,9 @@ impl Translator {
|
||||
message: format!("claude rejected a request: {error}"),
|
||||
}];
|
||||
}
|
||||
// Success, so the setting this request asked for is now
|
||||
// the session's, and this is the only place that says so:
|
||||
// the response carries no value of its own for a model.
|
||||
// Success, so the setting this request asked for is now the
|
||||
// session's, and this is the only place that says so: the
|
||||
// response carries no value of its own for a model.
|
||||
match asked {
|
||||
Some(Setting::Model(model)) => vec![Event::Settings {
|
||||
model: Some(model),
|
||||
@@ -195,11 +169,10 @@ impl Translator {
|
||||
}],
|
||||
Some(Setting::PermissionMode(mode)) => vec![Event::Settings {
|
||||
model: None,
|
||||
// The CLI echoes this one, and its answer wins:
|
||||
// `auto` and `manual` are names it accepts on the
|
||||
// way in and reports back under another name, so
|
||||
// repeating the request here would show a mode the
|
||||
// session is not in.
|
||||
// The CLI echoes this one, and its answer wins: `auto`
|
||||
// and `manual` are names it accepts on the way in and
|
||||
// reports back under another name, so repeating the
|
||||
// request would show a mode the session is not in.
|
||||
permission_mode: Some(
|
||||
response["response"]["mode"]
|
||||
.as_str()
|
||||
@@ -223,28 +196,22 @@ impl Translator {
|
||||
let mut events = Vec::new();
|
||||
// A turn another agent started, which is only knowable here.
|
||||
//
|
||||
// Measured against CLI 2.1.237 (2026-08-31) by sending a
|
||||
// real cross-session message to a real stream-json session:
|
||||
// the CLI emits no `user` record for it, and nothing in the
|
||||
// partial-message stream mentions it either. The whole of
|
||||
// it arrives as an `origin` object on the turn's `result`,
|
||||
// in the same shape the session file records -- so this is
|
||||
// `import::peer_message` reading a different record.
|
||||
// Measured against 2.1.237 (2026-08-31) by sending a real
|
||||
// cross-session message to a real stream-json session: the CLI
|
||||
// emits no `user` record for it and nothing in the
|
||||
// partial-message stream mentions it. The whole of it arrives as
|
||||
// an `origin` object on the turn's `result`, in the same shape
|
||||
// the session file records -- so this is `import::peer_message`
|
||||
// reading a different record.
|
||||
//
|
||||
// The cost is the position: the note lands after the reply
|
||||
// it caused rather than above it, because at no earlier
|
||||
// point in the turn does the CLI say why the turn started.
|
||||
// Taken deliberately over the alternative, which is a
|
||||
// second reader tailing the CLI's own session file for the
|
||||
// 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.
|
||||
// The cost is the position: the note lands after the reply it
|
||||
// caused, because at no earlier point does the CLI say why the
|
||||
// turn started. Taken deliberately over a second reader tailing
|
||||
// the CLI's own session file, which is two sources of truth for
|
||||
// one conversation and a poll per live session.
|
||||
//
|
||||
// Only peer-caused turns carry it: measured over a real
|
||||
// session's stdout, four ordinary results and no `origin`
|
||||
// between them.
|
||||
// Only peer-caused turns carry it: four ordinary results over a
|
||||
// real session's stdout had no `origin` between them.
|
||||
if let Some(peer) = crate::session::import::peer_message(message) {
|
||||
events.push(peer);
|
||||
}
|
||||
@@ -278,37 +245,35 @@ impl Translator {
|
||||
}
|
||||
}
|
||||
|
||||
/// The CLI's own notices: which session this is, and what it is doing
|
||||
/// that is not a turn.
|
||||
/// The CLI's own notices: which session this is, and what it is doing that
|
||||
/// is not a turn.
|
||||
///
|
||||
/// Compaction is the whole of that second kind, and it is announced
|
||||
/// rather than inferred. Measured against CLI 2.1.237 (2026-08-29) by
|
||||
/// driving a session through `/compact`, one produces in order:
|
||||
/// Compaction is the whole of that second kind, and it is announced rather
|
||||
/// than inferred. Measured against 2.1.237 (2026-08-29) by driving a session
|
||||
/// through `/compact`, one produces in order:
|
||||
///
|
||||
/// - `{"subtype":"status","status":"compacting"}` -- the start;
|
||||
/// - `{"subtype":"status","status":null,"compact_result":"success"}`,
|
||||
/// or `"failed"` with a `compact_error` saying why -- the end;
|
||||
/// - `{"subtype":"status","status":null,"compact_result":"success"}`, or
|
||||
/// `"failed"` with a `compact_error` -- the end;
|
||||
/// - a fresh `init` carrying the same `session_id`;
|
||||
/// - `{"subtype":"compact_boundary","compact_metadata":{…}}` with the
|
||||
/// token counts, and only when it succeeded;
|
||||
/// - the turn's ordinary `result`, which is what returns it to idle.
|
||||
/// - `{"subtype":"compact_boundary","compact_metadata":{…}}` with the token
|
||||
/// counts, and only when it succeeded;
|
||||
/// - the turn's ordinary `result`, which returns it to idle.
|
||||
///
|
||||
/// The keys are snake_case here and camelCase in the CLI's own
|
||||
/// transcript file, which records the same events. Reading the shape
|
||||
/// off that file -- the obvious place to find one, since it is on
|
||||
/// disk -- gets every field name wrong and silently yields a
|
||||
/// compaction with no numbers in it.
|
||||
/// The keys are snake_case here and camelCase in the CLI's own transcript
|
||||
/// file, which records the same events -- so reading the shape off that
|
||||
/// file, the obvious place to look, gets every field name wrong and
|
||||
/// silently yields a compaction with no numbers in it.
|
||||
fn translate_system(&mut self, message: &Value) -> Vec<Event> {
|
||||
match message.get("subtype").and_then(Value::as_str) {
|
||||
Some("init") => {
|
||||
if let Some(id) = message.get("session_id").and_then(Value::as_str) {
|
||||
self.session_id = Some(id.to_string());
|
||||
}
|
||||
// The CLI's own account of what it is set to, and the only
|
||||
// one that resolves an alias: a session launched with
|
||||
// `--model haiku` reports `claude-haiku-4-5-20251001`
|
||||
// here. It arrives again after a compaction, which is
|
||||
// free -- the manager drops a setting it is already in.
|
||||
// The CLI's own account of what it is set to, and the only one
|
||||
// that resolves an alias: a session launched with
|
||||
// `--model haiku` reports `claude-haiku-4-5-20251001` here. It
|
||||
// arrives again after a compaction, which is free.
|
||||
vec![Event::Settings {
|
||||
model: message
|
||||
.get("model")
|
||||
@@ -336,22 +301,19 @@ impl Translator {
|
||||
}
|
||||
}
|
||||
|
||||
/// A `system/status` line: the CLI entering or leaving a state that is
|
||||
/// not a turn.
|
||||
/// A `system/status` line: the CLI entering or leaving a state that is not
|
||||
/// a turn.
|
||||
///
|
||||
/// A null `status` is the leaving edge, and it carries how the thing
|
||||
/// went. Whatever it was, the turn it happened inside is still going
|
||||
/// when it ends -- the `result` has not arrived yet -- so leaving says
|
||||
/// `Running`, which is also the only place in this file that does. A
|
||||
/// state this build does not recognise is left alone rather than
|
||||
/// mapped onto the nearest one we do.
|
||||
/// A null `status` is the leaving edge, and it carries how the thing went.
|
||||
/// The turn it happened inside is still going when it ends -- the `result`
|
||||
/// has not arrived -- so leaving says `Running`. A state this build does
|
||||
/// not recognise is left alone rather than mapped onto the nearest one.
|
||||
fn translate_status(&self, message: &Value) -> Vec<Event> {
|
||||
// A mode change the CLI has made, announced a moment after it
|
||||
// answers the request that asked for it. Measured on 2.1.237:
|
||||
// `{"subtype":"status","status":null,"permissionMode":"plan"}`,
|
||||
// which is a leaving edge carrying no compaction result -- so it
|
||||
// is checked before the compaction reading below, which would
|
||||
// otherwise fall through to nothing.
|
||||
// A mode change the CLI has made, announced a moment after it answers
|
||||
// the request. Measured on 2.1.237:
|
||||
// `{"subtype":"status","status":null,"permissionMode":"plan"}`, which
|
||||
// is a leaving edge carrying no compaction result -- so it is checked
|
||||
// before the compaction reading below.
|
||||
if let Some(mode) = message.get("permissionMode").and_then(Value::as_str) {
|
||||
return vec![Event::Settings {
|
||||
model: None,
|
||||
@@ -371,8 +333,8 @@ impl Translator {
|
||||
};
|
||||
let mut events = Vec::new();
|
||||
if result != "success" {
|
||||
// The CLI's own sentence, because it is specific enough to act
|
||||
// on: "Not enough messages to compact." is a complete answer.
|
||||
// The CLI's own sentence, because it is specific enough to act on:
|
||||
// "Not enough messages to compact." is a complete answer.
|
||||
events.push(Event::Error {
|
||||
message: match message.get("compact_error").and_then(Value::as_str) {
|
||||
Some(why) => format!("compaction failed: {why}"),
|
||||
@@ -386,9 +348,9 @@ impl Translator {
|
||||
events
|
||||
}
|
||||
|
||||
/// Raw API streaming: only text deltas become events. Consolidated
|
||||
/// blocks arriving later re-carry the same text, so those are skipped
|
||||
/// in `translate_assistant` -- one source per fact.
|
||||
/// Raw API streaming: only text deltas become events. Consolidated blocks
|
||||
/// arriving later re-carry the same text, so those are skipped in
|
||||
/// `translate_assistant` -- one source per fact.
|
||||
fn translate_stream_event(&mut self, event: &Value) -> Vec<Event> {
|
||||
if event.get("type").and_then(Value::as_str) == Some("content_block_delta")
|
||||
&& let Some(delta) = event["delta"].get("text")
|
||||
@@ -448,9 +410,8 @@ impl Translator {
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("a tool");
|
||||
let input = request.get("input").cloned().unwrap_or(Value::Null);
|
||||
// Measured, not matched: the request names the call it is about, so
|
||||
// the phone never has to guess which tool row a permission belongs
|
||||
// to by comparing inputs.
|
||||
// Measured, not matched: the request names the call it is about, so the
|
||||
// phone never has to guess which tool row a permission belongs to.
|
||||
let about = request
|
||||
.get("tool_use_id")
|
||||
.and_then(Value::as_str)
|
||||
@@ -471,11 +432,10 @@ impl Translator {
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("(question)")
|
||||
.to_string();
|
||||
// Everything the reader decides on, carried in the event.
|
||||
// The alternative -- and what this was -- is the phone
|
||||
// reaching into the tool call's input for the parts the
|
||||
// event dropped, which puts this dialect's schema in the
|
||||
// app where no other dialect can reach it.
|
||||
// Everything the reader decides on, carried in the event. The
|
||||
// alternative -- and what this was -- is the phone reaching into
|
||||
// the tool call's input for the parts the event dropped, which
|
||||
// puts this dialect's schema where no other dialect can reach it.
|
||||
let options = question
|
||||
.get("options")
|
||||
.and_then(Value::as_array)
|
||||
@@ -499,12 +459,10 @@ impl Translator {
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false),
|
||||
// The call that is asking, so all of this draws as one
|
||||
// thing. It used to be `None` on the grounds that a
|
||||
// question the model asked is not permission for a
|
||||
// call -- true, and beside the point: the reader was
|
||||
// shown the AskUserQuestion call *and* its questions
|
||||
// as two separate cards for one event, and the call
|
||||
// itself said nothing they could act on.
|
||||
// thing. It used to be `None` on the grounds that a question
|
||||
// the model asked is not permission for a call -- true, and
|
||||
// beside the point: the reader was shown the AskUserQuestion
|
||||
// call *and* its questions as two separate cards.
|
||||
about: about.clone(),
|
||||
});
|
||||
questions.push(text);
|
||||
@@ -515,8 +473,8 @@ impl Translator {
|
||||
events.push(Event::Question {
|
||||
id: request_id.clone(),
|
||||
prompt: format!("Allow {tool_name}?\n{summary}"),
|
||||
// No header: the question is about the call it names, and
|
||||
// the phone draws it on that call's own row.
|
||||
// No header: the question is about the call it names, and the
|
||||
// phone draws it on that call's own row.
|
||||
header: None,
|
||||
options: vec![
|
||||
QuestionOption::plain("Allow"),
|
||||
@@ -541,13 +499,13 @@ impl Translator {
|
||||
events
|
||||
}
|
||||
|
||||
/// Applies one answer from the phone. Question ids are the control
|
||||
/// request id, suffixed `#i` for AskUserQuestion sub-questions.
|
||||
/// Applies one answer from the phone. Question ids are the control request
|
||||
/// id, suffixed `#i` for AskUserQuestion sub-questions.
|
||||
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
|
||||
// string-valued whatever the question, so several choices become
|
||||
// one line here rather than everything upstream pretending a
|
||||
// question can only ever have one answer.
|
||||
// string-valued whatever the question, so several choices become one
|
||||
// line here rather than everything upstream pretending a question can
|
||||
// only ever have one answer.
|
||||
let answer = answers.join(", ");
|
||||
let answer = answer.as_str();
|
||||
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
|
||||
/// saved into the session dir and referenced by an Image event (the
|
||||
/// phone fetches them from `/sessions/{id}/files/{ref}`). Replayed and
|
||||
/// `user` messages: tool results become ToolEnd, with any image parts saved
|
||||
/// into the session dir and referenced by an Image event. Replayed and
|
||||
/// synthetic user text is skipped -- the manager already recorded the
|
||||
/// user's side.
|
||||
fn translate_user(&self, message: &Value) -> Vec<Event> {
|
||||
// Only tool results are here. The CLI never echoes a person's own
|
||||
// message back on stdout -- measured, because the obvious way to
|
||||
// learn that a queued message had been taken was to watch for it
|
||||
// coming back -- so nothing in this function marks one as read.
|
||||
// The driver reports that itself, at the line it writes.
|
||||
// message back on stdout -- measured, because the obvious way to learn
|
||||
// that a queued message had been taken was to watch for it coming back
|
||||
// -- so the driver reports that itself, at the line it writes.
|
||||
let Some(content) = message["message"].get("content").and_then(Value::as_array) else {
|
||||
return Vec::new();
|
||||
};
|
||||
@@ -603,9 +559,8 @@ impl Translator {
|
||||
continue;
|
||||
}
|
||||
let mut texts = Vec::new();
|
||||
// Held until the call's id is in hand a few lines below: an
|
||||
// image is drawn under the call that produced it, so it has to
|
||||
// carry that id rather than merely arrive next to it.
|
||||
// Held until the call's id is in hand a few lines below: an image is
|
||||
// drawn under the call that produced it, so it has to carry that id.
|
||||
let mut images = Vec::new();
|
||||
match block.get("content") {
|
||||
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`.
|
||||
///
|
||||
/// The CLI omits these rather than sending them empty, but a caller that
|
||||
/// sends `""` means the same thing and should not produce a description
|
||||
/// that draws as a blank line.
|
||||
/// 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
|
||||
/// thing and should not produce a description that draws as a blank line.
|
||||
fn text_field(value: &Value, name: &str) -> Option<String> {
|
||||
value
|
||||
.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.
|
||||
///
|
||||
/// A free function rather than a method because the import replay needs
|
||||
/// exactly this too: a session's history carries the same image blocks as
|
||||
/// its live output, and a reader who can see a screenshot while it happens
|
||||
/// should still see it after a restart. Two copies of this would be two
|
||||
/// naming schemes for one directory.
|
||||
/// A free function rather than a method because the import replay needs exactly
|
||||
/// this too: a session's history carries the same image blocks as its live
|
||||
/// output. Two copies would be two naming schemes for one directory.
|
||||
pub(in crate::session) fn save_image(session_dir: &Path, part: &Value) -> Option<String> {
|
||||
let source = part.get("source")?;
|
||||
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
|
||||
.decode(data)
|
||||
.ok()?;
|
||||
// Screenshots are the overwhelming case, and they are PNG; an
|
||||
// unrecognized type is more likely a dialect change than a JPEG.
|
||||
// Screenshots are the overwhelming case and they are PNG; an unrecognized
|
||||
// type is more likely a dialect change than a JPEG.
|
||||
let extension = source
|
||||
.get("media_type")
|
||||
.and_then(Value::as_str)
|
||||
@@ -726,8 +677,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(translator.session_id.as_deref(), Some("5ecf21da-d53f"));
|
||||
// The resolved model, which is the point: a session launched with
|
||||
// `--model haiku` is reported by its full name here, and that is
|
||||
// the name the phone should be showing.
|
||||
// `--model haiku` is reported by its full name here.
|
||||
assert_eq!(
|
||||
events,
|
||||
vec![Event::Settings {
|
||||
@@ -749,8 +699,8 @@ mod tests {
|
||||
Setting::PermissionMode("plan".to_string()),
|
||||
);
|
||||
|
||||
// Success carries no model of its own -- measured on 2.1.237 --
|
||||
// so what was asked for is the only answer available.
|
||||
// Success carries no model of its own -- measured on 2.1.237 -- so what
|
||||
// was asked for is the only answer available.
|
||||
let events = translate_lines(
|
||||
&mut translator,
|
||||
&[
|
||||
@@ -765,9 +715,8 @@ mod tests {
|
||||
}]
|
||||
);
|
||||
|
||||
// A mode the CLI answers with a value of its own is taken from
|
||||
// that value: `auto` on the way in is `default` coming back, and
|
||||
// the request is not the answer.
|
||||
// A mode the CLI answers with a value of its own is taken from that
|
||||
// value: `auto` on the way in is `default` coming back.
|
||||
translator.expect_setting(
|
||||
"req-c".to_string(),
|
||||
Setting::PermissionMode("auto".to_string()),
|
||||
@@ -801,8 +750,8 @@ mod tests {
|
||||
}]
|
||||
);
|
||||
|
||||
// And neither request is still waiting: a second answer to either
|
||||
// id reports nothing at all.
|
||||
// And neither request is still waiting: a second answer to either id
|
||||
// reports nothing at all.
|
||||
let events = translate_lines(
|
||||
&mut translator,
|
||||
&[
|
||||
@@ -815,8 +764,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn a_mode_the_cli_announces_is_taken_from_the_announcement() {
|
||||
// The line it sends just after answering `set_permission_mode`,
|
||||
// which is also how a mode changed from the terminal arrives.
|
||||
// The line it sends just after answering `set_permission_mode`, which is
|
||||
// also how a mode changed from the terminal arrives.
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let mut translator = Translator::new(dir.path().to_path_buf());
|
||||
let events = translate_lines(
|
||||
@@ -916,8 +865,8 @@ mod tests {
|
||||
panic!("expected a question, got {events:?}");
|
||||
};
|
||||
assert_eq!(id, "req-1");
|
||||
// The call being asked about, so the phone draws the ask on that
|
||||
// tool's row instead of as a second card repeating its input.
|
||||
// The call being asked about, so the phone draws the ask on that tool's
|
||||
// row instead of as a second card repeating its input.
|
||||
assert_eq!(about.as_deref(), Some("toolu_03"));
|
||||
assert!(prompt.contains("Bash") && prompt.contains("rm -rf /tmp/x"));
|
||||
assert_eq!(labels(options), ["Allow", "Deny"]);
|
||||
@@ -1013,10 +962,9 @@ mod tests {
|
||||
#[test]
|
||||
fn a_question_carries_what_it_takes_to_answer_it() {
|
||||
// Descriptions and previews are what the reader decides on, and a
|
||||
// multi-select is how many answers the question takes. All of it
|
||||
// travels in the event: a phone that had to read this dialect's
|
||||
// tool input to find them would be the only place that knew how,
|
||||
// and no other provider could reach it.
|
||||
// multi-select is how many answers the question takes. All of it travels
|
||||
// in the event: a phone that had to read this dialect's tool input to
|
||||
// find them would be the only place that knew how.
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let mut translator = Translator::new(dir.path().to_path_buf());
|
||||
let events = translate_lines(
|
||||
@@ -1049,8 +997,8 @@ mod tests {
|
||||
.contains("dev-updater")
|
||||
);
|
||||
|
||||
// Two choices, one answer: the joining is this dialect's shape,
|
||||
// done where it is spoken. The CLI's answers map holds strings.
|
||||
// Two choices, one answer: the joining is this dialect's shape, done
|
||||
// where it is spoken. The CLI's answers map holds strings.
|
||||
let AnswerOutcome::Respond(response) = translator.answer(
|
||||
"req-9#0",
|
||||
&["Tool calls".to_string(), "Peer messages".to_string()],
|
||||
@@ -1078,8 +1026,8 @@ mod tests {
|
||||
panic!("expected an image event, got {events:?}");
|
||||
};
|
||||
assert!(image.ends_with(".png"));
|
||||
// Named as belonging to the call that produced it, so a phone draws
|
||||
// it under that row rather than beside it.
|
||||
// Named as belonging to the call that produced it, so a phone draws it
|
||||
// under that row rather than beside it.
|
||||
assert_eq!(about.as_deref(), Some("toolu_05"));
|
||||
let saved = dir.path().join("files").join(image);
|
||||
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.
|
||||
///
|
||||
/// The line is the real shape, taken from a real cross-session message
|
||||
/// sent to a real stream-json session on CLI 2.1.237 (2026-08-31) --
|
||||
/// including the `from` socket path, which is deliberately *not* what a
|
||||
/// reader is shown: the sending session's `name` is what they recognise
|
||||
/// it by. The `body` is the message as it was written; the content the
|
||||
/// model is given beside it wraps the same text in a preamble and a
|
||||
/// `<cross-session-message>` tag, which is written for the model rather
|
||||
/// than for a person.
|
||||
/// The line is the real shape, taken from a real cross-session message sent
|
||||
/// to a real stream-json session on 2.1.237 (2026-08-31) -- including the
|
||||
/// `from` socket path, which is deliberately *not* what a reader is shown:
|
||||
/// the sending session's `name` is what they recognise it by. The `body` is
|
||||
/// the message as written; the content the model is given wraps the same
|
||||
/// text in a preamble 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 turn it explains as the wire allows -- which is after the reply,
|
||||
/// not above it. See the comment at the callsite for why that is the
|
||||
/// best available position rather than an oversight.
|
||||
/// The note comes before the usage and the idle, so it sits as close to the
|
||||
/// turn it explains as the wire allows.
|
||||
#[test]
|
||||
fn a_turn_started_by_another_agent_records_who_and_what() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
@@ -1147,8 +1091,8 @@ mod tests {
|
||||
Event::PeerMessage {
|
||||
from: "ai-app-2-fb".to_string(),
|
||||
text: "Reply with just the word ACK.".to_string(),
|
||||
// Stamped by the pump, which is the only place that
|
||||
// knows what seq the turn started at.
|
||||
// Stamped by the pump, which is the only place that knows
|
||||
// what seq the turn started at.
|
||||
turn_start: None,
|
||||
},
|
||||
Event::UsageDelta {
|
||||
@@ -1162,9 +1106,9 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// And an ordinary turn does not, which is the half that decides
|
||||
/// whether the check above is a check or a rubber stamp. Measured over
|
||||
/// a real session's stdout: four results, no `origin` between them.
|
||||
/// And an ordinary turn does not, which is the half that decides whether
|
||||
/// the check above is a check or a rubber stamp. Measured over a real
|
||||
/// session's stdout: four results, no `origin` between them.
|
||||
#[test]
|
||||
fn an_ordinary_turn_carries_no_peer_note() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
@@ -1186,12 +1130,10 @@ mod tests {
|
||||
/// 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
|
||||
/// 2026-08-30. The result adds the turn up -- its
|
||||
/// `cache_read_input_tokens` of 40,211 is 14,259 and 25,952, the same
|
||||
/// conversation counted twice -- so reading the context off it would
|
||||
/// report a size the model never held, and by more the more tool calls
|
||||
/// a turn makes. The last message's three input figures are what it
|
||||
/// was holding when the turn ended.
|
||||
/// 2026-08-30. The result adds the turn up -- its `cache_read_input_tokens`
|
||||
/// of 40,211 is 14,259 and 25,952, the same conversation counted twice -- so
|
||||
/// reading the context off it would report a size the model never held, by
|
||||
/// more the more tool calls a turn makes.
|
||||
#[test]
|
||||
fn the_context_is_what_the_last_message_held_not_the_turn_added_up() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
@@ -1231,9 +1173,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn a_compaction_reports_its_start_and_what_it_recovered() {
|
||||
// Real lines (trimmed) from a 2.1.237 session driven through
|
||||
// `/compact`. Note the snake_case keys -- the CLI's transcript
|
||||
// file writes the same records in camelCase.
|
||||
// Real lines (trimmed) from a 2.1.237 session driven through `/compact`.
|
||||
// Note the snake_case keys -- the CLI's transcript file writes the same
|
||||
// records in camelCase.
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let mut translator = Translator::new(dir.path().to_path_buf());
|
||||
let events = translate_lines(
|
||||
@@ -1333,16 +1275,14 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Pressing Stop is not a failure, and the CLI cannot tell you which it
|
||||
/// was.
|
||||
/// Pressing Stop is not a failure, and the CLI cannot tell you which it was.
|
||||
///
|
||||
/// An interrupted turn arrives as exactly the same shape a broken one
|
||||
/// does -- `is_error` set, on a `result` -- so somebody who pressed the
|
||||
/// button was shown "the turn ended with an error" for doing what the
|
||||
/// button says. What separates the two is not in the line: it is that
|
||||
/// this side asked. The second half of this test is the one that
|
||||
/// matters, because the naive fix -- never reporting an error result --
|
||||
/// passes the first half and silences every genuine failure afterwards.
|
||||
/// An interrupted turn arrives as exactly the same shape a broken one does,
|
||||
/// so somebody who pressed the button was shown "the turn ended with an
|
||||
/// error". What separates the two is that this side asked. The second half
|
||||
/// of this test is the one that matters, because the naive fix -- never
|
||||
/// reporting an error result -- passes the first half and silences every
|
||||
/// genuine failure afterwards.
|
||||
#[test]
|
||||
fn a_turn_stopped_on_purpose_is_not_an_error() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
|
||||
+220
-335
@@ -9,26 +9,22 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
/// The name a session's image is stored and served under -- returned by
|
||||
/// `POST /attachments` for an upload, minted by a driver for one a tool
|
||||
/// produced, and fetched back from `/sessions/{id}/files/{ref}`. Both
|
||||
/// directions use the one id so the transcript renders them identically.
|
||||
/// The name a session's image is stored and served under -- minted for an
|
||||
/// upload or for one a tool produced, and fetched back from
|
||||
/// `/sessions/{id}/files/{ref}`. One id both directions, so the transcript
|
||||
/// renders them identically.
|
||||
pub type ImageRef = String;
|
||||
|
||||
/// The name an upload from the phone is stored and served under: an image
|
||||
/// is `<hex>.<extension>` and is an [`ImageRef`] like any other; any other
|
||||
/// file keeps its own name after the hex, `<hex>-<name>`, because the name
|
||||
/// is what the reader attached and what the session is told. The two are
|
||||
/// told apart by `crate::media::media_type_for`, which knows every image
|
||||
/// extension this server writes.
|
||||
/// The name an upload is stored and served under: an image is
|
||||
/// `<hex>.<extension>` and is an [`ImageRef`] like any other; any other file
|
||||
/// keeps its own name after the hex, `<hex>-<name>`, because the name is what
|
||||
/// the reader attached and what the session is told. Told apart by
|
||||
/// `crate::media::media_type_for`.
|
||||
pub type AttachmentRef = String;
|
||||
|
||||
/// One choice offered in answer to a [`Event::Question`].
|
||||
///
|
||||
/// More than a label because the reader is deciding, not confirming: what
|
||||
/// 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.
|
||||
/// One choice offered in answer to a [`Event::Question`]. More than a label
|
||||
/// because the reader is deciding rather than confirming: what an option
|
||||
/// means, and what picking it would produce, are what decide it.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct QuestionOption {
|
||||
@@ -42,7 +38,6 @@ pub struct QuestionOption {
|
||||
}
|
||||
|
||||
impl QuestionOption {
|
||||
/// An option that is only its label, which is most of them.
|
||||
pub fn plain(label: impl Into<String>) -> Self {
|
||||
Self {
|
||||
label: label.into(),
|
||||
@@ -52,70 +47,52 @@ impl QuestionOption {
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything a session can tell the outside world. Every event is
|
||||
/// appended to the session's transcript with a sequence number, then fanned
|
||||
/// out to SSE subscribers; the phone renders purely from this stream, so
|
||||
/// reconnecting is just "events after seq N" -- no separate history path
|
||||
/// to drift from the live one.
|
||||
/// Everything a session can tell the outside world. Every event is appended
|
||||
/// to the transcript with a sequence number, then fanned out to SSE
|
||||
/// subscribers, so reconnecting is just "events after seq N" -- no separate
|
||||
/// history path to drift from the live one.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
// `rename_all` renames the variants; `rename_all_fields` renames what is
|
||||
// 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
|
||||
// multi-word field went out as snake_case, the app looked for camelCase and
|
||||
// found nothing, and the event still rendered -- as the "no counts were
|
||||
// reported" case, which is a state it is allowed to be in. A wire mismatch
|
||||
// that lands on a plausible state is invisible; anything added below with a
|
||||
// two-word field would have hit the same thing.
|
||||
// here was one lowercase word until `pre_tokens` arrived, so a multi-word
|
||||
// field went out as snake_case, the app looked for camelCase and found
|
||||
// nothing, and the event still rendered -- as the "no counts reported" case,
|
||||
// which is a state it is allowed to be in.
|
||||
#[serde(
|
||||
tag = "type",
|
||||
rename_all = "camelCase",
|
||||
rename_all_fields = "camelCase"
|
||||
)]
|
||||
pub enum Event {
|
||||
/// What the user sent, written into the transcript by the manager (not
|
||||
/// by drivers) so every device renders the full conversation from the
|
||||
/// one stream. Recorded when the session reads the message, which is
|
||||
/// what `MessageTaken` reports.
|
||||
/// What the user sent, written into the transcript by the manager (not by
|
||||
/// drivers) so every device renders the conversation from one stream.
|
||||
/// Recorded when the session reads it, which is what `MessageTaken` reports.
|
||||
UserMessage {
|
||||
/// The [`Event::MessageQueued`] this resolves, when it waited.
|
||||
///
|
||||
/// A message sent between turns is read at once and never queued,
|
||||
/// so this is `None` for most of them. It is the pair to the id on
|
||||
/// `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.
|
||||
/// The [`Event::MessageQueued`] this resolves, when it waited. 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")]
|
||||
id: Option<String>,
|
||||
text: String,
|
||||
/// What was attached to it, by the ref the files route serves.
|
||||
///
|
||||
/// On the message rather than beside it. These used to be their own
|
||||
/// `Image` events emitted just before, which drew a person's
|
||||
/// screenshot as a row of its own floating above the bubble that
|
||||
/// 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.
|
||||
/// What was attached, by the ref the files route serves. On the
|
||||
/// message rather than beside it: these used to be their own `Image`
|
||||
/// events just before, which left the phone deciding from adjacency
|
||||
/// which message an image belonged to. `images` on disk until
|
||||
/// 2026-09-03, when files joined them; the alias reads the older rows.
|
||||
#[serde(default, alias = "images", skip_serializing_if = "Vec::is_empty")]
|
||||
attachments: Vec<AttachmentRef>,
|
||||
},
|
||||
/// A message accepted from the phone that the session cannot read yet.
|
||||
///
|
||||
/// Recorded, unlike the message itself, and that difference is the
|
||||
/// point. The *message* belongs in the transcript where the session
|
||||
/// read it -- see `MessageTaken` -- but something has to say it is
|
||||
/// waiting, and it has to be the server that says it: the phone used
|
||||
/// to remember its own outgoing messages, so leaving the session
|
||||
/// screen or restarting the app showed nothing pending when something
|
||||
/// was, which reads as "nothing queued" rather than "I have forgotten".
|
||||
/// Recorded, unlike the message itself, and that difference is the point:
|
||||
/// the message belongs in the transcript where the session read it, but
|
||||
/// something has to say it is waiting, and it has to be the server. The
|
||||
/// phone used to remember its own outgoing messages, so leaving the
|
||||
/// screen showed nothing pending when something was.
|
||||
///
|
||||
/// Carries no row of its own. It is resolved by the `UserMessage`
|
||||
/// bearing the same id, exactly as `CommandQueued` is resolved by
|
||||
/// `CommandSent`.
|
||||
/// Carries no row of its own; resolved by the `UserMessage` bearing the
|
||||
/// same id, as `CommandQueued` is resolved by `CommandSent`.
|
||||
MessageQueued {
|
||||
id: String,
|
||||
text: String,
|
||||
@@ -125,38 +102,31 @@ pub enum Event {
|
||||
#[serde(default, alias = "images", skip_serializing_if = "Vec::is_empty")]
|
||||
attachments: Vec<AttachmentRef>,
|
||||
},
|
||||
/// A message taken out of the queue before the session read it, by
|
||||
/// somebody tapping the bubble that was waiting for it.
|
||||
/// A message taken out of the queue before the session read it.
|
||||
///
|
||||
/// Recorded for the same reason `MessageQueued` is: the queue is the
|
||||
/// server's, so what is waiting has to be answerable from the
|
||||
/// transcript alone. Without it a phone that reconnects replays the
|
||||
/// `MessageQueued` and puts back a bubble for a message that will
|
||||
/// never arrive -- and nothing later would ever resolve it, since the
|
||||
/// `UserMessage` that normally does is exactly what is not coming.
|
||||
/// server's, so what is waiting has to be answerable from the transcript
|
||||
/// alone. Without it a phone that reconnects replays the `MessageQueued`
|
||||
/// and puts back a bubble nothing will ever resolve -- the `UserMessage`
|
||||
/// that normally does is exactly what is not coming.
|
||||
///
|
||||
/// Only ever sent for a message that had not been handed over. One
|
||||
/// that has is not droppable and says so instead; see
|
||||
/// Only ever sent for a message that had not been handed over; see
|
||||
/// [`Unqueued::AlreadySent`].
|
||||
MessageDropped {
|
||||
id: String,
|
||||
},
|
||||
/// A driver has taken one of the user's messages and started reading
|
||||
/// it. The manager turns this into the `UserMessage` above, so it
|
||||
/// never reaches a phone itself.
|
||||
/// A driver has taken one of the user's messages and started reading it.
|
||||
/// The manager turns this into the `UserMessage` above, so it never
|
||||
/// reaches a phone itself.
|
||||
///
|
||||
/// 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
|
||||
/// until then the session has not seen it -- so recording it among
|
||||
/// 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.
|
||||
/// message sent into a running turn waits, and recording it among things
|
||||
/// already read puts it in the transcript above output that predates it.
|
||||
MessageTaken {
|
||||
/// The `MessageQueued` this answers, or `None` when it never
|
||||
/// waited. Carried through onto the `UserMessage`.
|
||||
/// The `MessageQueued` this answers, or `None` when it never waited.
|
||||
/// Carried through onto the `UserMessage`.
|
||||
id: Option<String>,
|
||||
text: String,
|
||||
/// Carried through onto the `UserMessage` with everything else.
|
||||
#[serde(default, alias = "images", skip_serializing_if = "Vec::is_empty")]
|
||||
attachments: Vec<AttachmentRef>,
|
||||
},
|
||||
@@ -183,13 +153,10 @@ pub enum Event {
|
||||
Image {
|
||||
#[serde(rename = "ref")]
|
||||
image: ImageRef,
|
||||
/// The tool call whose result carried it, when one did.
|
||||
///
|
||||
/// A screenshot belongs under the call that took it, not floating
|
||||
/// beside it -- the reader has to pair them by position otherwise,
|
||||
/// 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.
|
||||
/// The tool call whose result carried it, when one did. A screenshot
|
||||
/// belongs under the call that took it, not floating beside it -- the
|
||||
/// reader has to pair them by position otherwise, and position is
|
||||
/// exactly what a page boundary breaks.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
about: Option<String>,
|
||||
},
|
||||
@@ -199,71 +166,54 @@ pub enum Event {
|
||||
id: String,
|
||||
prompt: String,
|
||||
/// A few words naming what the question is about, when the asker
|
||||
/// offered one -- a tag beside the question rather than part of
|
||||
/// it. `None` for a permission, which is about the call above it.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
/// offered one. `None` for a permission, which is about the call
|
||||
/// above it.
|
||||
header: Option<String>,
|
||||
options: Vec<QuestionOption>,
|
||||
/// Whether several options may be chosen at once.
|
||||
///
|
||||
/// Here rather than left for a phone to work out from the dialect
|
||||
/// underneath: how many answers a question takes is a fact about
|
||||
/// the question, and the alternative was the app parsing Claude
|
||||
/// 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.
|
||||
/// Whether several options may be chosen at once. Here rather than
|
||||
/// left for a phone to work out from the dialect underneath: how many
|
||||
/// answers a question takes is a fact about the question, and the
|
||||
/// alternative was Claude Code's tool-input schema written out a
|
||||
/// second time in Kotlin, where no other dialect could reach it.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
multi_select: bool,
|
||||
/// The tool call this is permission for, when it is one.
|
||||
///
|
||||
/// The CLI's `can_use_tool` request carries the `tool_use_id` of
|
||||
/// 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.
|
||||
/// 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
|
||||
/// card repeating its input. `None` for anything not about a tool.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
about: Option<String>,
|
||||
},
|
||||
/// A message another agent sent this session.
|
||||
///
|
||||
/// Its own kind rather than a `UserMessage`, because it is not
|
||||
/// something the reader said and a transcript that renders it in their
|
||||
/// voice is claiming they did. It also explains what would otherwise
|
||||
/// be inexplicable: a session that starts working on something nobody
|
||||
/// on this phone asked for.
|
||||
/// Its own kind rather than a `UserMessage`, because it is not something
|
||||
/// the reader said and a transcript that renders it in their voice is
|
||||
/// claiming they did. It also explains what would otherwise be
|
||||
/// inexplicable: a session working on something nobody here asked for.
|
||||
PeerMessage {
|
||||
/// The sending session's own name, which is what the reader
|
||||
/// recognises it by -- the socket path it came from is not.
|
||||
from: String,
|
||||
text: String,
|
||||
/// The seq of the `Status::Running` that opened the turn this
|
||||
/// message started, so a reader can draw it above that turn.
|
||||
/// The seq of the `Status::Running` that opened the turn this message
|
||||
/// started, so a reader can draw it above that turn.
|
||||
///
|
||||
/// It exists because the live Claude Code path cannot record the
|
||||
/// message where it belongs. The CLI says nothing about a peer
|
||||
/// message until the turn's `result` -- see
|
||||
/// `claude::translate` -- so the event is appended after
|
||||
/// 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.
|
||||
/// The CLI says nothing about a peer message until the turn's
|
||||
/// `result`, so the event is appended after 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 on screen.
|
||||
///
|
||||
/// Filled in by the pump, which is the only place that knows a
|
||||
/// seq, and only where a turn was open: `None` for a message read
|
||||
/// out of a session file by `import`, which already has it in the
|
||||
/// right place, and for one that started no turn.
|
||||
/// Filled in by the pump, the only place that knows a seq, and only
|
||||
/// where a turn was open: `None` for a message replayed by `import`,
|
||||
/// which already has it in the right place.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
turn_start: Option<u64>,
|
||||
},
|
||||
/// The manager's record of a question being answered, so a rendered
|
||||
/// question card resolves on every device, not just the one that
|
||||
/// answered it.
|
||||
/// question card resolves on every device rather than only the one that
|
||||
/// answered.
|
||||
///
|
||||
/// A list because a question can take several answers, and one that
|
||||
/// took 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.
|
||||
/// A list because a question can take several answers, and one that took
|
||||
/// one is the list of length one rather than a different shape.
|
||||
Answered {
|
||||
id: String,
|
||||
answers: Vec<String>,
|
||||
@@ -273,17 +223,13 @@ pub enum Event {
|
||||
},
|
||||
/// What the session is set to, as the session itself reports it.
|
||||
///
|
||||
/// Asking for a change and having one are different things, and only
|
||||
/// this one is a measurement: a model name the dialect does not know,
|
||||
/// a mode it refuses, or a driver whose model is fixed at startup all
|
||||
/// leave a request that was sent and nothing that changed. Reporting
|
||||
/// from the request instead put the answer on the phone before the
|
||||
/// question had been answered, and left it there when the answer was
|
||||
/// no.
|
||||
/// Asking for a change and having one are different things, and only this
|
||||
/// is a measurement: a model name the dialect does not know, a mode it
|
||||
/// refuses, or a driver whose model is fixed at startup all leave a
|
||||
/// request that was sent and nothing that changed. Reporting from the
|
||||
/// request put the answer on the phone before the question was answered.
|
||||
///
|
||||
/// Either field alone, because the two are confirmed separately and
|
||||
/// by different things -- the CLI echoes a mode change, and names the
|
||||
/// model it resolved an alias to when a session starts.
|
||||
/// Either field alone, because the two are confirmed separately.
|
||||
Settings {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
model: Option<String>,
|
||||
@@ -295,58 +241,44 @@ pub enum Event {
|
||||
/// What this turn cost: the tokens it was charged for.
|
||||
tokens: u64,
|
||||
/// 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,
|
||||
/// because it is not a sum: a conversation's context goes *down*
|
||||
/// at a compaction and a clear, so adding turns up would report a
|
||||
/// figure the session stopped being true of long ago. It is also
|
||||
/// the number a reader is asking about -- how much room is left
|
||||
/// before the next compaction -- rather than what has been spent
|
||||
/// getting here.
|
||||
/// Carried rather than summed by whoever is reading, because it is
|
||||
/// not a sum: context goes *down* at a compaction and a clear, so
|
||||
/// adding turns up would report a figure the session stopped being
|
||||
/// true of long ago.
|
||||
///
|
||||
/// `None` where the dialect did not say, which every reader has to
|
||||
/// be able to draw: a turn whose usage the CLI omitted leaves the
|
||||
/// context unmeasured rather than unchanged, and entries written
|
||||
/// before this existed have no answer at all.
|
||||
/// `None` where the dialect did not say, which every reader has to be
|
||||
/// able to draw.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
context: Option<u64>,
|
||||
},
|
||||
/// A compaction that finished, and how much context it recovered.
|
||||
///
|
||||
/// The counts are the point, and a spinner is not: what a reader wants
|
||||
/// afterwards is that the session went from a million tokens to ten
|
||||
/// thousand, which is measured rather than estimated. They are
|
||||
/// optional because the record has shipped without them, and "the
|
||||
/// 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.
|
||||
/// The counts are the point, and a spinner is not. They are optional
|
||||
/// because the record has shipped without them, and "the compaction
|
||||
/// happened, we don't know by how much" is a state this has to be able to
|
||||
/// say -- a plausible number would be indistinguishable from a counted one.
|
||||
Compacted {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pre_tokens: Option<u64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
post_tokens: Option<u64>,
|
||||
/// What asked for it, in the dialect's own word -- `auto` when the
|
||||
/// session compacted on its own. Carried rather than reduced to a
|
||||
/// bool so an unrecognised trigger stays unrecognised: an
|
||||
/// automatic compaction is the one worth naming, because it
|
||||
/// explains a wait nobody asked for, and defaulting the unknown
|
||||
/// case to "you asked for this" would explain it away.
|
||||
/// session compacted on its own. Carried rather than reduced to a bool
|
||||
/// so an unrecognised trigger stays unrecognised: an automatic
|
||||
/// compaction is the one worth naming, because it explains a wait
|
||||
/// nobody asked for.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
trigger: Option<String>,
|
||||
},
|
||||
/// A command the session was asked to run on itself, held because it
|
||||
/// cannot run yet.
|
||||
///
|
||||
/// These are not messages: `/compact` and `/rename` are instructions
|
||||
/// to the session about itself, and a session in the middle of a turn
|
||||
/// 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.
|
||||
/// cannot run yet. These are not messages: `/compact` and `/rename` are
|
||||
/// instructions about the session, and a session mid-turn reads a line
|
||||
/// written to it as something the model should see. So they wait, and
|
||||
/// this is what a phone draws while they do.
|
||||
CommandQueued {
|
||||
id: String,
|
||||
/// What to show for it: the command as a person would type it.
|
||||
text: String,
|
||||
},
|
||||
/// The same command, now handed to the session. Its [`CommandQueued`]
|
||||
@@ -356,73 +288,55 @@ pub enum Event {
|
||||
id: String,
|
||||
text: String,
|
||||
},
|
||||
/// The conversation was cleared: everything above this is still in
|
||||
/// the record but is no longer in the session's context.
|
||||
/// The conversation was cleared: everything above this is still in the
|
||||
/// record but is no longer in the session's context.
|
||||
///
|
||||
/// Nothing is deleted. A transcript is the thing a person scrolls
|
||||
/// back through, and a session that dropped its history from the
|
||||
/// 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.
|
||||
/// Nothing is deleted. A transcript is the thing a person scrolls back
|
||||
/// through, so this is a divider, not a truncation.
|
||||
///
|
||||
/// **Load-bearing, not decorative.** For any driver that rebuilds its
|
||||
/// conversation from the transcript, this marker decides what the
|
||||
/// model is given -- dropping it, or treating it as something only
|
||||
/// the phone draws, silently puts a cleared conversation back in
|
||||
/// front of the model at full cost. Today `llama::conversation` is
|
||||
/// the only fold that reads it, which is the reason to write this
|
||||
/// down rather than leave it to be inferred from a second example
|
||||
/// that does not exist yet.
|
||||
/// conversation from the transcript, this marker decides what the model
|
||||
/// is given -- dropping it, or treating it as something only the phone
|
||||
/// draws, silently puts a cleared conversation back in front of the model
|
||||
/// at full cost. Today `llama::conversation` is the only fold that reads
|
||||
/// it, which is why this is written down rather than left to be inferred
|
||||
/// from a second example that does not exist.
|
||||
Cleared,
|
||||
Error {
|
||||
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
|
||||
/// is cheaper but it is still one the model was given, so all three count;
|
||||
/// output is left out because it is what the turn produced rather than
|
||||
/// 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.
|
||||
/// One function so the definition cannot drift, because it is extracted 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.
|
||||
pub fn context_tokens(input: u64, cache_creation: u64, cache_read: u64) -> u64 {
|
||||
input + cache_creation + cache_read
|
||||
}
|
||||
|
||||
/// The context after `event`, given what it was before.
|
||||
///
|
||||
/// The whole rule in one place, because three readers need the same
|
||||
/// answer: the pump keeping a live session's figure, the transcript
|
||||
/// seeding it at startup, and the phone folding the same events into what
|
||||
/// it draws. Written here beside the events it reads so a fourth reader
|
||||
/// finds it.
|
||||
/// The whole rule in one place, because three readers need the same answer:
|
||||
/// the pump keeping a live session's figure, the transcript seeding it at
|
||||
/// startup, and the phone folding the same events into what it draws.
|
||||
///
|
||||
/// The two that *lower* it are the point. A clear takes the conversation
|
||||
/// away and a compaction replaces it with a summary, so a figure measured
|
||||
/// before either stopped being true at that moment -- and carrying it
|
||||
/// forward is how a session that had just been cleared went on reporting
|
||||
/// the context it no longer had.
|
||||
/// The two that *lower* it are the point. A clear takes the conversation away
|
||||
/// and a compaction replaces it with a summary, so a figure measured before
|
||||
/// either stopped being true at that moment -- and carrying it forward is how
|
||||
/// a session that had just been cleared went on reporting the context it no
|
||||
/// longer had.
|
||||
///
|
||||
/// `None` is "we don't know", which is a state 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.
|
||||
/// `None` is "we don't know", which each of them can reach.
|
||||
pub fn context_after(current: Option<u64>, event: &Event) -> Option<u64> {
|
||||
match event {
|
||||
// `or`, so a turn the dialect reported no usage for leaves the last
|
||||
// measurement standing: it is stale by a turn, which every context
|
||||
// figure is, rather than wrong.
|
||||
// measurement standing: stale by a turn, which every context figure
|
||||
// is, rather than wrong.
|
||||
Event::UsageDelta { context, .. } => context.or(current),
|
||||
Event::Compacted { post_tokens, .. } => *post_tokens,
|
||||
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.
|
||||
///
|
||||
/// A closed set rather than a string, because the two that are not
|
||||
/// dialect-specific have to reach every provider: compaction is a
|
||||
/// capability an llama session may one day have, and a name is this
|
||||
/// server's own. `Raw` is the escape for a dialect's own commands --
|
||||
/// `/context`, `/usage` -- which only the thing running the session can
|
||||
/// interpret.
|
||||
/// dialect-specific have to reach every provider: compaction is a capability
|
||||
/// an llama session may one day have, and a name is this server's own. `Raw`
|
||||
/// is the escape for a dialect's own commands, which only the thing running
|
||||
/// the session can interpret.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum SessionCommand {
|
||||
Compact,
|
||||
@@ -447,8 +360,8 @@ pub enum SessionCommand {
|
||||
}
|
||||
|
||||
impl SessionCommand {
|
||||
/// What a person would have typed to ask for this, which is what a
|
||||
/// phone shows while it waits.
|
||||
/// What a person would have typed to ask for this, which is what a phone
|
||||
/// shows while it waits.
|
||||
pub fn label(&self) -> String {
|
||||
match self {
|
||||
Self::Compact => "/compact".to_string(),
|
||||
@@ -477,32 +390,28 @@ pub enum SessionStatus {
|
||||
AwaitingInput,
|
||||
Compacting,
|
||||
Exited,
|
||||
/// There is a process recorded for this session and the machine will
|
||||
/// not say whether it is still running.
|
||||
/// There is a process recorded for this session and the machine will not
|
||||
/// say whether it is still running.
|
||||
///
|
||||
/// Its own state rather than the nearest of the others, because both
|
||||
/// neighbours are lies with consequences: `Exited` invites starting a
|
||||
/// second process against a conversation that may already have one,
|
||||
/// and `Idle` claims a session is waiting for you when nobody has
|
||||
/// checked. It resolves itself -- the driver keeps asking -- so what
|
||||
/// it means to a reader is "wait", not "act".
|
||||
/// second process against a conversation that may already have one, and
|
||||
/// `Idle` claims a session is waiting for you when nobody has checked.
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// What became of a request to take a queued message back.
|
||||
///
|
||||
/// Three states rather than a bool because the two failures are not the
|
||||
/// same fact. A driver that writes into its session the moment a message
|
||||
/// arrives -- which is what `ClaudeDriver` does, so that a steer reaches
|
||||
/// the model at the next tool boundary rather than at the end of the turn
|
||||
/// -- can never take one back, and a phone that was told only "no" would
|
||||
/// have to guess whether it had asked too late or asked about nothing.
|
||||
/// Three states rather than a bool because the two failures are not the same
|
||||
/// fact. A driver that writes into its session the moment a message arrives
|
||||
/// -- which is what `ClaudeDriver` does, so a steer reaches the model at the
|
||||
/// next tool boundary -- can never take one back, and a phone told only "no"
|
||||
/// would have to guess whether it asked too late or asked about nothing.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Unqueued {
|
||||
/// Out of the queue; the session will never read it.
|
||||
Dropped,
|
||||
/// Already handed to the session, so there is nothing left to take
|
||||
/// back. The message is on its way into the conversation.
|
||||
/// Already handed to the session, so there is nothing left to take back.
|
||||
AlreadySent,
|
||||
/// Nothing is waiting under that id.
|
||||
Unknown,
|
||||
@@ -522,110 +431,93 @@ pub type EventSink = mpsc::UnboundedSender<Event>;
|
||||
pub trait Driver: Send + Sync {
|
||||
/// Takes a message, now or once the session is free for it.
|
||||
///
|
||||
/// Every driver owes exactly one `MessageTaken` per message, at the
|
||||
/// moment it actually starts reading it: that event is what puts the
|
||||
/// message in the transcript, so a driver that never sends it drops
|
||||
/// the message from the conversation entirely.
|
||||
/// Every driver owes exactly one `MessageTaken` per message, at the moment
|
||||
/// it actually starts reading it: that event is what puts the message in
|
||||
/// the transcript, so a driver that never sends it drops the message from
|
||||
/// the conversation entirely.
|
||||
fn send_user_message(&self, text: String, attachments: Vec<AttachmentRef>);
|
||||
/// Takes back a message that is still waiting, named by the id its
|
||||
/// [`Event::MessageQueued`] carried.
|
||||
///
|
||||
/// Answering is the whole of the contract: a driver that drops the
|
||||
/// message owes an [`Event::MessageDropped`], and one that cannot must
|
||||
/// say which of the two reasons it is, because they are different
|
||||
/// things to a reader -- "the session has already been told" is worth
|
||||
/// 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 queue at all: nothing of yours is waiting.
|
||||
/// Answering is the whole of the contract: a driver that drops the message
|
||||
/// owes an [`Event::MessageDropped`], and one that cannot must say which
|
||||
/// of the two reasons it is -- "the session has already been told" is
|
||||
/// worth 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
|
||||
/// queue at all.
|
||||
fn unqueue(&self, _id: &str) -> Unqueued {
|
||||
Unqueued::Unknown
|
||||
}
|
||||
/// Answers one question with everything that was chosen, in the order
|
||||
/// it was offered. One answer is a list of one; a driver whose dialect
|
||||
/// takes a single value joins them where it writes it.
|
||||
/// Answers one question with everything that was chosen, in the order it
|
||||
/// was offered. A driver whose dialect takes a single value joins them
|
||||
/// where it writes it.
|
||||
fn answer_question(&self, id: &str, answers: &[String]);
|
||||
/// Stop mid-run; the session survives.
|
||||
fn interrupt(&self);
|
||||
fn set_model(&self, model: &str);
|
||||
/// How much the session asks about before acting. Live rather than
|
||||
/// spawn-only: the answer changes with what is being done, and a phone
|
||||
/// is the worst place to answer "may I run this?" forty times.
|
||||
/// spawn-only: the answer changes with what is being done, and a phone is
|
||||
/// the worst place to answer "may I run this?" forty times.
|
||||
fn set_permission_mode(&self, mode: &str);
|
||||
// Both of the above are requests, and neither reports the outcome by
|
||||
// returning. A driver that actually changes the setting owes an
|
||||
// [`Event::Settings`] once it has -- that event, and not the request,
|
||||
// is what the manager and the phone read. One that cannot change it
|
||||
// owes an [`Event::Error`] saying why; saying nothing leaves a phone
|
||||
// showing a setting nobody applied.
|
||||
// returning. A driver that changes the setting owes an [`Event::Settings`]
|
||||
// once it has -- that event, not the request, is what the manager and the
|
||||
// phone read. One that cannot owes an [`Event::Error`] saying why.
|
||||
|
||||
/// Tells the process what this conversation is called, when it has
|
||||
/// somewhere to put it.
|
||||
///
|
||||
/// Unlike the two above, this is not a request that can fail: the
|
||||
/// rename has already happened in this server's own config, which is
|
||||
/// what a phone lists and the only place the name has to be. So a
|
||||
/// driver whose process has no notion of a name does nothing here 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
|
||||
/// Unlike the two above, this is not a request that can fail: the rename
|
||||
/// has already happened in this server's config, which is what a phone
|
||||
/// lists. So a driver whose process has no notion of a name does nothing
|
||||
/// and says nothing. Claude Code has one: `--name` at creation and
|
||||
/// `/rename` afterwards, which is what puts the same name in its own
|
||||
/// session picker and in what other agents see.
|
||||
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`,
|
||||
/// anything a CLI adds next month. A driver whose process has 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 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.
|
||||
/// Called only when the session is between turns; the waiting is done
|
||||
/// above, once, for every driver.
|
||||
fn run_command(&self, text: &str);
|
||||
/// pi: native compaction; claude: `/compact`.
|
||||
/// llama: not built, and refused; claude: `/compact`.
|
||||
fn compact(&self);
|
||||
|
||||
/// Drops the conversation so far without ending the session.
|
||||
///
|
||||
/// The cheap half of managing a long session, and the reason it is a
|
||||
/// driver operation rather than a manager one: compaction *reads* the
|
||||
/// whole conversation in order to summarise it, so on a large context
|
||||
/// it is itself one of the most expensive requests the session will
|
||||
/// make -- measured at 1.7 million tokens for a single automatic
|
||||
/// compaction on 2026-08-29. Clearing costs nothing, because nothing
|
||||
/// is sent.
|
||||
/// The cheap half of managing a long session, and why it is a driver
|
||||
/// operation rather than a manager one: compaction *reads* the whole
|
||||
/// conversation in order to summarise it, so on a large context it is
|
||||
/// itself one of the most expensive requests the session will make --
|
||||
/// measured at 1.7 million tokens for one automatic compaction on
|
||||
/// 2026-08-29. Clearing costs nothing, because nothing is sent.
|
||||
///
|
||||
/// Every implementation emits [`Event::Cleared`] so the transcript
|
||||
/// carries the divider whatever the dialect did behind it.
|
||||
/// Every implementation emits [`Event::Cleared`] so the transcript carries
|
||||
/// the divider whatever the dialect did behind it.
|
||||
fn clear(&self);
|
||||
/// Stop attending to the process but leave it running, because this
|
||||
/// server is going away and means to adopt it again when it comes
|
||||
/// back.
|
||||
/// server is going away and means to adopt it again.
|
||||
///
|
||||
/// This is deliberately not a shutdown. A backend restart -- a
|
||||
/// rebuild, a service restart, a crash -- must not end a turn that is
|
||||
/// in flight, so a session's process outlives the server that started
|
||||
/// it and is found again through `session::process`. A driver with no
|
||||
/// process of its own has nothing to do here.
|
||||
/// Deliberately not a shutdown: a backend restart must not end a turn that
|
||||
/// is in flight, so a session's process outlives the server that started
|
||||
/// it and is found again through `session::process`.
|
||||
///
|
||||
/// Its counterpart is [`Driver::stop`]. Every driver owes exactly one
|
||||
/// of the two on the way out, and which one is the difference between
|
||||
/// "back shortly" and "this conversation is over".
|
||||
/// Whether a line written *now* would start a turn of its own, rather
|
||||
/// than landing inside one already in flight.
|
||||
/// Its counterpart is [`Driver::stop`]. Every driver owes exactly one of
|
||||
/// the two on the way out, and which one is the difference between "back
|
||||
/// shortly" and "this conversation is over".
|
||||
/// Whether a line written *now* would start a turn of its own, rather than
|
||||
/// landing inside one already in flight.
|
||||
///
|
||||
/// Asked of the driver because the driver is the only thing that knows:
|
||||
/// 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 manager's `SessionStatus` cannot answer it -- that is built from
|
||||
/// what has been *recorded*, so between writing a line and the CLI's
|
||||
/// first output it still reads idle, and a second line sent in that gap
|
||||
/// lands inside the turn the first one started. For a command that is
|
||||
/// the difference between being executed and being read to the model as
|
||||
/// text, which is silent both ways.
|
||||
/// Asked of the driver because the driver is the only thing that knows: it
|
||||
/// updates this the instant it writes rather than when output returns. The
|
||||
/// manager's `SessionStatus` is built from what has been *recorded*, so
|
||||
/// between writing a line and the CLI's first output it still reads idle,
|
||||
/// and a second line sent in that gap lands inside the turn the first one
|
||||
/// started. For a command that is 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.
|
||||
fn between_turns(&self) -> bool {
|
||||
@@ -633,16 +525,12 @@ pub trait Driver: Send + Sync {
|
||||
}
|
||||
|
||||
fn detach(&self);
|
||||
/// End the process for good, because it must not survive this. The
|
||||
/// path out for everything [`detach`] preserves.
|
||||
/// End the process for good, because it must not survive this. The path
|
||||
/// out for everything [`Driver::detach`] preserves.
|
||||
///
|
||||
/// Two callers, and the difference between them is only what is being
|
||||
/// ended: a session being deleted, whose conversation goes with it, and
|
||||
/// a throwaway session at a server's exit, whose transcript stays and
|
||||
/// whose process does not (see [`SessionConfig::throwaway`]).
|
||||
///
|
||||
/// [`detach`]: Driver::detach
|
||||
/// [`SessionConfig::throwaway`]: crate::config::SessionConfig::throwaway
|
||||
/// Two callers, differing only in what is being ended: a session being
|
||||
/// deleted, whose conversation goes with it, and a throwaway session at a
|
||||
/// server's exit, whose transcript stays and whose process does not.
|
||||
fn stop(&self);
|
||||
}
|
||||
|
||||
@@ -650,11 +538,10 @@ pub trait Driver: Send + Sync {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// 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 cannot find reads as a field the server
|
||||
/// chose not to send, which several of them are allowed to be.
|
||||
/// 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
|
||||
/// cannot find reads as a field the server chose not to send, which
|
||||
/// several of them are allowed to be.
|
||||
#[test]
|
||||
fn multi_word_fields_go_out_in_camel_case() {
|
||||
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
|
||||
/// fold: a figure measured before a compaction or a clear stopped being
|
||||
/// true at that moment, and carrying it forward is how a session that
|
||||
/// had just been cleared went on reporting the context it no longer
|
||||
/// had.
|
||||
/// The two events that take the context *down* are the point of the fold:
|
||||
/// a figure measured before a compaction or a clear stopped being true at
|
||||
/// that moment, and carrying it forward is how a session that had just
|
||||
/// been cleared went on reporting the context it no longer had.
|
||||
#[test]
|
||||
fn a_compaction_and_a_clear_move_the_context_a_turn_cannot() {
|
||||
let after = |current, event| context_after(current, &event);
|
||||
@@ -707,8 +593,7 @@ mod tests {
|
||||
assert_eq!(after(Some(9_617), Event::Cleared), None);
|
||||
|
||||
// A compaction that did not say how much it recovered leaves the
|
||||
// context unknown rather than stale: it definitely moved, and the
|
||||
// one thing that is certainly wrong is the figure from before it.
|
||||
// context unknown rather than stale: it definitely moved.
|
||||
assert_eq!(
|
||||
after(
|
||||
Some(128_402),
|
||||
|
||||
+176
-239
@@ -1,56 +1,44 @@
|
||||
//! The phase-1 fake driver: no child process, just events. It exists to
|
||||
//! prove the whole pipe -- spawn, transcript, SSE cursors, questions,
|
||||
//! interrupts, compaction -- before any AI is involved, and stays useful afterwards as
|
||||
//! a connectivity check that costs no tokens.
|
||||
//! The fake driver: no child process, just events. It proves the whole pipe --
|
||||
//! spawn, transcript, SSE cursors, questions, interrupts, compaction -- and
|
||||
//! stays useful afterwards as a connectivity check that costs no tokens. It
|
||||
//! 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.
|
||||
//! A leading word asks for something more specific:
|
||||
//! Every message is echoed back as a few streamed text deltas. A leading word
|
||||
//! asks for something more specific:
|
||||
//!
|
||||
//! - `/tool [input]` -- a full tool run, start through end.
|
||||
//! - `/bash [command]` -- a Bash call carrying that command, for what the
|
||||
//! phone's shell highlighting does to a particular line.
|
||||
//! - `/tools [n] [gap]` -- n calls back to back, for what a run of them
|
||||
//! looks like when a screen groups them. `gap` is seconds between one
|
||||
//! call and the next, default none: it is what makes a run *grow* while
|
||||
//! somebody is looking at it, which is the only way to reach the state
|
||||
//! where a call opened on its own gains a neighbour. The first call
|
||||
//! carries a screenshot, so that state can also be reached with an image
|
||||
//! open full screen -- which is where it used to close itself.
|
||||
//! - `/tools [n] [gap]` -- n calls back to back. `gap` is seconds between one
|
||||
//! call and the next, which is what makes a run *grow* while somebody is
|
||||
//! looking at it -- the only way to reach the state where a call opened on
|
||||
//! its own gains a neighbour. The first call carries a screenshot, so that
|
||||
//! state is also reachable with an image open full screen.
|
||||
//! - `/question [text]` -- a question, exercising the answer path.
|
||||
//! - `/ask` -- an AskUserQuestion call: two questions on one tool call,
|
||||
//! with descriptions, a preview and a multi-select, which is the shape
|
||||
//! that is awkward to get a real model to produce on demand. Wrapped in
|
||||
//! a run of ordinary calls on each side, because being asked something
|
||||
//! happens in the middle of work and the screen has to keep it out of
|
||||
//! the collapsed group around it.
|
||||
//! - `/slow [seconds]` -- a turn that stays running (default 30), so states that only
|
||||
//! exist *while* something is happening can be looked at.
|
||||
//! - `/ask` -- an AskUserQuestion call: two questions on one tool call, with
|
||||
//! descriptions, a preview and a multi-select, which is the shape that is
|
||||
//! awkward to get a real model to produce on demand. Wrapped in a run of
|
||||
//! ordinary calls on each side, because being asked something happens in the
|
||||
//! middle of work.
|
||||
//! - `/slow [seconds]` -- a turn that stays running (default 30), so states
|
||||
//! that only exist *while* something is happening can be looked at.
|
||||
//! - `/error [text]` -- a failure, which is otherwise awkward to cause.
|
||||
//! - `/peer [text]` -- a message from another agent, which otherwise takes
|
||||
//! two live sessions and one of them deciding to write.
|
||||
//! - `/compact` -- a compaction, start to finish. Typed rather than
|
||||
//! pressed, because the real dialects take it as a typed command too and
|
||||
//! the phone no longer has a button for it.
|
||||
//! - `/peer [text]`, `/peer-turn` -- a message from another agent, in the
|
||||
//! in-place and the live shapes.
|
||||
//! - `/compact` -- a compaction, start to finish.
|
||||
//! - `/stream N` -- one long answer in N small pieces, 50ms apart: the 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: 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
|
||||
//! that renders echo sessions correctly renders the real thing.
|
||||
//!
|
||||
//! - `/stream N` -- one long answer in N small pieces, 50ms apart: the
|
||||
//! 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.
|
||||
//! `/slow` earns its place: a queued message, a Stop button and a spinner are
|
||||
//! 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
|
||||
//! the state never arrives and the attempt still costs a turn.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
@@ -62,24 +50,18 @@ use super::driver::{
|
||||
};
|
||||
|
||||
/// Delay between streamed deltas -- long enough that streaming is visibly
|
||||
/// streaming in the UI, short enough that tests waiting on a full turn
|
||||
/// stay fast.
|
||||
/// streaming, short enough that tests waiting on a full turn stay fast.
|
||||
const DELTA_DELAY: Duration = Duration::from_millis(50);
|
||||
|
||||
/// 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 conversation, and a large one
|
||||
/// 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.
|
||||
/// 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
|
||||
/// conversation. Three seconds -- what this was -- is too short to look at the
|
||||
/// row that only exists while a compaction is running.
|
||||
const COMPACT_TIME: Duration = Duration::from_secs(13);
|
||||
|
||||
/// 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; `Some` for `/ask`, where several questions share one
|
||||
/// call and the call ends when the last of them is answered.
|
||||
/// 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;
|
||||
/// `Some` for `/ask`, where several questions share one call.
|
||||
struct PendingQuestion {
|
||||
id: String,
|
||||
call: Option<String>,
|
||||
@@ -89,37 +71,31 @@ pub struct EchoDriver {
|
||||
sink: EventSink,
|
||||
/// 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
|
||||
/// tool boundary; echo used to answer it on the spot, which made it
|
||||
/// the wrong shape for testing anything about queueing -- the status
|
||||
/// dropped to idle immediately, so a phone had nothing to show as
|
||||
/// pending. Holding it here is what makes echo able to stand in.
|
||||
/// A real CLI holds a message sent mid-turn and injects it at the next tool
|
||||
/// boundary; echo used to answer it on the spot, which made it the wrong
|
||||
/// shape for testing anything about queueing.
|
||||
busy: Arc<AtomicBool>,
|
||||
/// Held messages with the id of the `MessageQueued` each one announced,
|
||||
/// so the announcement can say which waiting bubble it resolves.
|
||||
/// Held messages with the id of the `MessageQueued` each one announced, so
|
||||
/// the announcement can say which waiting bubble it resolves.
|
||||
queued: Arc<Mutex<Vec<Held>>>,
|
||||
/// Where `/mixed` writes the attachments it references, which is the same
|
||||
/// directory the files route serves them from.
|
||||
session_dir: PathBuf,
|
||||
/// Ids of the questions awaiting an answer, in the order they were
|
||||
/// asked. A list because `/ask` puts up to four on one tool call, the
|
||||
/// way AskUserQuestion does, and the turn resumes when the last of
|
||||
/// them is answered rather than the first.
|
||||
/// Ids of the questions awaiting an answer, in the order asked. A list
|
||||
/// because `/ask` puts up to four on one tool call, and the turn resumes
|
||||
/// when the last is answered rather than the first.
|
||||
pending_questions: Mutex<Vec<PendingQuestion>>,
|
||||
/// A pretend context, so the status row has something that behaves the
|
||||
/// way a real one does: it grows with each turn, drops to what the
|
||||
/// compaction says it recovered, and a clear leaves it unmeasured. The
|
||||
/// numbers are invented like everything else here; what is real is
|
||||
/// which way they move.
|
||||
/// A pretend context, so the status row has something that behaves the way
|
||||
/// a real one does: it grows with each turn, drops to what the compaction
|
||||
/// says it recovered, and a clear leaves it unmeasured. What is real is
|
||||
/// which way the numbers move.
|
||||
context: Arc<AtomicU64>,
|
||||
}
|
||||
|
||||
impl EchoDriver {
|
||||
/// 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 group is a group -- and because the point of the
|
||||
/// fixture is what a question looks like with work around it.
|
||||
/// 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
|
||||
/// group is a group.
|
||||
fn some_calls(&self, label: &str) {
|
||||
for index in 0..3 {
|
||||
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.
|
||||
///
|
||||
/// Two questions on one call, because that is where the display is
|
||||
/// hardest and where it was wrong: one question with four options
|
||||
/// reads fine even when the options are laid out badly. Written out
|
||||
/// in full rather than generated so it carries the parts that are
|
||||
/// easy to leave out of a fixture -- a header, an option with a
|
||||
/// description, an option with a preview block, and a multi-select.
|
||||
/// Two questions on one call, because that is where the display is hardest
|
||||
/// and where it was wrong. Written out in full rather than generated so it
|
||||
/// carries the parts that are easy to leave out of a fixture -- a header, an
|
||||
/// option with a description, an option with a preview block, and a
|
||||
/// multi-select.
|
||||
fn ask_user_question(&self) {
|
||||
// Written once, in the shape the events carry, and turned into
|
||||
// the tool call's own input below -- the CLI sends both, and two
|
||||
// hand-written copies of one question would drift.
|
||||
// Written once, in the shape the events carry, and turned into the tool
|
||||
// call's own input below -- the CLI sends both, and two hand-written
|
||||
// copies of one question would drift.
|
||||
let asked = [
|
||||
(
|
||||
"Theme",
|
||||
@@ -236,8 +211,7 @@ impl EchoDriver {
|
||||
header: Some(header.to_string()),
|
||||
options,
|
||||
multi_select: multi,
|
||||
// The call that asked, so all of it draws as one thing --
|
||||
// which is the whole point of the fixture.
|
||||
// The call that asked, so all of it draws as one thing.
|
||||
about: Some(call.clone()),
|
||||
});
|
||||
}
|
||||
@@ -248,23 +222,21 @@ impl EchoDriver {
|
||||
|
||||
/// 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
|
||||
/// is announced with `MessageTaken`, which is what puts it in the
|
||||
/// transcript, and a command is not -- the manager has already
|
||||
/// recorded that one was sent, and saying so twice drew the same
|
||||
/// line in both colours.
|
||||
/// `announce` is the whole difference: a message is announced with
|
||||
/// `MessageTaken`, which is what puts it in the transcript, and a command is
|
||||
/// not -- the manager has already recorded that one was sent, and saying so
|
||||
/// twice drew the same line in both colours.
|
||||
fn handle(&self, text: String, attachments: Vec<AttachmentRef>, announce: bool) {
|
||||
let sink = self.sink.clone();
|
||||
|
||||
// Mid-turn messages are held rather than answered, the way a real
|
||||
// CLI holds them until the next tool boundary. Without this the
|
||||
// session went idle the instant one arrived, and every state that
|
||||
// only exists while something is queued was untestable.
|
||||
// Mid-turn messages are held rather than answered, the way a real CLI
|
||||
// holds them until the next tool boundary. Without this the session went
|
||||
// idle the instant one arrived, and every state that only exists while
|
||||
// something is queued was untestable.
|
||||
if self.busy.load(Ordering::SeqCst) {
|
||||
// The waiting is recorded, exactly as the real driver records
|
||||
// it: the phone draws its pending bubbles from the server, so
|
||||
// an echo session has to produce the same events or the states
|
||||
// it exists to exercise are not the app's real ones.
|
||||
// The waiting is recorded, exactly as the real driver records it:
|
||||
// the phone draws its pending bubbles from the server, so an echo
|
||||
// session has to produce the same events.
|
||||
let id = super::random_hex();
|
||||
self.queued
|
||||
.lock()
|
||||
@@ -280,17 +252,11 @@ impl EchoDriver {
|
||||
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
|
||||
// survive: the CLI says nothing about a peer message until the
|
||||
// turn's `result`, so the event arrives below the whole reply it
|
||||
// caused and the phone has to put it back. Checked before `/peer`,
|
||||
// which would otherwise take the rest of this word as the body.
|
||||
// survive: the CLI says nothing about a peer message until the turn's
|
||||
// `result`, so the event arrives below the whole reply it caused and the
|
||||
// phone has to put it back. Checked before `/peer`, which would
|
||||
// otherwise take the rest of this word as the body.
|
||||
if let Some(rest) = text.strip_prefix("/peer-turn") {
|
||||
if announce {
|
||||
self.emit(Event::MessageTaken {
|
||||
@@ -345,9 +311,9 @@ impl EchoDriver {
|
||||
return;
|
||||
}
|
||||
|
||||
// The same word the real CLI takes, so a phone drives both the same
|
||||
// way. `Driver::compact` is what the manager's own route calls;
|
||||
// this is the typed path onto it.
|
||||
// The same word the real CLI takes, so a phone drives both the same way.
|
||||
// `Driver::compact` is what the manager's route calls; this is the typed
|
||||
// path onto it.
|
||||
if text.trim() == "/compact" {
|
||||
if announce {
|
||||
self.emit(Event::MessageTaken {
|
||||
@@ -403,23 +369,20 @@ impl EchoDriver {
|
||||
return;
|
||||
}
|
||||
|
||||
// Checked before `/tool`, which is a prefix of it: matching the
|
||||
// shorter one first would read "/tools 4" as a single tool whose
|
||||
// input is "s 4".
|
||||
// Checked before `/tool`, which is a prefix of it: matching the shorter
|
||||
// one first would read "/tools 4" as a single tool whose input is "s 4".
|
||||
let many_tools = text.strip_prefix("/tools").map(|rest| {
|
||||
let mut words = rest.split_whitespace();
|
||||
// At least two, because one call is not a run of them and this
|
||||
// exists to produce a run.
|
||||
// At least two, because one call is not a run of them.
|
||||
let count = words
|
||||
.next()
|
||||
.and_then(|w| w.parse().ok())
|
||||
.unwrap_or(3usize)
|
||||
.clamp(2, 12);
|
||||
// How long to wait between calls, default none. A run that
|
||||
// arrives all at once cannot exercise anything about a run
|
||||
// *growing*: the case worth watching is a call somebody has
|
||||
// opened and is reading when the next one turns it into a
|
||||
// group, and 50ms apart is faster than anybody can open one.
|
||||
// How long to wait between calls, default none. A run that arrives
|
||||
// all at once cannot exercise a run *growing*: the case worth
|
||||
// watching is a call somebody has opened and is reading when the
|
||||
// next one turns it into a group.
|
||||
let gap = Duration::from_secs(
|
||||
words
|
||||
.next()
|
||||
@@ -438,21 +401,19 @@ impl EchoDriver {
|
||||
let run_bash = text
|
||||
.strip_prefix("/bash")
|
||||
.map(|rest| rest.trim().to_string());
|
||||
// Seconds to stay running before answering, default 30. Clamped
|
||||
// rather than trusted: this is a test affordance, and a session
|
||||
// pinned running for an hour by a typo is a worse outcome than a
|
||||
// short wait.
|
||||
// Seconds to stay running before answering, default 30. Clamped rather
|
||||
// than trusted: a session pinned running for an hour by a typo is a
|
||||
// worse outcome than a short wait.
|
||||
let stream = text
|
||||
.strip_prefix("/stream")
|
||||
.map(|rest| rest.trim().parse::<usize>().unwrap_or(400).clamp(1, 4000));
|
||||
let mixed = text
|
||||
.strip_prefix("/mixed")
|
||||
.map(|rest| rest.trim().parse::<usize>().unwrap_or(12).clamp(1, 400));
|
||||
// How many columns wide a fixture table should be, default six.
|
||||
// The count is the parameter because it is the thing the phone
|
||||
// has to react to: a narrow table lays itself out across the
|
||||
// screen and a wide one has to start scrolling sideways, and the
|
||||
// boundary between the two is where the layout is wrong.
|
||||
// How many columns wide a fixture table should be, default six. The
|
||||
// count is the parameter because it is what the phone has to react to: a
|
||||
// narrow table lays itself out across the screen and a wide one has to
|
||||
// scroll sideways, and the boundary is where the layout is wrong.
|
||||
let table = text
|
||||
.strip_prefix("/table")
|
||||
.map(|rest| rest.trim().parse::<usize>().unwrap_or(6).clamp(1, 12));
|
||||
@@ -472,10 +433,9 @@ impl EchoDriver {
|
||||
let _ = sink.send(event);
|
||||
};
|
||||
let finish = || finish_turn(&sink, &queued, &busy);
|
||||
// Echo takes a message the instant it gets one, but it says so
|
||||
// anyway: a driver that skips this leaves the phone holding a
|
||||
// message it thinks is still queued, and the point of an echo
|
||||
// provider is that it behaves like the real ones.
|
||||
// Echo takes a message the instant it gets one, but says so anyway:
|
||||
// a driver that skips this leaves the phone holding a message it
|
||||
// thinks is still queued.
|
||||
if announce {
|
||||
send(Event::MessageTaken {
|
||||
id: None,
|
||||
@@ -488,8 +448,7 @@ impl EchoDriver {
|
||||
});
|
||||
|
||||
if let Some(linger) = linger {
|
||||
// A delta a second: visibly alive rather than merely slow,
|
||||
// which is what the states being looked at accompany.
|
||||
// A delta a second: visibly alive rather than merely slow.
|
||||
let seconds = linger.as_secs();
|
||||
for remaining in (1..=seconds).rev() {
|
||||
send(Event::AssistantText {
|
||||
@@ -532,16 +491,14 @@ impl EchoDriver {
|
||||
"timeout": 5000,
|
||||
}),
|
||||
});
|
||||
// The first call carries a screenshot, and only the
|
||||
// first. That is what makes this rig cover the case a
|
||||
// growing run is actually about: an image opened full
|
||||
// screen from a call that is alone, and then a second
|
||||
// call arriving and turning that row into a group. The
|
||||
// dialog used to be inside the row, so the reader was
|
||||
// thrown back to the transcript by the session making
|
||||
// another tool call. Any of the calls would do; the
|
||||
// first is the one that is on its own for a whole
|
||||
// `gap`, which is the window somebody can open it in.
|
||||
// The first call carries a screenshot, and only the first.
|
||||
// That is what makes this rig cover the case a growing run
|
||||
// is about: an image opened full screen from a call that is
|
||||
// alone, and then a second call turning that row into a
|
||||
// group. The dialog used to be inside the row, so the reader
|
||||
// was thrown back to the transcript by the session making
|
||||
// another tool call. The first call is the one that is on
|
||||
// its own for a whole `gap`.
|
||||
if i == 1 {
|
||||
let part = serde_json::json!({
|
||||
"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
|
||||
// model does and what `/slow` does not: `/slow` emits a line a
|
||||
// second, so its message grows in steps a reader can watch one
|
||||
// at a time. A jump caused by the *anchor row itself* changing
|
||||
// height needs growth that is continuous.
|
||||
// second, so its message grows in steps a reader can watch one at a
|
||||
// time. A jump caused by the *anchor row itself* changing height
|
||||
// needs growth that is continuous.
|
||||
if let Some(pieces) = stream {
|
||||
for i in 0..pieces {
|
||||
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(' ') {
|
||||
send(Event::AssistantText {
|
||||
delta: word.to_string(),
|
||||
@@ -640,8 +596,7 @@ impl EchoDriver {
|
||||
tokio::time::sleep(DELTA_DELAY).await;
|
||||
}
|
||||
// A conversation gets bigger, so the pretend context does too:
|
||||
// roughly a hundred tokens a turn plus the words themselves,
|
||||
// which is enough to watch it climb between compactions.
|
||||
// roughly a hundred tokens a turn plus the words themselves.
|
||||
let spent = text.split_whitespace().count() as u64;
|
||||
send(Event::UsageDelta {
|
||||
tokens: spent,
|
||||
@@ -667,36 +622,32 @@ impl EchoDriver {
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// report to.
|
||||
/// means the session is being torn down.
|
||||
fn emit(&self, event: Event) {
|
||||
let _ = self.sink.send(event);
|
||||
}
|
||||
}
|
||||
|
||||
/// A 16x10 checkerboard, the smallest thing that is recognisably an image
|
||||
/// rather than a blank rectangle.
|
||||
///
|
||||
/// Embedded rather than generated because the alternative is a PNG encoder
|
||||
/// 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.
|
||||
/// A 16x10 checkerboard, the smallest thing recognisably an image rather than a
|
||||
/// 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
|
||||
/// that it occupies an image's worth of space.
|
||||
const SAMPLE_PNG: &str = "iVBORw0KGgoAAAANSUhEUgAAABAAAAAKCAIAAAAy3EnLAAAAIklEQVR42mPo3PILiOTk9ICIGDYDyRqIVwphk65h1A9EsAGCYdJRj+JH4wAAAABJRU5ErkJggg==";
|
||||
|
||||
/// One beat of `/mixed`: a row shape chosen by position, so the same N
|
||||
/// always produces the same transcript.
|
||||
/// One beat of `/mixed`: a row shape chosen by position, so the same N always
|
||||
/// produces the same transcript.
|
||||
///
|
||||
/// Repeatable on purpose. A scrolling fault is judged by watching the same
|
||||
/// content behave differently, and a rig that produced a different
|
||||
/// transcript each run would make every comparison an argument about
|
||||
/// whether the content changed.
|
||||
/// content behave differently, and a rig that produced a different transcript
|
||||
/// each run would make every comparison an argument about whether the content
|
||||
/// changed.
|
||||
async fn write_beat(sink: &EventSink, session_dir: &Path, beat: usize) {
|
||||
let send = |event: Event| {
|
||||
let _ = sink.send(event);
|
||||
};
|
||||
match beat % 5 {
|
||||
// A paragraph, of three lengths, because a list of uniform rows
|
||||
// hides exactly the faults that uneven ones expose.
|
||||
// A paragraph, of three lengths, because a list of uniform rows hides
|
||||
// exactly the faults that uneven ones expose.
|
||||
1 => {
|
||||
let words = match beat % 3 {
|
||||
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
|
||||
// position, so no two lines wrap the same way. A paragraph of
|
||||
// uniform tokens is a wall that looks identical at every
|
||||
// offset, which makes it impossible to tell a scroll of one
|
||||
// line from a scroll of ten -- by eye or by comparing frames.
|
||||
// uniform tokens looks identical at every offset, which makes it
|
||||
// impossible to tell a scroll of one line from a scroll of ten.
|
||||
let body: String = (0..words)
|
||||
.map(|w| {
|
||||
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"),
|
||||
});
|
||||
}
|
||||
// A run of three, which the app folds into one collapsed group --
|
||||
// the row whose identity depends on what is next to it.
|
||||
// A run of three, which the app folds into one collapsed group -- the
|
||||
// row whose identity depends on what is next to it.
|
||||
3 => {
|
||||
for i in 1..=3 {
|
||||
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
|
||||
// than composing the whole run in one frame -- which is the condition
|
||||
// a scrolling fault actually happens under.
|
||||
// Slow enough that the phone renders each beat as it arrives rather than
|
||||
// composing the whole run in one frame -- which is the condition a scrolling
|
||||
// fault actually happens under.
|
||||
tokio::time::sleep(Duration::from_millis(120)).await;
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// it. All three, because all three are what the `MessageTaken` at the other
|
||||
/// end owes -- named rather than written out at each of the four places that
|
||||
/// mention it.
|
||||
/// `MessageQueued` that announced it, what it said, and what was attached. All
|
||||
/// three, because all three are what the `MessageTaken` at the other end owes.
|
||||
type Held = (String, String, Vec<AttachmentRef>);
|
||||
|
||||
/// 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
|
||||
/// off with an ellipsis, and a cut cell looks exactly like a short one, so
|
||||
/// a fixture of tidy one-word values would have rendered perfectly while
|
||||
/// the defect was still there. The column count is what decides whether
|
||||
/// the table fits the screen or has to scroll sideways.
|
||||
/// Both halves matter. Long cells are what the renderer used to cut off with an
|
||||
/// ellipsis, and a cut cell looks exactly like a short one, so a fixture of
|
||||
/// tidy one-word values would have rendered perfectly while the defect was
|
||||
/// still there. The column count decides whether the table fits the screen.
|
||||
///
|
||||
/// Written out as markdown rather than assembled from a grid type because
|
||||
/// what is being tested is the renderer's parse of the syntax a model
|
||||
/// actually writes, pipes and alignment row included.
|
||||
/// Written out as markdown rather than assembled from a grid type because what
|
||||
/// is being tested is the renderer's parse of the syntax a model actually
|
||||
/// writes, pipes and alignment row included.
|
||||
fn markdown_table(columns: usize) -> String {
|
||||
let headings = [
|
||||
"What it is",
|
||||
@@ -854,17 +801,16 @@ fn markdown_table(columns: usize) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// Ending a turn is also when anything held during it is taken up -- the
|
||||
/// moment a real CLI would have injected it. One place, because a turn has
|
||||
/// several ways to end (a reply, an interrupt, a compaction) and every one
|
||||
/// of them owes the same answer.
|
||||
/// Ending a turn is also when anything held during it is taken up -- the moment
|
||||
/// a real CLI would have injected it. One place, because a turn has several
|
||||
/// ways to end (a reply, an interrupt, a compaction) and every one of them owes
|
||||
/// the same answer.
|
||||
fn finish_turn(sink: &EventSink, queued: &Mutex<Vec<Held>>, busy: &AtomicBool) {
|
||||
let held = std::mem::take(&mut *queued.lock().unwrap());
|
||||
for (id, text, attachments) in held {
|
||||
// Announced before it is answered, in that order: a phone showing
|
||||
// the message as pending needs the signal that it has been read,
|
||||
// and the answer is meaningless above a message still drawn as
|
||||
// waiting.
|
||||
// Announced before it is answered, in that order: a phone showing the
|
||||
// message as pending needs the signal that it has been read, and the
|
||||
// answer is meaningless above a message still drawn as waiting.
|
||||
let _ = sink.send(Event::MessageTaken {
|
||||
id: Some(id),
|
||||
text: text.clone(),
|
||||
@@ -885,12 +831,10 @@ impl Driver for EchoDriver {
|
||||
!self.busy.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// Really droppable, which is what makes this the rig for the phone's
|
||||
/// side of it: the held message is this driver's own and nothing has
|
||||
/// been written anywhere, so a tap here exercises the whole path
|
||||
/// through to the bubble disappearing on every device. The Claude
|
||||
/// driver can only ever refuse -- see its own `unqueue` -- so it
|
||||
/// cannot exercise the case where the drop succeeds.
|
||||
/// Really droppable, which is what makes this the rig for the phone's side
|
||||
/// of it: the held message is this driver's own and nothing has been written
|
||||
/// anywhere, so a tap here exercises the whole path through to the bubble
|
||||
/// disappearing on every device. The Claude driver can only ever refuse.
|
||||
fn unqueue(&self, id: &str) -> Unqueued {
|
||||
let mut queued = self.queued.lock().unwrap();
|
||||
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>) {
|
||||
// Announced, because this is a message: every driver owes exactly
|
||||
// one `MessageTaken` per message, and one that quietly vanishes
|
||||
// from the transcript is the thing echo must not model. A command
|
||||
// owes none -- the manager has already recorded that it was sent,
|
||||
// and announcing it again drew the same line twice, once in each
|
||||
// colour.
|
||||
// Announced, because this is a message: every driver owes exactly one
|
||||
// `MessageTaken` per message, and one that quietly vanishes from the
|
||||
// transcript is the thing echo must not model. A command owes none.
|
||||
self.handle(text, attachments, true);
|
||||
}
|
||||
|
||||
/// Echo's commands *are* its messages -- `/tool`, `/slow`, `/ask` --
|
||||
/// so this is the same path with the same parsing, and the fixture
|
||||
/// behaves like a real session driven the same way.
|
||||
/// Echo's commands *are* its messages -- `/tool`, `/slow`, `/ask` -- so
|
||||
/// this is the same path with the same parsing, and the fixture behaves like
|
||||
/// a real session driven the same way.
|
||||
fn run_command(&self, text: &str) {
|
||||
self.handle(text.to_string(), Vec::new(), false);
|
||||
}
|
||||
@@ -930,8 +871,8 @@ impl Driver for EchoDriver {
|
||||
return;
|
||||
};
|
||||
let answered = pending.remove(at);
|
||||
// Whether anything on the same call is still unanswered: a
|
||||
// tool that asked four questions ends once, not four times.
|
||||
// Whether anything on the same call is still unanswered: a tool that
|
||||
// asked four questions ends once, not four times.
|
||||
let waiting = answered
|
||||
.call
|
||||
.as_ref()
|
||||
@@ -946,9 +887,9 @@ impl Driver for EchoDriver {
|
||||
id: call,
|
||||
output: format!("answered: {answer}"),
|
||||
});
|
||||
// The work carries on where it left off, which is what makes
|
||||
// the asked-here row a boundary with a group on each side
|
||||
// rather than the last thing in the turn.
|
||||
// The work carries on where it left off, which is what makes the
|
||||
// asked-here row a boundary with a group on each side rather than
|
||||
// the last thing in the turn.
|
||||
self.some_calls("after");
|
||||
} else {
|
||||
self.emit(Event::AssistantText {
|
||||
@@ -961,17 +902,16 @@ impl Driver for EchoDriver {
|
||||
}
|
||||
|
||||
fn interrupt(&self) {
|
||||
// Nothing real to stop; a pending question is abandoned so the
|
||||
// session isn't stuck awaiting input forever.
|
||||
// Nothing real to stop; a pending question is abandoned so the session
|
||||
// isn't stuck awaiting input forever.
|
||||
self.pending_questions.lock().unwrap().clear();
|
||||
self.emit(Event::Status {
|
||||
state: SessionStatus::Idle,
|
||||
});
|
||||
}
|
||||
|
||||
// Nothing to forward: this process has no notion of what the
|
||||
// conversation is called, and the rename it belongs to has already
|
||||
// happened where the name lives. See `Driver::set_title`.
|
||||
// Nothing to forward: this process has no notion of what the conversation
|
||||
// is called, and the rename has already happened where the name lives.
|
||||
fn set_title(&self, _title: &str) {}
|
||||
|
||||
fn set_permission_mode(&self, mode: &str) {
|
||||
@@ -986,14 +926,11 @@ impl Driver for EchoDriver {
|
||||
});
|
||||
}
|
||||
|
||||
/// A compaction with nothing to compact.
|
||||
///
|
||||
/// The counts are invented, like everything else this driver says --
|
||||
/// what is real is the shape and the order: busy, a pause long enough
|
||||
/// to see, then the result. `Compacting` and `Compacted` are states a
|
||||
/// 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.
|
||||
/// A compaction with nothing to compact. The counts are invented, like
|
||||
/// everything else this driver says -- what is real is the shape and the
|
||||
/// order: busy, a pause long enough to see, then the result. The only other
|
||||
/// way to reach those states is to fill a real session's context and spend
|
||||
/// two minutes of somebody's account getting it back.
|
||||
fn compact(&self) {
|
||||
let sink = self.sink.clone();
|
||||
let queued = Arc::clone(&self.queued);
|
||||
@@ -1005,10 +942,10 @@ impl Driver for EchoDriver {
|
||||
state: SessionStatus::Compacting,
|
||||
});
|
||||
tokio::time::sleep(COMPACT_TIME).await;
|
||||
// What it says it recovered is what the pretend context becomes,
|
||||
// so the figure on the status row and the one on the divider
|
||||
// agree -- two numbers about the same moment disagreeing is the
|
||||
// thing this rig exists to catch.
|
||||
// What it says it recovered is what the pretend context becomes, so
|
||||
// the figure on the status row and the one on the divider agree --
|
||||
// two numbers about the same moment disagreeing is the thing this
|
||||
// rig exists to catch.
|
||||
context.store(9_617, Ordering::SeqCst);
|
||||
let _ = sink.send(Event::Compacted {
|
||||
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.
|
||||
///
|
||||
/// The imported conversation is for reading; *continuing* it is the CLI's
|
||||
/// job through `--resume`, and it reads the whole file itself regardless
|
||||
/// of what is shown here. So this is a display budget, not a fidelity one
|
||||
/// -- and it needs to be a budget, because these files reach tens of
|
||||
/// 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.
|
||||
/// The imported conversation is for reading; *continuing* it is the CLI's job
|
||||
/// through `--resume`, and it reads the whole file itself. So this is a
|
||||
/// display budget, and it needs to be one: these files reach tens of megabytes
|
||||
/// and every line would otherwise cross a WireGuard link to a phone.
|
||||
const REPLAY_LINES: usize = 2000;
|
||||
|
||||
/// Whether a session is open in a CLI somewhere.
|
||||
///
|
||||
/// Three answers, because "nobody could check" is not "nobody is using
|
||||
/// 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.
|
||||
/// Whether a session is open in a CLI somewhere. Three answers, because
|
||||
/// "nobody could check" is not "nobody is using it" -- collapsing them puts
|
||||
/// the dangerous case behind the safe word.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum InUse {
|
||||
@@ -49,8 +44,8 @@ pub enum InUse {
|
||||
No,
|
||||
/// Checked, and a live CLI has it open.
|
||||
Yes,
|
||||
/// The machine does not keep the record this is read from, so there is
|
||||
/// no answer to be had -- not an answer of "no".
|
||||
/// The machine does not keep the record this is read from, so there is no
|
||||
/// answer to be had -- not an answer of "no".
|
||||
Unknown,
|
||||
}
|
||||
|
||||
@@ -61,113 +56,87 @@ pub struct Importable {
|
||||
/// The CLI's own session id, which is both the file name and the
|
||||
/// `--resume` token.
|
||||
pub id: String,
|
||||
/// Where that session was working, offered as the imported session's
|
||||
/// cwd so it resumes pointing at the same tree.
|
||||
/// Where that session was working, offered as the imported session's cwd
|
||||
/// so it resumes pointing at the same tree.
|
||||
pub cwd: String,
|
||||
/// The first thing a person said in it, for recognising it in a list.
|
||||
pub title: String,
|
||||
/// Epoch seconds, for ordering by "what I was last doing".
|
||||
pub modified: f64,
|
||||
pub lines: usize,
|
||||
/// How many tokens the model was holding at the last turn.
|
||||
///
|
||||
/// The input side of the most recent assistant message's usage --
|
||||
/// prompt plus both cache figures -- which is the closest thing to
|
||||
/// "what continuing this costs", and unlike the size it is a number
|
||||
/// the CLI itself recorded rather than one inferred from the file.
|
||||
/// 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
|
||||
/// to "what continuing this costs" and is a number the CLI recorded rather
|
||||
/// than one inferred from the file.
|
||||
///
|
||||
/// Size and this disagree in the direction that matters. Most of a big
|
||||
/// transcript is usually history from before a compaction, which the
|
||||
/// model is no longer given: of the 133 MB session behind the
|
||||
/// 2026-08-29 incident, 99% of the bytes sat before its last
|
||||
/// 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.
|
||||
/// transcript is usually history from before a compaction, which the model
|
||||
/// is no longer given: of the 133 MB session behind the 2026-08-29
|
||||
/// incident, 99% of the bytes sat before its last compaction summary.
|
||||
///
|
||||
/// `None` when no assistant turn has recorded usage yet -- which is
|
||||
/// not zero, and is why this is an option rather than a default.
|
||||
/// `None` when no assistant turn has recorded usage yet -- which is not
|
||||
/// zero, and is why this is an option.
|
||||
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
|
||||
/// 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, 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.
|
||||
/// Shown rather than warned about: importing a large session is a choice
|
||||
/// somebody is entitled to make.
|
||||
pub bytes: u64,
|
||||
/// Whether [`title`](Self::title) is a name somebody chose rather than
|
||||
/// something read out of the conversation. Sorted on, and worth the
|
||||
/// reader knowing: a name is a claim about what a session *is*, and a
|
||||
/// last message is only the last thing that happened in it.
|
||||
/// something read out of the conversation. Worth the reader knowing: a name
|
||||
/// is a claim about what a session *is*, and a last message is only the
|
||||
/// last thing that happened in it.
|
||||
pub named: bool,
|
||||
/// Whether a CLI is running this session right now.
|
||||
///
|
||||
/// The load-bearing field on this struct. Importing a session that is
|
||||
/// already open puts a second `--resume` on one file: the whole
|
||||
/// conversation gets duplicated into it, both copies then read each
|
||||
/// other's writes as work done elsewhere, and the adopted one is
|
||||
/// billed for re-reading everything -- measured on 2026-08-29 at 65 MB
|
||||
/// and 154 screenshots, from importing the session the importing agent
|
||||
/// was itself running in.
|
||||
/// The load-bearing field on this struct. Importing a session already open
|
||||
/// puts a second `--resume` on one file: the conversation gets duplicated
|
||||
/// into it, both copies read each other's writes as work done elsewhere,
|
||||
/// and the adopted one is billed for re-reading everything -- measured on
|
||||
/// 2026-08-29 at 65 MB and 154 screenshots.
|
||||
pub in_use: InUse,
|
||||
/// Where it lives. Not serialized: the phone chooses by id and the
|
||||
/// server resolves the path, so a path never crosses the wire in
|
||||
/// either direction.
|
||||
/// Where it lives. Not serialized: the phone chooses by id and the server
|
||||
/// resolves the path, so a path never crosses the wire either direction.
|
||||
#[serde(skip)]
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
/// Asks `transport`'s machine which Claude Code sessions it has.
|
||||
///
|
||||
/// One command rather than one per file, for the reason `setups::discover`
|
||||
/// gives: over ssh each would be its own connection and handshake.
|
||||
///
|
||||
/// `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.
|
||||
/// One command rather than one per file: over ssh each would be its own
|
||||
/// connection and handshake. `stat -c` is GNU-specific, which is the thing to
|
||||
/// change first if this ever meets a BSD.
|
||||
pub async fn list(transport: &Transport) -> Result<Vec<Importable>> {
|
||||
// Which sessions are open right now, before the files themselves.
|
||||
//
|
||||
// Claude Code writes a descriptor per live session at
|
||||
// `~/.claude/sessions/<pid>.json`, and the pid is the file name. It
|
||||
// also records `procStart` -- the kernel's start time for that pid --
|
||||
// for the same reason `session::process` does: a pid on its own is
|
||||
// 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 number. Checking both is what makes this a measurement.
|
||||
// `~/.claude/sessions/<pid>.json`, and records `procStart` -- the kernel's
|
||||
// start time for that pid -- for the same reason `session::process` does: a
|
||||
// pid on its own is reused, so a descriptor left by a crashed CLI would
|
||||
// otherwise mark a session as open for as long as something else held its
|
||||
// number. Checking both is what makes this a measurement.
|
||||
//
|
||||
// The `LIVEKNOWN` line says the directory was there to be read at
|
||||
// all. Without it an old CLI that keeps no descriptors would look
|
||||
// exactly like a machine with nothing running, which is the one
|
||||
// mistake this check exists to prevent.
|
||||
// The `LIVEKNOWN` line says the directory was there to be read at all.
|
||||
// Without it an old CLI that keeps no descriptors would look exactly like a
|
||||
// machine with nothing running.
|
||||
//
|
||||
// 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`
|
||||
// record, and a name somebody chose beats anything inferred from the
|
||||
// conversation. 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*, not the
|
||||
// first: the question a list like this answers is "which one was I
|
||||
// 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.
|
||||
// Tool results are excluded rather than typed messages included, and the
|
||||
// difference matters: a tool result is *also* a user record, so grepping
|
||||
// the type alone gave a session that ended mid-tool a tail of empty records.
|
||||
// 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 launch = Launch::new("sh", vec!["-c".to_string(), script], None);
|
||||
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.
|
||||
///
|
||||
/// Importing needs everything a row holds -- the path to follow, how many
|
||||
/// lines have already been written, what it is called, where it was working
|
||||
/// and whether something else has it open -- and used to get them by
|
||||
/// listing *every* session and searching the result. That is a full read of
|
||||
/// 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.
|
||||
/// Importing needs everything a row holds, and used to get it by listing
|
||||
/// *every* session and searching the result -- a full read of every transcript
|
||||
/// on the machine, seconds of it, to answer a question about one file, paid
|
||||
/// once per import in a batch. Same script, same parsing, one glob narrower.
|
||||
pub async fn find(transport: &Transport, id: &str) -> Result<Option<Importable>> {
|
||||
if !is_session_id(id) {
|
||||
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.
|
||||
///
|
||||
/// One script with the glob substituted rather than two that drift: the
|
||||
/// per-file half decides what a row *is*, and a row has to mean the same
|
||||
/// thing whether it arrived from a listing or from a lookup. The glob is
|
||||
/// this module's own text; the only thing that ever crosses from outside is
|
||||
/// the id, which stays an argument (`$1`) and is checked by
|
||||
/// [`is_session_id`] first.
|
||||
/// One script with the glob substituted rather than two that drift: a row has
|
||||
/// to mean the same thing whether it came from a listing or a lookup. The glob
|
||||
/// is this module's own text; the only thing that crosses from outside is the
|
||||
/// id, which stays an argument and is checked by [`is_session_id`] first.
|
||||
fn listing_script(glob: &str) -> String {
|
||||
// `replace` rather than `format!`: this is shell, so it is full of
|
||||
// braces -- `${s##*/}`, an awk program, the `{[^}]*` that finds a usage
|
||||
// record -- and every one of them would have to be doubled to survive a
|
||||
// format string. Doubling braces inside a script is exactly the kind of
|
||||
// edit that looks right and changes what the shell runs.
|
||||
// `replace` rather than `format!`: this is shell, so it is full of braces,
|
||||
// and every one would have to be doubled to survive a format string --
|
||||
// exactly the kind of edit that looks right and changes what the shell runs.
|
||||
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
|
||||
// addresses: `--resume` takes it, deleting globs for it, and the
|
||||
// in-flight registry is keyed on it. So two rows sharing an id are two
|
||||
// rows that no operation can tell apart -- and the phone keys its list
|
||||
// on it too, which turned this into a crash rather than a confusion.
|
||||
// addresses: `--resume` takes it, deleting globs for it, the in-flight
|
||||
// registry is keyed on it, and the phone keys its list on it -- which
|
||||
// turned two rows sharing an id into a crash rather than a confusion.
|
||||
//
|
||||
// It is a real state of the machine, not corruption: resuming a session
|
||||
// from a different working directory makes the CLI write a second file
|
||||
// under that directory's project folder with the same id. One of the two
|
||||
// is then usually a stub of a few hundred bytes and the other is the
|
||||
// conversation somebody means.
|
||||
// It is a real state of the machine, not corruption: resuming from a
|
||||
// different working directory makes the CLI write a second file under that
|
||||
// directory's project folder with the same id. One is then usually a stub
|
||||
// of a few hundred bytes.
|
||||
//
|
||||
// So the copy with the most in it wins, and the row's `cwd` comes from
|
||||
// that same copy -- which is the directory `--resume` will find it under.
|
||||
// Ties go to the more recent, and the *stub* is often the more recent, so
|
||||
// the size has to be the first key rather than the tie-break.
|
||||
// So the copy with the most in it wins, and the row's `cwd` comes from that
|
||||
// same copy. Ties go to the more recent, and the *stub* is often the more
|
||||
// recent, so size has to be the first key rather than the tie-break.
|
||||
sessions.sort_by(|a, b| {
|
||||
b.lines
|
||||
.cmp(&a.lines)
|
||||
@@ -283,12 +242,10 @@ fn parse_listing(found: &str) -> Result<Vec<Importable>> {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
sessions.retain(|session| seen.insert(session.id.clone()));
|
||||
|
||||
// Most recent first, and only that. Naming was tried as the first key
|
||||
// and is a worse list: it buries what somebody was just doing under
|
||||
// everything they ever named, and the reason to open this screen is
|
||||
// almost always to pick up where they left off. A name still shows,
|
||||
// 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.
|
||||
// Most recent first, and only that. Naming was tried as the first key and
|
||||
// is a worse list: it buries what somebody was just doing under everything
|
||||
// they ever named. A name still shows, as the row's title and as a word
|
||||
// beside it.
|
||||
sessions.sort_by(|a, b| b.modified.total_cmp(&a.modified));
|
||||
Ok(sessions)
|
||||
}
|
||||
@@ -322,23 +279,21 @@ fn parse_row(line: &str) -> Option<Importable> {
|
||||
if !is_hidden(&record)
|
||||
&& let Some(text) = first_line_of(&record)
|
||||
{
|
||||
// Kept rather than broken out of: these arrive oldest first,
|
||||
// so the last one to survive the filter is the most recent
|
||||
// thing that was actually said.
|
||||
// Kept rather than broken out of: these arrive oldest first, so the
|
||||
// last to survive the filter is the most recent thing said.
|
||||
said = Some(text);
|
||||
}
|
||||
}
|
||||
|
||||
Some(Importable {
|
||||
id,
|
||||
// Filled in by `list`, which is the only thing that knows: it
|
||||
// takes one command to ask a machine, and asking per row would be
|
||||
// one ssh connection each.
|
||||
// Filled in by `list`, which is the only thing that knows: it takes one
|
||||
// command to ask a machine, and asking per row would be one ssh
|
||||
// connection each.
|
||||
in_use: InUse::Unknown,
|
||||
cwd: cwd.unwrap_or_default(),
|
||||
// A name somebody typed outranks anything read out of the
|
||||
// conversation, because they chose it to answer this exact
|
||||
// question.
|
||||
// A name somebody typed outranks anything read out of the conversation,
|
||||
// because they chose it to answer this exact question.
|
||||
named: named.is_some(),
|
||||
title: named
|
||||
.or(said)
|
||||
@@ -351,24 +306,21 @@ fn parse_row(line: &str) -> Option<Importable> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The input tokens named in one `usage` object, added up.
|
||||
///
|
||||
/// Prompt plus cache creation plus cache read: all three are context the
|
||||
/// model was given -- the definition is [`driver::context_tokens`]; this
|
||||
/// is the same three figures dug out of a raw line rather than a parsed
|
||||
/// one, because these files reach tens of megabytes.
|
||||
/// 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
|
||||
/// definition is [`driver::context_tokens`]; this is the same three figures dug
|
||||
/// out of a raw line rather than a parsed one, because these files reach tens
|
||||
/// of megabytes.
|
||||
///
|
||||
/// `None` for an empty blob, meaning no assistant turn has recorded usage.
|
||||
/// Missing individual fields count as zero, which is what an absent
|
||||
/// category means; an unparseable one does the same rather than
|
||||
/// discarding the figures that did read.
|
||||
/// Missing fields count as zero, which is what an absent category means.
|
||||
fn context_tokens(usage: &str) -> Option<u64> {
|
||||
if usage.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
// The leading quote matters: without it `"input_tokens"` also matches
|
||||
// inside `"cache_read_input_tokens"`, and the same number gets counted
|
||||
// three times.
|
||||
// inside `"cache_read_input_tokens"`, and the same number is counted three
|
||||
// times.
|
||||
let field = |name: &str| -> u64 {
|
||||
usage
|
||||
.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.
|
||||
///
|
||||
/// None for the CLI's own plumbing. A slash command, the caveat wrapped
|
||||
/// around a local command's output, and an injected reminder are all
|
||||
/// stored as ordinary user records without the `isMeta` flag -- so titling
|
||||
/// by "first user record" gave a list where most rows read
|
||||
/// `<command-name>/clear</command-name>`, which identifies nothing. The
|
||||
/// caller offers several candidates for exactly this reason.
|
||||
/// None for the CLI's own plumbing. A slash command, the caveat wrapped around
|
||||
/// a local command's output, and an injected reminder are all stored as
|
||||
/// ordinary user records without `isMeta` -- so titling by "first user record"
|
||||
/// gave a list where most rows read `<command-name>/clear</command-name>`.
|
||||
fn first_line_of(record: &Value) -> Option<String> {
|
||||
let text = text_of(record.get("message")?.get("content")?);
|
||||
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)
|
||||
}
|
||||
|
||||
/// Records the transcript should not show: a subagent's private
|
||||
/// conversation, 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 into this transcript would
|
||||
/// Records the transcript should not show: a subagent's private conversation,
|
||||
/// 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
|
||||
/// show the reader two conversations interleaved as one.
|
||||
fn is_hidden(record: &Value) -> bool {
|
||||
record.get("isSidechain").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 {
|
||||
match content {
|
||||
Value::String(text) => text.clone(),
|
||||
@@ -432,36 +378,31 @@ fn text_of(content: &Value) -> String {
|
||||
|
||||
/// Whether a directory the machine recorded is still there.
|
||||
///
|
||||
/// Asked because a session's recorded cwd can outlive the directory: these
|
||||
/// files go back months, and a checkout that moved leaves every session
|
||||
/// from before the move pointing at a path that is gone. Resuming into one
|
||||
/// fails at `cd` before the CLI starts, which is a confusing way to meet a
|
||||
/// feature whose whole promise is "carry on where you left off".
|
||||
/// A session's recorded cwd can outlive the directory: these files go back
|
||||
/// months, and a checkout that moved leaves every session from before it
|
||||
/// pointing at a path that is gone. Resuming into one fails at `cd` before the
|
||||
/// CLI starts.
|
||||
pub async fn directory_exists(transport: &Transport, path: &str) -> bool {
|
||||
if path.is_empty() {
|
||||
return false;
|
||||
}
|
||||
// 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
|
||||
// because a path is only expanded where it is a working directory --
|
||||
// `~/repos/ai-app` as an argument stays four literal characters on
|
||||
// both transports (`ssh::quote_path`, `ssh::expand_home`), so the old
|
||||
// form answered "no such directory" about every home-relative path
|
||||
// somebody typed.
|
||||
// question this stands in for is "can a session start here" and because a
|
||||
// path is only expanded where it is a working directory -- `~/repos/ai-app`
|
||||
// as an argument stays literal on both transports, so the old 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)));
|
||||
transport.capture(&launch).await.is_ok()
|
||||
}
|
||||
|
||||
/// Reads the tail of one session's file, as the raw JSONL.
|
||||
///
|
||||
/// `tail` rather than the whole file, and as [`Launch`] arguments rather
|
||||
/// than a shell string, so the path is an argument and never syntax.
|
||||
/// `tail` rather than the whole file, and as [`Launch`] arguments rather than a
|
||||
/// shell string, so the path is an argument and never syntax.
|
||||
///
|
||||
/// Returns text rather than events because turning records into events has
|
||||
/// a side effect -- writing out the images they carry -- and it needs the
|
||||
/// session directory to write them into. That directory does not exist
|
||||
/// until the session is created, which is after this runs, so the
|
||||
/// conversion happens there instead. See [`events_from`].
|
||||
/// Returns text rather than events because turning records into events has a
|
||||
/// side effect -- writing out the images they carry -- and that needs the
|
||||
/// session directory, which does not exist until after this runs.
|
||||
pub async fn read_tail(transport: &Transport, path: &str) -> Result<String> {
|
||||
let launch = Launch::new(
|
||||
"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.
|
||||
///
|
||||
/// 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
|
||||
/// that does not parse is one this reader has no opinion about.
|
||||
/// boundary, but the *file* may have been appended to since.
|
||||
///
|
||||
/// `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
|
||||
/// screenshot looks identical whether it was watched as it happened or
|
||||
/// replayed afterwards. It is only the *reference* that 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.
|
||||
/// place and by the same function the live translator uses -- so a screenshot
|
||||
/// looks identical whether it was watched happening or replayed afterwards.
|
||||
/// Only the *reference* reaches the phone.
|
||||
pub fn events_from(text: &str, session_dir: &std::path::Path) -> Vec<Event> {
|
||||
let mut events = Vec::new();
|
||||
// What the newest record that had an opinion says the session is
|
||||
// doing. Kept to the end rather than pushed as it is found, because
|
||||
// the answer is the last one and everything before it is history.
|
||||
// What the newest record that had an opinion says the session is doing.
|
||||
// Kept to the end rather than pushed as it is found, because the answer is
|
||||
// the last one and everything before it is history.
|
||||
let mut state = None;
|
||||
for line in text.lines() {
|
||||
let Ok(record) = serde_json::from_str::<Value>(line) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(peer) = peer_message(&record) {
|
||||
// Before `is_hidden`, which these records are: the CLI marks
|
||||
// them meta because they are not the user's own words, and
|
||||
// that is the reason to draw them differently rather than the
|
||||
// reason to drop them. A session working on something a phone
|
||||
// never asked for is otherwise unexplainable from the phone.
|
||||
// Before `is_hidden`, which these records are: the CLI marks them
|
||||
// meta because they are not the user's own words, and that is the
|
||||
// reason to draw them differently rather than to drop them. A
|
||||
// session working on something a phone never asked for is otherwise
|
||||
// unexplainable from the phone.
|
||||
state = turn_state(&record).or(state);
|
||||
events.push(peer);
|
||||
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.
|
||||
///
|
||||
/// Measured from a real session file (2026-08-29): the record is a `user`
|
||||
/// one marked `isMeta`, and its `origin` carries `kind: "peer"`, the
|
||||
/// sending session's `name`, and the message itself as `body`. The
|
||||
/// message content beside it is the same text wrapped in an explanatory
|
||||
/// preamble and a `<cross-session-message>` tag, which is written for the
|
||||
/// 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.
|
||||
/// Measured from a real session file (2026-08-29): the record is a `user` one
|
||||
/// marked `isMeta`, and its `origin` carries `kind: "peer"`, the sending
|
||||
/// session's `name`, and the message as `body`. The message content beside it
|
||||
/// is the same text wrapped in a preamble written for the model rather than for
|
||||
/// a person, so the body is what a reader is shown.
|
||||
///
|
||||
/// Shared with the live driver (`claude::translate`), which finds the same
|
||||
/// `origin` object on a different record -- so this reads the object and
|
||||
/// not the record around it. One function because it is one wire format:
|
||||
/// two copies would drift the first time the CLI renames a field, and the
|
||||
/// half that drifted would go on producing nothing at all, which is
|
||||
/// indistinguishable from nobody having sent anything.
|
||||
/// Shared with the live driver, which finds the same `origin` object on a
|
||||
/// different record -- so this reads the object and not the record around it.
|
||||
/// One function because it is one wire format: two copies would drift the first
|
||||
/// time the CLI renames a field, and the half that drifted would produce
|
||||
/// nothing at all, which is indistinguishable from nobody having sent
|
||||
/// anything.
|
||||
pub(in crate::session) fn peer_message(record: &Value) -> Option<Event> {
|
||||
let origin = record.get("origin")?;
|
||||
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
|
||||
/// told from the file.
|
||||
/// Whether this record means the session is working, as far as the file can
|
||||
/// say.
|
||||
///
|
||||
/// The one thing a session file does not contain is the CLI saying "this
|
||||
/// turn is over": there is no `result` record, only the messages. What
|
||||
/// there is instead is why the last assistant message stopped, and that
|
||||
/// answers it -- `tool_use` means a call is being made and more is coming,
|
||||
/// anything else means the model has finished talking. Anything on the
|
||||
/// 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.
|
||||
/// The one thing a session file does not contain is the CLI saying "this turn
|
||||
/// is over": there is no `result` record. What there is instead is why the last
|
||||
/// assistant message stopped -- `tool_use` means a call is being made and more
|
||||
/// is coming, anything else means the model has finished talking. Anything on
|
||||
/// the user's side means the session has something to answer.
|
||||
///
|
||||
/// `None` is the third answer and it matters: a record that says nothing
|
||||
/// about the turn leaves the status alone rather than voting for idle. The
|
||||
/// 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.
|
||||
/// `None` is the third answer and it matters: a record that says nothing about
|
||||
/// the turn leaves the status alone rather than voting for idle.
|
||||
///
|
||||
/// 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
|
||||
/// forever. Nothing in the file distinguishes that from a model thinking,
|
||||
/// and inventing a timeout here would replace a stale reading with a
|
||||
/// confident wrong one.
|
||||
/// file's last record still says `tool_use`, so it reads as working forever.
|
||||
/// Nothing in the file distinguishes that from a model thinking, and inventing
|
||||
/// a timeout would replace a stale reading with a confident wrong one.
|
||||
fn turn_state(record: &Value) -> Option<super::driver::SessionStatus> {
|
||||
use super::driver::SessionStatus;
|
||||
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) {
|
||||
// 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
|
||||
// a person said, and showing it as a message would put the reader's
|
||||
// own words and a command's output in the same voice.
|
||||
// models it -- but it is the other half of a tool call, and showing it as a
|
||||
// message would put the reader's own words and a command's output in the
|
||||
// same voice.
|
||||
if let Value::Array(blocks) = content {
|
||||
for block in blocks {
|
||||
// A picture the person attached to their own message, rather
|
||||
// than one a tool produced. Same block shape, one level up.
|
||||
// A picture the person attached to their own message rather than
|
||||
// one a tool produced. Same block shape, one level up.
|
||||
push_images(events, std::slice::from_ref(block), session_dir, None);
|
||||
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)
|
||||
{
|
||||
// Before the tool's own row, matching the live translator:
|
||||
// a screenshot belongs to the call that took it, and after
|
||||
// the result it reads as belonging to whatever came next.
|
||||
// Before the tool's own row, matching the live translator: a
|
||||
// screenshot belongs to the call that took it.
|
||||
if let Some(Value::Array(parts)) = block.get("content") {
|
||||
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);
|
||||
if !text.trim().is_empty() {
|
||||
// Replayed from the CLI's own file: it was read long ago, so
|
||||
// there is no waiting bubble for it to resolve.
|
||||
// The images in this record are saved and referenced separately just
|
||||
// above, because a replayed message's pictures came out of somebody
|
||||
// else's file rather than out of this app's composer -- there is no
|
||||
// upload here whose refs could ride on the message.
|
||||
// Replayed from the CLI's own file: it was read long ago, so there is
|
||||
// no waiting bubble for it to resolve. Its images are saved and
|
||||
// referenced separately just above, because they came out of somebody
|
||||
// else's file rather than this app's composer.
|
||||
events.push(Event::UserMessage {
|
||||
id: None,
|
||||
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.
|
||||
///
|
||||
/// `about` is the call the images came out of, or `None` for one a person attached
|
||||
/// to their own message -- the same distinction the live translator makes, so replayed
|
||||
/// history draws a screenshot under the call that took it exactly as a live one does.
|
||||
/// 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
|
||||
/// own message -- the same distinction the live translator makes, so replayed
|
||||
/// history draws a screenshot under the call that took it.
|
||||
fn push_images(
|
||||
events: &mut Vec<Event>,
|
||||
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.
|
||||
///
|
||||
/// 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
|
||||
/// an `rm` refused. "Not there" is deliberately kept apart from "it broke"
|
||||
/// rather than folded together by the shell -- only one of them is worth
|
||||
/// retrying, and the caller is what knows how to word either.
|
||||
/// least one file went, `missing` if the glob matched nothing, `failed` if an
|
||||
/// `rm` refused. "Not there" is deliberately kept apart from "it broke" --
|
||||
/// only one of them is worth retrying.
|
||||
///
|
||||
/// Every copy of each id, not the first. The same id can name a file under
|
||||
/// two project directories -- see the de-duplication in `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. `failed`
|
||||
/// therefore sticks once set: one copy removed and another refused is not a
|
||||
/// success.
|
||||
/// Every copy of each id, not the first. The same id can name a file under two
|
||||
/// project directories, and stopping at the first left the other behind, so the
|
||||
/// row came back on the next listing after a delete that reported success.
|
||||
/// `failed` therefore sticks once set.
|
||||
///
|
||||
/// Ids arrive as arguments rather than in the script text, so nothing here
|
||||
/// is shell syntax; `is_session_id` is what keeps one from globbing its way
|
||||
/// out of the projects directory.
|
||||
/// Ids arrive as arguments rather than in the script text; `is_session_id` is
|
||||
/// what keeps one from globbing its way out of the projects directory.
|
||||
const DELETE_SCRIPT: &str = r#"
|
||||
for id do
|
||||
state=missing
|
||||
@@ -730,37 +652,30 @@ done
|
||||
|
||||
/// Deletes sessions [`list`] reported, and says what happened to each.
|
||||
///
|
||||
/// By id, resolved on the machine against what it actually has, so the
|
||||
/// caller never names a file -- the same rule importing follows, and it
|
||||
/// matters more here: this one removes something.
|
||||
/// By id, resolved on the machine against what it actually has, so the caller
|
||||
/// never names a file -- the same rule importing follows, and it matters more
|
||||
/// here: this one removes something.
|
||||
///
|
||||
/// Irreversible, and the caller is expected to have said so. Claude Code
|
||||
/// keeps no copy: the JSONL *is* the session, so deleting it ends any
|
||||
/// chance of resuming that conversation, including from an ai-app session
|
||||
/// that was already importing it.
|
||||
/// Irreversible, and the caller is expected to have said so. Claude Code keeps
|
||||
/// no copy: the JSONL *is* the session.
|
||||
///
|
||||
/// The whole batch in one invocation, which over ssh is the difference
|
||||
/// between one connection and one per session. Six deletes started in the
|
||||
/// same tick were six `ssh` processes racing to authenticate, and a batch
|
||||
/// big enough to pass the remote sshd's `MaxStartups` (10 unauthenticated
|
||||
/// connections, by default, before it begins refusing) had rows come back
|
||||
/// as `Connection closed by … port 2222` -- a row reporting a delete that
|
||||
/// never ran, for a reason that has nothing to do with the session. One
|
||||
/// connection cannot exceed that however many ids are selected.
|
||||
/// The whole batch in one invocation, which over ssh is the difference between
|
||||
/// one connection and one per session. Six deletes started in the same tick
|
||||
/// were six `ssh` processes racing to authenticate, and a batch past the remote
|
||||
/// sshd's `MaxStartups` had rows come back as `Connection closed by …` -- a row
|
||||
/// reporting a delete that never ran, for a reason nothing to do with the
|
||||
/// session.
|
||||
///
|
||||
/// Still one outcome per id, because a batch is not a transaction: six
|
||||
/// removals that must all succeed or all roll back is not something a
|
||||
/// filesystem offers, and the caller settles each row from its own line.
|
||||
/// Every requested id gets an entry, so an id the machine said nothing
|
||||
/// about is reported as such rather than defaulting to either answer.
|
||||
/// Still one outcome per id, because a batch is not a transaction. 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(
|
||||
transport: &Transport,
|
||||
ids: &[String],
|
||||
) -> Result<HashMap<String, Result<(), String>>> {
|
||||
// Refused here rather than on the machine: `is_session_id` is what
|
||||
// keeps an id from walking out of the projects directory, and a bad
|
||||
// one must never reach the glob. It fails only itself -- one malformed
|
||||
// id is not a reason to leave the other five in place.
|
||||
// Refused here rather than on the machine: `is_session_id` is what keeps an
|
||||
// id from walking out of the projects directory. It fails only itself --
|
||||
// one malformed id is not a reason to leave the other five in place.
|
||||
let (safe, mut outcomes): (Vec<&String>, HashMap<String, Result<(), String>>) =
|
||||
ids.iter().fold(
|
||||
(Vec::new(), HashMap::new()),
|
||||
@@ -780,23 +695,16 @@ pub async fn delete(
|
||||
return Ok(outcomes);
|
||||
}
|
||||
|
||||
// The file name *is* the id, so the machine can find it by name. This
|
||||
// used to call `list` and search its output, which is correct and costs
|
||||
// a full read of every transcript on the machine -- around four seconds
|
||||
// against a gigabyte of them, per delete, so a batch of ten took the
|
||||
// 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.
|
||||
// The file name *is* the id, so the machine can find it by name. This used
|
||||
// to call `list` and search its output, which is correct and costs a full
|
||||
// read of every transcript on the machine -- around four seconds against a
|
||||
// gigabyte of them, per delete.
|
||||
//
|
||||
// Every copy of each id, not the first. The same id can name a file
|
||||
// under two project directories -- see the de-duplication in
|
||||
// `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.
|
||||
// Every copy of each id, not the first: the same id can name a file under
|
||||
// two project directories, and stopping at the first left the other behind.
|
||||
//
|
||||
// Each id prints its own verdict rather than the loop exiting on the
|
||||
// first failure: with a batch, exiting would leave every id after it
|
||||
// unexplained. See [`DELETE_SCRIPT`] for what the words mean.
|
||||
// Each id prints its own verdict rather than the loop exiting on the first
|
||||
// failure, which would leave every id after it unexplained.
|
||||
let mut args = vec![
|
||||
"-c".to_string(),
|
||||
DELETE_SCRIPT.to_string(),
|
||||
@@ -806,8 +714,7 @@ pub async fn delete(
|
||||
let launch = Launch::new("sh", args, None);
|
||||
|
||||
// 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
|
||||
// them -- so it is returned as the error, not written into each row.
|
||||
// which is true of every id in the batch rather than of any one of them.
|
||||
let reported = transport
|
||||
.capture(&launch)
|
||||
.await
|
||||
@@ -826,10 +733,10 @@ pub async fn delete(
|
||||
},
|
||||
);
|
||||
}
|
||||
// Anything the machine did not mention. The connection can drop
|
||||
// part-way through the loop, and an id whose line never arrived is one
|
||||
// nobody knows the fate of -- which is its own answer, and must not be
|
||||
// read as either a success or a clean "not there".
|
||||
// Anything the machine did not mention. The connection can drop part-way
|
||||
// through the loop, and an id whose line never arrived is one nobody knows
|
||||
// the fate of -- which is its own answer, and must not read as either a
|
||||
// success or a clean "not there".
|
||||
for id in safe {
|
||||
outcomes.entry(id.clone()).or_insert_with(|| {
|
||||
Err(format!(
|
||||
@@ -843,40 +750,34 @@ pub async fn delete(
|
||||
|
||||
/// 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
|
||||
/// 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
|
||||
/// is supposed to name. [`delete`] is where that would be fatal, because it
|
||||
/// removes whatever it lands on, and it is exactly the reason `delete` used
|
||||
/// to resolve ids by searching a listing instead.
|
||||
/// 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 is
|
||||
/// supposed to name, and [`delete`] removes whatever it lands on.
|
||||
///
|
||||
/// 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
|
||||
/// these did not come from the list this app showed.
|
||||
/// whole alphabet. Refused rather than escaped.
|
||||
fn is_session_id(id: &str) -> bool {
|
||||
!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.
|
||||
///
|
||||
/// A poll rather than a watch, because the file may be on another machine
|
||||
/// and there is no portable way to be told. Ten seconds is chosen against
|
||||
/// the cost of an ssh round trip rather than against how fast a person
|
||||
/// types: nothing here is waiting on it, and the events arrive on the same
|
||||
/// stream as everything else once they do.
|
||||
/// A poll rather than a watch, because the file may be on another machine and
|
||||
/// there is no portable way to be told. Ten seconds is chosen against the cost
|
||||
/// of an ssh round trip rather than against how fast a person types.
|
||||
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.
|
||||
///
|
||||
/// Kept beside the session rather than in its config, because it is a
|
||||
/// position in someone else's file rather than anything the person chose,
|
||||
/// and it changes constantly.
|
||||
/// Kept beside the session rather than in its config, because it is a position
|
||||
/// in someone else's file rather than anything the person chose, and it changes
|
||||
/// constantly.
|
||||
#[derive(Debug, Clone, Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Cursor {
|
||||
/// Server-side only, and resolved once at import. Nothing accepts a
|
||||
/// path from the phone; this is the path *we* found.
|
||||
/// Server-side only, resolved once at import. Nothing accepts a path from
|
||||
/// the phone; this is the path *we* found.
|
||||
pub path: String,
|
||||
/// Lines of that file already accounted for -- whether replayed into
|
||||
/// 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
|
||||
//! over its OpenAI-compatible HTTP API and translated into the common
|
||||
//! event model.
|
||||
//! The llama.cpp driver: a `llama-server` process per session, spoken to over
|
||||
//! its OpenAI-compatible HTTP API and translated into the common event model.
|
||||
//!
|
||||
//! Two things make this shaped differently from the Claude driver, and
|
||||
//! both are worth knowing before changing anything here.
|
||||
//! Two things make this shaped differently from the Claude driver.
|
||||
//!
|
||||
//! **It is spawned but not spoken to over stdio.** The process is started
|
||||
//! through the same [`Transport`] as any other, and then reached over
|
||||
//! HTTP on a loopback port. That is the case the transport's doc comment
|
||||
//! flags: a remote llama-server would need its port forwarded as well as
|
||||
//! its command wrapped, which is not built, so a session on an ssh host
|
||||
//! is refused rather than silently talking to the wrong machine.
|
||||
//! through the same [`Transport`] as any other and then reached over HTTP on a
|
||||
//! loopback port. A remote llama-server would need its port forwarded as well
|
||||
//! as its command wrapped, which is not built, so a session on an ssh host is
|
||||
//! refused rather than silently talking to the wrong machine.
|
||||
//!
|
||||
//! **The server is stateless between requests**, so the whole
|
||||
//! conversation goes with every one. It is rebuilt from the session's
|
||||
//! transcript rather than kept in this struct, which is not tidiness: a
|
||||
//! copy in driver memory is invisible to a second device and gone when
|
||||
//! 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.
|
||||
//! **The server is stateless between requests**, so the whole conversation goes
|
||||
//! with every one. It is rebuilt from the session's transcript rather than kept
|
||||
//! in this struct, which is not tidiness: a copy in driver memory is invisible
|
||||
//! to a second device and gone when this process restarts.
|
||||
//!
|
||||
//! That leaves the Claude driver as the odd one out rather than this one:
|
||||
//! the 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
|
||||
//! inconsistency should resolve it in this direction.
|
||||
//! That leaves the Claude driver as the odd one out rather than this one -- the
|
||||
//! CLI's own memory of a conversation is a cache in front of the same
|
||||
//! transcript. Resolve any inconsistency in this direction.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
@@ -38,10 +31,9 @@ use super::process;
|
||||
use super::transport::{Launch, Streams, Transport};
|
||||
use crate::config::{ProviderConfig, SessionConfig};
|
||||
|
||||
/// How long to wait for a model to load before giving up on it. Loading
|
||||
/// is mostly disk, and a large quantised model on a cold cache is
|
||||
/// genuinely slow, so this is generous -- the failure it exists for is a
|
||||
/// server that will never answer, not one that is taking its time.
|
||||
/// How long to wait for a model to load before giving up. Loading is mostly
|
||||
/// disk, and a large quantised model on a cold cache is genuinely slow, so this
|
||||
/// is generous -- the failure it exists for is a server that will never answer.
|
||||
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.
|
||||
@@ -62,20 +54,19 @@ pub struct LlamaDriver {
|
||||
/// Set by [`Driver::interrupt`]; the streaming loop checks it between
|
||||
/// chunks and stops, leaving what was generated in the transcript.
|
||||
cancel: Arc<AtomicBool>,
|
||||
/// Where this session's process record lives, so [`Driver::stop`] can
|
||||
/// find the server it has to end.
|
||||
/// Where this session's process record lives, so [`Driver::stop`] can find
|
||||
/// the server it has to end.
|
||||
session_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl LlamaDriver {
|
||||
/// Takes charge of this session's `llama-server`: the one already
|
||||
/// loaded if there is one, otherwise a new one.
|
||||
/// Takes charge of this session's `llama-server`: the one already loaded if
|
||||
/// there is one, otherwise a new one.
|
||||
///
|
||||
/// One entry point, for the reason `ClaudeDriver::launch` gives -- the
|
||||
/// choice is not the caller's and a second process is the expensive
|
||||
/// mistake. Here it is expensive in a different currency: two servers
|
||||
/// holding the same model is twice the memory, and the second would
|
||||
/// bind a different port while the phone kept talking to the first.
|
||||
/// One entry point, for the reason `ClaudeDriver::launch` gives, expensive
|
||||
/// in a different currency: two servers holding the same model is twice the
|
||||
/// memory, and the second would bind a different port while the phone kept
|
||||
/// talking to the first.
|
||||
pub fn launch(
|
||||
meta: &SessionConfig,
|
||||
provider: &ProviderConfig,
|
||||
@@ -96,10 +87,10 @@ impl LlamaDriver {
|
||||
)?;
|
||||
let path = model_path(models_dir, model)?;
|
||||
|
||||
// Already loaded and still running: keep talking to it. The
|
||||
// health poll below is what confirms it is really answering, so
|
||||
// adopting a pid whose server has wedged still reports as a
|
||||
// failure rather than as a session that silently never replies.
|
||||
// Already loaded and still running: keep talking to it. The health poll
|
||||
// below confirms it is really answering, so adopting a pid whose server
|
||||
// has wedged still reports as a failure rather than as a session that
|
||||
// silently never replies.
|
||||
if let Some(process::Record {
|
||||
detail: process::Detail::Http { port },
|
||||
pid,
|
||||
@@ -129,9 +120,9 @@ impl LlamaDriver {
|
||||
"--port".into(),
|
||||
port.to_string(),
|
||||
];
|
||||
// Settings that belong to the server because they decide how the
|
||||
// model is loaded; the sampling ones ride on each request instead,
|
||||
// so changing them later needn't reload anything.
|
||||
// Settings that belong to the server because they decide how the model
|
||||
// is loaded; the sampling ones ride on each request instead, so changing
|
||||
// them later needn't reload anything.
|
||||
for (key, flag) in [
|
||||
("contextSize", "-c"),
|
||||
("gpuLayers", "-ngl"),
|
||||
@@ -147,8 +138,8 @@ impl LlamaDriver {
|
||||
let launch = Launch::new(program, args, meta.cwd.as_deref());
|
||||
// Its output goes to files, not pipes. Not only so the process can
|
||||
// outlive this server: nothing ever read those pipes, so a chatty
|
||||
// llama-server filled the 64 KB buffer and blocked mid-load with
|
||||
// no sign of why.
|
||||
// llama-server filled the 64 KB buffer and blocked mid-load with no sign
|
||||
// of why.
|
||||
let child = transport.spawn(
|
||||
&launch,
|
||||
Streams::Detached {
|
||||
@@ -164,10 +155,9 @@ impl LlamaDriver {
|
||||
"session {} running {program} for {model} on 127.0.0.1:{port} as pid {pid}",
|
||||
meta.id
|
||||
);
|
||||
// Reaped so it does not become a zombie while this server is still
|
||||
// its parent; the health poll and the record are what actually say
|
||||
// whether the session is alive, because after a restart there is no
|
||||
// `Child` here to ask.
|
||||
// Reaped so it does not become a zombie while this server is still its
|
||||
// parent; the health poll and the record are what say whether the
|
||||
// session is alive, because after a restart there is no `Child` to ask.
|
||||
tokio::spawn(async move {
|
||||
let mut child = child;
|
||||
let _ = child.wait().await;
|
||||
@@ -189,10 +179,10 @@ impl LlamaDriver {
|
||||
|
||||
/// The driver for a `llama-server` at `endpoint`, however it got there.
|
||||
///
|
||||
/// Shared by starting one and adopting one, because everything after
|
||||
/// "there is a server at this address" is identical -- including
|
||||
/// waiting for it to answer, which an adopted one still owes: a
|
||||
/// recorded pid says a process exists, not that its model is loaded.
|
||||
/// Shared by starting one and adopting one, because everything after "there
|
||||
/// is a server at this address" is identical -- including waiting for it to
|
||||
/// answer, which an adopted one still owes: a recorded pid says a process
|
||||
/// exists, not that its model is loaded.
|
||||
fn attached(
|
||||
endpoint: String,
|
||||
meta: &SessionConfig,
|
||||
@@ -201,9 +191,9 @@ impl LlamaDriver {
|
||||
session_dir: &Path,
|
||||
sink: EventSink,
|
||||
) -> Self {
|
||||
// Loading is slow enough to be worth saying so: the session shows
|
||||
// as running until the model is in memory, then goes idle, rather
|
||||
// than looking ready and refusing the first message.
|
||||
// Loading is slow enough to be worth saying so: the session shows as
|
||||
// running until the model is in memory, rather than looking ready and
|
||||
// refusing the first message.
|
||||
let _ = sink.send(Event::Status {
|
||||
state: SessionStatus::Running,
|
||||
});
|
||||
@@ -262,11 +252,9 @@ impl LlamaDriver {
|
||||
/// terminal anyway.
|
||||
const SERVER_LOG: &str = "llama-server.log";
|
||||
|
||||
/// 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 to notice a server that has gone, and a few
|
||||
/// seconds late costs nothing.
|
||||
/// 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
|
||||
/// to notice a server that has gone.
|
||||
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
|
||||
@@ -281,22 +269,21 @@ fn log_file(path: &Path) -> Result<std::fs::File> {
|
||||
.with_context(|| format!("opening {}", path.display()))
|
||||
}
|
||||
|
||||
/// Reports the server going away, for as long as the session is there to
|
||||
/// report it to.
|
||||
/// Reports the server going away, for as long as the session is there to report
|
||||
/// it to.
|
||||
///
|
||||
/// Polled rather than waited on, for the reason the Claude driver gives:
|
||||
/// after a restart this server is not the process's parent and has nothing
|
||||
/// to wait on, so liveness has to be a question asked of the record -- and
|
||||
/// asking it two different ways is how the two answers come to disagree.
|
||||
/// Polled rather than waited on, for the reason the Claude driver gives: after a
|
||||
/// restart this server is not the process's parent, so liveness has to be a
|
||||
/// question asked of the record -- and asking it two different ways is how the
|
||||
/// two answers come to disagree.
|
||||
fn watch(session_dir: PathBuf, sink: EventSink) {
|
||||
std::thread::spawn(move || {
|
||||
loop {
|
||||
std::thread::sleep(WATCH_INTERVAL);
|
||||
match process::recorded(&session_dir) {
|
||||
Some((_, process::Liveness::Alive)) => {}
|
||||
// Nothing recorded means the session was stopped or
|
||||
// deleted deliberately, and whoever did that has already
|
||||
// said so.
|
||||
// Nothing recorded means the session was stopped or deleted
|
||||
// deliberately, and whoever did that has already said so.
|
||||
None => return,
|
||||
Some((_, process::Liveness::Dead)) => {
|
||||
let _ = sink.send(Event::Error {
|
||||
@@ -335,34 +322,33 @@ impl Driver for LlamaDriver {
|
||||
let cancel = Arc::clone(&self.cancel);
|
||||
cancel.store(false, Ordering::Relaxed);
|
||||
|
||||
// Its own thread: the request blocks for as long as the model
|
||||
// takes to generate, which is the whole point of streaming it.
|
||||
// Its own thread: the request blocks for as long as the model takes to
|
||||
// generate, which is the whole point of streaming it.
|
||||
std::thread::spawn(move || {
|
||||
// Nothing is ever held back here -- there is no queue to wait
|
||||
// in -- so the message is taken the moment it arrives. Said
|
||||
// anyway, because this is what records it: see `MessageTaken`.
|
||||
// Nothing is ever held back here -- there is no queue to wait in --
|
||||
// so the message is taken the moment it arrives. Said anyway,
|
||||
// because this is what records it: see `MessageTaken`.
|
||||
let _ = sink.send(Event::MessageTaken {
|
||||
id: None,
|
||||
text: text.clone(),
|
||||
// Never any: this driver refuses attachments above, and
|
||||
// saying so is what the refusal above is for.
|
||||
// Never any: this driver refuses attachments above.
|
||||
attachments: Vec::new(),
|
||||
});
|
||||
let _ = sink.send(Event::Status {
|
||||
state: SessionStatus::Running,
|
||||
});
|
||||
// Everything before this message, plus this message. Read
|
||||
// rather than remembered, and `text` is appended here rather
|
||||
// than waited for, because the message's own transcript entry
|
||||
// is still on its way when this runs.
|
||||
// Everything before this message, plus this message. Read rather
|
||||
// than remembered, and `text` is appended here rather than waited
|
||||
// for, because the message's own transcript entry is still on its
|
||||
// way when this runs.
|
||||
let mut messages = conversation(&transcript);
|
||||
messages.push(Message {
|
||||
role: "user".into(),
|
||||
content: text,
|
||||
});
|
||||
// The reply is not stored: the deltas below are the durable
|
||||
// record, so the next turn reads back exactly what the phone
|
||||
// was shown -- including a partial one that was interrupted.
|
||||
// The reply is not stored: the deltas below are the durable record,
|
||||
// so the next turn reads back exactly what the phone was shown --
|
||||
// including a partial one that was interrupted.
|
||||
if let Err(err) = generate(&endpoint, &messages, &sampling, &cancel, &sink) {
|
||||
let _ = sink.send(Event::Error {
|
||||
message: format!("{err:#}"),
|
||||
@@ -375,17 +361,15 @@ impl Driver for LlamaDriver {
|
||||
}
|
||||
|
||||
fn answer_question(&self, _id: &str, _answers: &[String]) {
|
||||
// Nothing here asks questions: this driver has no tools, so no
|
||||
// permission prompts and no AskUserQuestion.
|
||||
// Nothing here asks questions: this driver has no tools.
|
||||
}
|
||||
|
||||
fn interrupt(&self) {
|
||||
self.cancel.store(true, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
// Nothing to forward: this process has no notion of what the
|
||||
// conversation is called, and the rename it belongs to has already
|
||||
// happened where the name lives. See `Driver::set_title`.
|
||||
// Nothing to forward: this process has no notion of what the conversation
|
||||
// is called, and the rename has already happened where the name lives.
|
||||
fn set_title(&self, _title: &str) {}
|
||||
|
||||
fn set_permission_mode(&self, _mode: &str) {
|
||||
@@ -420,23 +404,21 @@ impl Driver for LlamaDriver {
|
||||
}
|
||||
|
||||
fn clear(&self) {
|
||||
// All of it. `conversation` folds from the last of these, so
|
||||
// recording the marker *is* the reset -- there is no driver state
|
||||
// to keep in step with it, which is the same property that makes
|
||||
// a second device see the same conversation this one does.
|
||||
// All of it. `conversation` folds from the last of these, so recording
|
||||
// the marker *is* the reset -- there is no driver state to keep in step
|
||||
// with it, which is the same property that makes a second device see the
|
||||
// same conversation this one does.
|
||||
let _ = self.sink.send(Event::Cleared);
|
||||
}
|
||||
|
||||
/// Stops generating and leaves the server loaded.
|
||||
///
|
||||
/// Worth being deliberate about, because the cost is asymmetric and
|
||||
/// points the other way from the Claude driver's: a `llama-server`
|
||||
/// holds its whole model in memory, so a leaked one is gigabytes
|
||||
/// nobody is using. It is left anyway, because the alternative is
|
||||
/// unloading and reloading that model on every backend restart --
|
||||
/// minutes of disk, for a session somebody is in the middle of. The
|
||||
/// record is what keeps it from being *nobody's*: the next run of this
|
||||
/// server adopts it rather than starting a second one.
|
||||
/// Worth being deliberate about, because the cost points the other way from
|
||||
/// the Claude driver's: a `llama-server` holds its whole model in memory, so
|
||||
/// a leaked one is gigabytes nobody is using. It is left anyway, because the
|
||||
/// alternative is unloading and reloading that model on every backend
|
||||
/// restart -- minutes of disk, for a session somebody is in the middle of.
|
||||
/// The record is what keeps it from being *nobody's*.
|
||||
fn detach(&self) {
|
||||
self.cancel.store(true, Ordering::Relaxed);
|
||||
}
|
||||
@@ -452,16 +434,15 @@ impl Driver for LlamaDriver {
|
||||
|
||||
/// The conversation so far, folded out of the transcript.
|
||||
///
|
||||
/// Consecutive `AssistantText` deltas are one assistant turn, closed by
|
||||
/// the next user message -- which is also what makes an interrupted reply
|
||||
/// come back as the partial text the phone actually saw, rather than
|
||||
/// vanishing or being invented.
|
||||
/// Consecutive `AssistantText` deltas are one assistant turn, closed by the next
|
||||
/// user message -- which is also what makes an interrupted reply come back as
|
||||
/// the partial text the phone actually saw.
|
||||
///
|
||||
/// This must stay a pure function of the transcript and must never
|
||||
/// re-render earlier turns. llama.cpp caches the prompt prefix, so a
|
||||
/// growing conversation reprocesses almost nothing -- but only while
|
||||
/// every turn is byte-identical to last time. Changing how an old turn is
|
||||
/// rendered silently reprocesses the whole history on every message.
|
||||
/// This must stay a pure function of the transcript and must never re-render
|
||||
/// earlier turns. llama.cpp caches the prompt prefix, so a growing conversation
|
||||
/// reprocesses almost nothing -- but only while every turn is byte-identical to
|
||||
/// last time. Changing how an old turn is rendered silently reprocesses the
|
||||
/// whole history on every message.
|
||||
fn conversation(path: &Path) -> Vec<Message> {
|
||||
let Ok(events) = crate::session::transcript::read_after(path, 0) else {
|
||||
return Vec::new();
|
||||
@@ -469,8 +450,8 @@ fn conversation(path: &Path) -> Vec<Message> {
|
||||
let mut messages: Vec<Message> = Vec::new();
|
||||
let mut pending = String::new();
|
||||
// Everything before the last clear is still in the transcript and is
|
||||
// deliberately not in the conversation. Folding from zero here would
|
||||
// put it back, which is the whole of what clearing had to undo.
|
||||
// deliberately not in the conversation. Folding from zero would put it back,
|
||||
// which is the whole of what clearing had to undo.
|
||||
let events = match events.iter().rposition(|e| e.event == Event::Cleared) {
|
||||
Some(at) => &events[at + 1..],
|
||||
None => &events[..],
|
||||
@@ -518,12 +499,10 @@ fn model_path(models_dir: &Path, key: &str) -> Result<PathBuf> {
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// An unused loopback port, by asking the OS for one and letting it go.
|
||||
///
|
||||
/// Racy in principle: something else could take it between here and
|
||||
/// llama-server binding. In practice nothing on this machine is hunting
|
||||
/// 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.
|
||||
/// 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
|
||||
/// 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> {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0")?;
|
||||
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
|
||||
/// arrives. Emits rather than returns: the transcript those events land
|
||||
/// in is what the next turn reads back, so there is nothing to hand up.
|
||||
/// arrives. Emits rather than returns, because the transcript those events land
|
||||
/// in is what the next turn reads back.
|
||||
fn generate(
|
||||
endpoint: &str,
|
||||
messages: &[Message],
|
||||
@@ -574,16 +553,15 @@ fn generate(
|
||||
let reader = std::io::BufReader::new(response.body_mut().as_reader());
|
||||
let mut tokens = 0u64;
|
||||
// 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
|
||||
// one thing whichever kind of session it is.
|
||||
// definition the other dialects report, so one word on the phone means one
|
||||
// thing whichever kind of session it is.
|
||||
let mut context = None;
|
||||
for line in std::io::BufRead::lines(reader) {
|
||||
if cancel.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
let line = line.context("reading the generation stream")?;
|
||||
// Server-sent events: the payload lines are the ones that matter,
|
||||
// and blank lines separate events.
|
||||
// Server-sent events: the payload lines are the ones that matter.
|
||||
let Some(payload) = line.strip_prefix("data: ") else {
|
||||
continue;
|
||||
};
|
||||
@@ -631,8 +609,8 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::session::transcript::Transcript;
|
||||
|
||||
/// Writes a transcript the way the pump does, so the fold is tested
|
||||
/// against the real file format rather than a hand-built vector.
|
||||
/// Writes a transcript the way the pump does, so the fold is tested against
|
||||
/// the real file format rather than a hand-built vector.
|
||||
fn transcript_with(events: &[Event]) -> (tempfile::TempDir, PathBuf) {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("transcript.jsonl");
|
||||
@@ -685,11 +663,10 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// The interrupted case, which decides what a resumed conversation is
|
||||
/// built from: whatever the phone was shown. The deltas that arrived
|
||||
/// before the stop are in the transcript, so they are in the prompt --
|
||||
/// the model is never told it said something the user did not see, and
|
||||
/// never has a turn silently dropped from under it.
|
||||
/// The interrupted case, which decides what a resumed conversation is built
|
||||
/// from: whatever the phone was shown. The deltas that arrived before the
|
||||
/// stop are in the transcript, so they are in the prompt -- the model is
|
||||
/// never told it said something the user did not see.
|
||||
fn an_interrupted_reply_stays_in_the_conversation() {
|
||||
let (_dir, path) = transcript_with(&[
|
||||
Event::UserMessage {
|
||||
@@ -741,9 +718,9 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Clearing decides what the *model* is given, not just what the
|
||||
/// phone draws. Everything above the marker stays in the transcript
|
||||
/// -- a person can still scroll back to it -- and none of it is sent.
|
||||
/// Clearing decides what the *model* is given, not just what the phone
|
||||
/// draws. Everything above the marker stays in the transcript and none of it
|
||||
/// is sent.
|
||||
fn the_conversation_starts_after_the_last_clear() {
|
||||
let (_dir, path) = transcript_with(&[
|
||||
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.
|
||||
//!
|
||||
//! Importing and deleting used to be whatever the phone was in the middle
|
||||
//! of: the request was the work, so leaving the screen cancelled it and
|
||||
//! coming back showed no sign it had ever started. Sessions half-imported
|
||||
//! that way are the expensive kind of missing -- the row is back in the
|
||||
//! list looking untouched, and taking it again is the second `--resume` the
|
||||
//! whole import path exists to prevent.
|
||||
//! Importing and deleting used to be whatever the phone was in the middle of:
|
||||
//! the request was the work, so leaving the screen cancelled it and coming back
|
||||
//! showed no sign it had ever started. Sessions half-imported that way are the
|
||||
//! expensive kind of missing -- the row is back in the list looking untouched,
|
||||
//! and taking it again is the second `--resume` the import path exists to
|
||||
//! prevent.
|
||||
//!
|
||||
//! 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
|
||||
//! /setups/{id}/importable` carries what is happening to it, which is what
|
||||
//! a phone that was asleep, out of range, or freshly opened has to go on;
|
||||
//! and [`Registry::subscribe`] is the live stream, which is what makes a
|
||||
//! screen somebody is looking at change by itself. Neither is sufficient
|
||||
//! alone -- a broadcast has no memory, and a listing is only true when it
|
||||
//! was fetched.
|
||||
//! phone reads that record two ways and needs both: every row of the importable
|
||||
//! listing carries what is happening to it, which is what a phone that was
|
||||
//! asleep has to go on; and [`Registry::subscribe`] is the live stream, which is
|
||||
//! what makes a screen change by itself. A broadcast has no memory, and a
|
||||
//! listing is only true when it was fetched.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
@@ -31,8 +29,8 @@ pub enum Operation {
|
||||
}
|
||||
|
||||
impl Operation {
|
||||
/// The word a row shows while this runs. Fixed here rather than in the
|
||||
/// app so the two ends cannot disagree about what a state is called.
|
||||
/// The word a row shows while this runs. Fixed here rather than in the app
|
||||
/// so the two ends cannot disagree about what a state is called.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Importing => "importing",
|
||||
@@ -43,11 +41,9 @@ impl Operation {
|
||||
|
||||
/// 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
|
||||
/// are easy to leave out: it can still be running, it can have finished,
|
||||
/// and it can have failed. There is deliberately no "unknown" -- this is
|
||||
/// the server's own work, so not knowing would be a bug rather than a
|
||||
/// state.
|
||||
/// The three states are every way an operation ends, including the two easy to
|
||||
/// leave out: still running, finished, and failed. There is deliberately no
|
||||
/// "unknown" -- this is the server's own work, so not knowing would be a bug.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase", tag = "state")]
|
||||
pub enum Change {
|
||||
@@ -68,9 +64,8 @@ pub enum Change {
|
||||
}
|
||||
|
||||
impl Change {
|
||||
/// Which machine this is about, so a stream scoped to one can drop the
|
||||
/// rest. Every variant carries it; matching here rather than at the
|
||||
/// filter keeps that fact in one place.
|
||||
/// Which machine this is about, so a stream scoped to one can drop the rest.
|
||||
/// Every variant carries it; matching here keeps that fact in one place.
|
||||
pub fn setup(&self) -> &str {
|
||||
match self {
|
||||
Self::Started { setup, .. }
|
||||
@@ -84,11 +79,10 @@ impl Change {
|
||||
#[derive(Debug)]
|
||||
pub struct Registry {
|
||||
running: Mutex<HashMap<(String, String), Operation>>,
|
||||
/// Kept after the operation ends, because a phone that was not looking
|
||||
/// when it failed has no other way to find out. Replaced when the next
|
||||
/// operation on that session starts, and dropped by [`Registry::prune`]
|
||||
/// when the session is no longer on the machine -- an error about a
|
||||
/// transcript that is gone has nothing left to be about.
|
||||
/// Kept after the operation ends, because a phone that was not looking when
|
||||
/// it failed has no other way to find out. Replaced when the next operation
|
||||
/// on that session starts, and dropped by [`Registry::prune`] when the
|
||||
/// session is no longer on the machine.
|
||||
failures: Mutex<HashMap<(String, String), String>>,
|
||||
changes: broadcast::Sender<Change>,
|
||||
}
|
||||
@@ -98,8 +92,8 @@ impl Default for Registry {
|
||||
Self {
|
||||
running: Mutex::new(HashMap::new()),
|
||||
failures: Mutex::new(HashMap::new()),
|
||||
// Enough that a phone watching one screen cannot lag behind a
|
||||
// batch of any size somebody would start by hand.
|
||||
// Enough that a phone watching one screen cannot lag behind a batch
|
||||
// of any size somebody would start by hand.
|
||||
changes: broadcast::channel(256).0,
|
||||
}
|
||||
}
|
||||
@@ -109,10 +103,9 @@ impl Registry {
|
||||
/// Marks an operation as running and announces it.
|
||||
///
|
||||
/// The returned guard is how it stops being marked: settle it with
|
||||
/// [`InFlight::succeeded`] or [`InFlight::failed`], or drop it and it
|
||||
/// reports a failure. Dropping without settling means the task was
|
||||
/// cancelled or panicked, and a row stuck on "importing" for ever is a
|
||||
/// worse answer than one that says it did not finish.
|
||||
/// [`InFlight::succeeded`] or [`InFlight::failed`], or drop it and it reports
|
||||
/// a failure. Dropping without settling means the task was cancelled or
|
||||
/// panicked, and a row stuck on "importing" for ever is a worse answer.
|
||||
pub fn begin(self: &Arc<Self>, setup: &str, session: &str, operation: Operation) -> InFlight {
|
||||
let key = (setup.to_string(), session.to_string());
|
||||
self.running.lock().unwrap().insert(key.clone(), operation);
|
||||
@@ -141,11 +134,8 @@ impl Registry {
|
||||
self.failures.lock().unwrap().get(&key).cloned()
|
||||
}
|
||||
|
||||
/// Forgets failures against sessions the machine no longer has.
|
||||
///
|
||||
/// 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.
|
||||
/// Forgets failures against sessions the machine no longer has. Called from
|
||||
/// the listing, which is the only place that knows what is still there.
|
||||
pub fn prune(&self, setup: &str, present: &[String]) {
|
||||
self.failures
|
||||
.lock()
|
||||
@@ -155,8 +145,8 @@ impl Registry {
|
||||
});
|
||||
}
|
||||
|
||||
/// Every change as it happens. See the module note on why this is not
|
||||
/// the only way the phone finds out.
|
||||
/// Every change as it happens. See the module note on why this is not the
|
||||
/// only way the phone finds out.
|
||||
pub fn subscribe(&self) -> broadcast::Receiver<Change> {
|
||||
self.changes.subscribe()
|
||||
}
|
||||
@@ -229,8 +219,8 @@ mod tests {
|
||||
assert!(matches!(changes.try_recv(), Ok(Change::Finished { .. })));
|
||||
}
|
||||
|
||||
/// A failure outlives the operation, because the phone that needs it may
|
||||
/// not have been listening when it happened.
|
||||
/// A failure outlives the operation, because the phone that needs it may not
|
||||
/// have been listening when it happened.
|
||||
#[test]
|
||||
fn a_failure_is_kept_until_something_replaces_or_prunes_it() {
|
||||
let registry = Arc::new(Registry::default());
|
||||
@@ -256,8 +246,8 @@ mod tests {
|
||||
assert!(registry.failure("local", "abc").is_none());
|
||||
}
|
||||
|
||||
/// Trying again clears the last failure, so a row cannot show an error
|
||||
/// from before the attempt somebody is currently watching.
|
||||
/// Trying again clears the last failure, so a row cannot show an error from
|
||||
/// before the attempt somebody is currently watching.
|
||||
#[test]
|
||||
fn starting_again_clears_the_previous_failure() {
|
||||
let registry = Arc::new(Registry::default());
|
||||
@@ -270,8 +260,8 @@ mod tests {
|
||||
second.succeeded();
|
||||
}
|
||||
|
||||
/// A task that is cancelled or panics must not leave a row saying
|
||||
/// something is still happening to it.
|
||||
/// A task that is cancelled or panics must not leave a row saying something
|
||||
/// is still happening to it.
|
||||
#[test]
|
||||
fn dropping_an_unsettled_operation_reports_a_failure() {
|
||||
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
|
||||
//! rather than start a second one.
|
||||
//!
|
||||
//! The server deliberately outlives its own restarts badly and its
|
||||
//! children well: stopping the backend must not kill a turn that is in
|
||||
//! flight, so session processes are left running and adopted again on the
|
||||
//! way back up. That only works if "is this still mine?" has an answer,
|
||||
//! which is what this module is.
|
||||
//! Stopping the backend must not kill a turn that is in flight, so session
|
||||
//! processes are left running and adopted again on the 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
|
||||
//! number alone eventually means treating a stranger's process as a
|
||||
//! session -- never resuming the real conversation, and signalling
|
||||
//! something unrelated when the session is deleted. The kernel's start
|
||||
//! time for that pid is recorded beside it; the pair is unique for as long
|
||||
//! as the machine has been up, which is longer than any of this lives.
|
||||
//! **A pid is not an identity.** Pids are reused, so adopting one by number
|
||||
//! alone eventually means treating a stranger's process as a session -- never
|
||||
//! resuming the real conversation, and signalling something unrelated when the
|
||||
//! session is deleted. The kernel's start time for that pid is recorded beside
|
||||
//! it; the pair is unique for as long as the machine has been up.
|
||||
//!
|
||||
//! **How to reach it again belongs here too**, in the same record and the
|
||||
//! same write, because it answers the other half of the same question: not
|
||||
//! 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 that
|
||||
//! takes differs by driver -- a reading position into a log for one spoken
|
||||
//! 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.
|
||||
//! **How to reach it again belongs here too**, in the same record and the same
|
||||
//! write, because it answers the other half of the same question. Splitting
|
||||
//! them would be two files that can disagree about one process. What it takes
|
||||
//! differs by driver, 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
|
||||
//! that write leaves a record that does not parse, which is read as "no
|
||||
//! live process" -- so the failure is the old behaviour (start one with
|
||||
//! `--resume`) rather than a wrong adoption.
|
||||
//! The record is rewritten in place as reading advances. A crash during that
|
||||
//! write leaves a record that does not parse, which is read as "no live
|
||||
//! process" -- so the failure is the old behaviour rather than a wrong
|
||||
//! adoption.
|
||||
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -40,8 +35,8 @@ const RECORD_FILE: &str = "process.json";
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Record {
|
||||
pub pid: u32,
|
||||
/// The kernel's start time for `pid`, in clock ticks since boot. See
|
||||
/// the module comment: this is what makes the pid an identity.
|
||||
/// The kernel's start time for `pid`, in clock ticks since boot. See the
|
||||
/// module comment: this is what makes the pid an identity.
|
||||
pub started: u64,
|
||||
/// What the driver needs in order to pick this process back up.
|
||||
#[serde(flatten)]
|
||||
@@ -52,24 +47,21 @@ pub struct Record {
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum Detail {
|
||||
/// Spoken to over stdio, which outlives the server as files in the
|
||||
/// session directory. `stdout_read` is how many bytes of the stdout
|
||||
/// log have already become events: everything before it is in the
|
||||
/// transcript, everything after it is what a reattaching server owes
|
||||
/// the conversation.
|
||||
/// Spoken to over stdio, which outlives the server as files in the session
|
||||
/// directory. `stdout_read` is how many bytes of the stdout log have
|
||||
/// already become events: everything after it is what a reattaching server
|
||||
/// owes the conversation.
|
||||
Stdio { stdout_read: u64 },
|
||||
/// Spoken to over HTTP on a loopback port, which is all it takes to
|
||||
/// find it again -- there is no stream to be partway through.
|
||||
/// Spoken to over HTTP on a loopback port, which is all it takes to find
|
||||
/// it again -- there is no stream to be partway through.
|
||||
Http { port: u16 },
|
||||
}
|
||||
|
||||
/// Whether a recorded process is still there.
|
||||
///
|
||||
/// Three answers rather than a boolean, because "I could not find out" is
|
||||
/// a real one and is not the same as "no". Treating it as "no" is what
|
||||
/// would start a second process against a conversation that already has
|
||||
/// one -- the expensive mistake this whole module exists to prevent -- so
|
||||
/// it has to be sayable.
|
||||
/// Three answers rather than a boolean, because "I could not find out" is a
|
||||
/// real one and is not the same as "no". Treating it as "no" is what would
|
||||
/// start a second process against a conversation that already has one.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Liveness {
|
||||
Alive,
|
||||
@@ -78,9 +70,9 @@ pub enum Liveness {
|
||||
}
|
||||
|
||||
impl Record {
|
||||
/// The record for a process this server just started, or `None` when
|
||||
/// the kernel will not say when it started -- which is the same
|
||||
/// answer as "do not adopt this later", and the safe one.
|
||||
/// The record for a process this server just started, or `None` when the
|
||||
/// kernel will not say when it started -- which is the same answer as "do
|
||||
/// not adopt this later", and the safe one.
|
||||
pub fn of(pid: u32, detail: Detail) -> Option<Self> {
|
||||
Some(Self {
|
||||
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 {
|
||||
match stat_of(self.pid) {
|
||||
// A different start time is a reused pid, which is a different
|
||||
// process and so definitely not ours.
|
||||
// A different start time is a reused pid, so definitely not ours.
|
||||
Ok(Some(stat)) if stat.started == self.started => {
|
||||
if stat.exited {
|
||||
Liveness::Dead
|
||||
@@ -112,12 +101,10 @@ fn path(session_dir: &Path) -> PathBuf {
|
||||
session_dir.join(RECORD_FILE)
|
||||
}
|
||||
|
||||
/// The recorded process and whether it is still there, or `None` when
|
||||
/// nothing 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 reading of that
|
||||
/// is that this server has no claim on anything.
|
||||
/// The recorded process and whether it is still there, or `None` when nothing
|
||||
/// 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
|
||||
/// reading is that this server has no claim on anything.
|
||||
pub fn recorded(session_dir: &Path) -> Option<(Record, Liveness)> {
|
||||
let text = std::fs::read_to_string(path(session_dir)).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))
|
||||
}
|
||||
|
||||
/// The recorded process if it is definitely still running.
|
||||
///
|
||||
/// 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 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
|
||||
/// the second half is the one that starts a duplicate.
|
||||
pub fn live(session_dir: &Path) -> Option<Record> {
|
||||
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
|
||||
/// rename is what makes this safe: a reader sees either the whole old
|
||||
/// record or the whole new one, never a partial.
|
||||
/// Writing in place would not be, and the consequence is severe rather than
|
||||
/// untidy. `fs::write` truncates before it fills, so a crash inside 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. 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
|
||||
/// than untidy. `fs::write` truncates before it fills, so a crash inside
|
||||
/// 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.
|
||||
/// Errors are logged rather than returned: this runs on the reading path, and a
|
||||
/// session that cannot save its position is still worth having.
|
||||
pub fn write(session_dir: &Path, record: &Record) {
|
||||
let path = path(session_dir);
|
||||
let text = match serde_json::to_string(record) {
|
||||
@@ -165,8 +145,8 @@ pub fn write(session_dir: &Path, record: &Record) {
|
||||
return;
|
||||
}
|
||||
};
|
||||
// Beside the real file so the rename stays within one filesystem,
|
||||
// which is what makes it atomic.
|
||||
// Beside the real file so the rename stays within one filesystem, which is
|
||||
// what makes it atomic.
|
||||
let temp = path.with_extension("json.new");
|
||||
let written = std::fs::OpenOptions::new()
|
||||
.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.
|
||||
///
|
||||
/// 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.
|
||||
/// 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.
|
||||
pub fn size_of(path: &Path) -> u64 {
|
||||
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.
|
||||
///
|
||||
/// Here rather than beside each caller: it is a property of stopping one of
|
||||
/// these, and two drivers plus the manager had written the same five seconds
|
||||
/// down separately, which is three places for it to drift.
|
||||
/// Here rather than beside each caller: two drivers plus the manager had
|
||||
/// written the same five seconds down separately.
|
||||
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
|
||||
/// actually end: a deleted session, or one being replaced.
|
||||
/// Asks it to stop, then makes sure. Used where a leaked process must actually
|
||||
/// end: a deleted session, or one being replaced.
|
||||
///
|
||||
/// SIGTERM first because the CLI writes its own session file on the way
|
||||
/// out and a SIGKILL would cost whatever it had not flushed; SIGKILL after
|
||||
/// the grace period because a session the phone has deleted must not still
|
||||
/// be running when it looks again.
|
||||
/// SIGTERM first because the CLI writes its own session file on the way out and
|
||||
/// a SIGKILL would cost whatever it had not flushed; SIGKILL after the grace
|
||||
/// period because a session the phone has deleted must not still be running.
|
||||
pub fn stop(record: &Record, grace: std::time::Duration) {
|
||||
if record.liveness() != Liveness::Alive {
|
||||
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
|
||||
/// not, for a caller that is about to exit.
|
||||
/// Waits for processes already asked to stop, and kills whichever have not, for
|
||||
/// a caller that is about to exit.
|
||||
///
|
||||
/// The waiting cannot be [`stop`]'s here, and that is the whole reason
|
||||
/// this exists: the kill it leaves behind is a timer inside the tokio
|
||||
/// runtime, and a runtime that is shutting down never runs it. That is
|
||||
/// how the backend's original `shutdown_all` leaked the processes it had
|
||||
/// just asked to stop -- it reported them stopped, too, which is worse
|
||||
/// than not asking.
|
||||
/// The waiting cannot be [`stop`]'s here, and that is the whole reason this
|
||||
/// exists: the kill it leaves behind is a timer inside the tokio runtime, and a
|
||||
/// runtime that is shutting down never runs it. That is how the original
|
||||
/// `shutdown_all` leaked the processes it had just asked to stop -- it reported
|
||||
/// them stopped, too, which is worse than not asking.
|
||||
///
|
||||
/// 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
|
||||
/// are, and a server does not sit for a minute on the way out.
|
||||
/// together, so waiting is bounded by the grace period however many there are.
|
||||
pub fn wait_gone(records: &[Record], grace: std::time::Duration) {
|
||||
/// How often to look. Short enough that the ordinary case -- a
|
||||
/// process that goes at once -- costs nothing noticeable, and long
|
||||
/// enough not to spin.
|
||||
/// How often to look. Short enough that a process that goes at once costs
|
||||
/// nothing noticeable, and long enough not to spin.
|
||||
const LOOK: std::time::Duration = std::time::Duration::from_millis(20);
|
||||
|
||||
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
|
||||
/// not is killed. Written once because the two callers differ only in how
|
||||
/// they wait, and a grace period that means one thing in one of them and
|
||||
/// something else in the other is exactly the drift `STOP_GRACE` was
|
||||
/// gathered here to prevent.
|
||||
/// The end of both paths above: a process that was asked to stop and did not is
|
||||
/// killed. Written once because the two callers differ only in how they wait,
|
||||
/// and a grace period meaning one thing in one and something else in the other
|
||||
/// is exactly the drift `STOP_GRACE` was gathered here to prevent.
|
||||
fn kill_if_still_there(record: &Record, grace: std::time::Duration) {
|
||||
if record.liveness() == Liveness::Alive {
|
||||
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) {
|
||||
// SAFETY: `kill` with a positive pid touches only that process, and
|
||||
// the pid came from a record whose start time was just confirmed to
|
||||
// match -- so it is still the process this server started, not a
|
||||
// reused number. A failure (already gone) is nothing to act on.
|
||||
// SAFETY: `kill` with a positive pid touches only that process, and the pid
|
||||
// came from a record whose start time was just confirmed to match -- so it
|
||||
// is still the process this server started, not a reused number. A failure
|
||||
// (already gone) is nothing to act on.
|
||||
unsafe {
|
||||
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.
|
||||
struct Stat {
|
||||
/// The kernel's start time in clock ticks since boot -- see
|
||||
/// [`Record::started`].
|
||||
started: u64,
|
||||
/// State `Z`: the process has ended, and the kernel is keeping its
|
||||
/// entry only until somebody collects the exit status.
|
||||
/// State `Z`: the process has ended, and the kernel is keeping its entry
|
||||
/// only until somebody collects the exit status.
|
||||
///
|
||||
/// Read rather than ignored, because the entry it leaves behind has
|
||||
/// the same pid *and* the same start time, so a process that has
|
||||
/// plainly finished goes on answering "still there" for as long as
|
||||
/// nothing reaps it. None of this module's callers want that answer: a
|
||||
/// session whose CLI has exited is over whether or not the status has
|
||||
/// 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.
|
||||
/// Read rather than ignored, because that entry has the same pid *and* the
|
||||
/// same start time, so a finished process goes on answering "still there"
|
||||
/// for as long as nothing reaps it -- which makes `Exited` unsayable: the
|
||||
/// session shows `unknown`, its Start button never appears, and stopping it
|
||||
/// says there is nothing to stop.
|
||||
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>> {
|
||||
let stat = match std::fs::read_to_string(format!("/proc/{pid}/stat")) {
|
||||
Ok(stat) => stat,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
// A `/proc` entry that exists but does not have the shape this reads
|
||||
// is not a process that has gone away; it is a reading this code
|
||||
// cannot make, which is the other thing entirely.
|
||||
// A `/proc` entry that exists but does not have the shape this reads is not
|
||||
// a process that has gone away; it is a reading this code cannot make.
|
||||
let unreadable =
|
||||
|| std::io::Error::new(std::io::ErrorKind::InvalidData, "unreadable /proc stat");
|
||||
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
|
||||
// and field 22 is the 20th.
|
||||
// Field 3 is the first after the name, so the state is the first here and
|
||||
// field 22 is the 20th.
|
||||
let mut fields = after_name.split_whitespace();
|
||||
let exited = fields.next().ok_or_else(unreadable)? == "Z";
|
||||
let started = fields
|
||||
@@ -346,9 +311,9 @@ fn stat_of(pid: u32) -> std::io::Result<Option<Stat>> {
|
||||
Ok(Some(Stat { started, exited }))
|
||||
}
|
||||
|
||||
/// Reads `path` from `from`, returning what is there and where reading
|
||||
/// reached. A file that has been truncated or replaced under us reads from
|
||||
/// the start, since the offset no longer means anything in it.
|
||||
/// Reads `path` from `from`, returning what is there and where reading reached.
|
||||
/// A file truncated or replaced under us reads from the start, since the offset
|
||||
/// no longer means anything in it.
|
||||
pub fn read_from(path: &Path, from: u64) -> Result<(Vec<u8>, u64)> {
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
let mut file = match std::fs::File::open(path) {
|
||||
@@ -380,8 +345,8 @@ mod tests {
|
||||
.expect("this process has a start time");
|
||||
assert_eq!(mine.liveness(), Liveness::Alive);
|
||||
|
||||
// The same pid with a different start time is a different process
|
||||
// -- which is the whole reason the start time is recorded.
|
||||
// The same pid with a different start time is a different process --
|
||||
// which is the whole reason the start time is recorded.
|
||||
let recycled = Record {
|
||||
started: mine.started + 1,
|
||||
..mine.clone()
|
||||
@@ -416,8 +381,8 @@ mod tests {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let mut record =
|
||||
Record::of(std::process::id(), Detail::Stdio { stdout_read: 0 }).expect("start time");
|
||||
// Rewritten the way the reader rewrites it: constantly, as the
|
||||
// position advances. Each one must land whole.
|
||||
// Rewritten the way the reader rewrites it: constantly, as the position
|
||||
// advances. Each one must land whole.
|
||||
for read in [1u64, 4096, 2, 999_999] {
|
||||
record.detail = Detail::Stdio { stdout_read: read };
|
||||
write(dir.path(), &record);
|
||||
@@ -427,8 +392,8 @@ mod tests {
|
||||
"after offset {read}"
|
||||
);
|
||||
}
|
||||
// The rename is what makes it atomic; a leftover neighbour would
|
||||
// mean it had not happened.
|
||||
// The rename is what makes it atomic; a leftover neighbour would mean it
|
||||
// had not happened.
|
||||
let stray: Vec<_> = std::fs::read_dir(dir.path())
|
||||
.expect("read dir")
|
||||
.filter_map(Result::ok)
|
||||
@@ -468,8 +433,8 @@ mod tests {
|
||||
assert_eq!(bytes, b"world");
|
||||
assert_eq!(read, 11);
|
||||
|
||||
// An offset past the end means the file was replaced, so the
|
||||
// offset describes a file that no longer exists.
|
||||
// An offset past the end means the file was replaced, so the offset
|
||||
// describes a file that no longer exists.
|
||||
std::fs::write(&path, b"new").expect("truncate");
|
||||
let (bytes, read) = read_from(&path, 11).expect("read");
|
||||
assert_eq!(bytes, b"new");
|
||||
|
||||
@@ -41,9 +41,8 @@ impl Transcript {
|
||||
/// the last line if one exists.
|
||||
pub fn open(path: &Path) -> Result<Self> {
|
||||
// One pass for all three answers. They are wanted at the same moment
|
||||
// by the same caller, and reading the file again for each doubled
|
||||
// the cost of starting every session -- which is paid per session,
|
||||
// at the point a restart is trying to be quick.
|
||||
// by the same caller, and reading the file again for each doubled the
|
||||
// cost of starting every session.
|
||||
let existing = read_after(path, 0)?;
|
||||
let last_seq = existing.last().map(|entry| entry.seq).unwrap_or(0);
|
||||
let last_status = existing.iter().rev().find_map(|entry| match entry.event {
|
||||
@@ -63,9 +62,9 @@ impl Transcript {
|
||||
next_seq: last_seq + 1,
|
||||
last_status,
|
||||
last_activity: existing.last().map(|entry| entry.ts),
|
||||
// Folded rather than read off the newest usage entry: a clear
|
||||
// or a compaction after it is what the answer is, and those
|
||||
// events carry no usage of their own.
|
||||
// Folded rather than read off the newest usage entry: a clear or
|
||||
// a compaction after it is what the answer is, and those events
|
||||
// carry no usage of their own.
|
||||
context_tokens: existing
|
||||
.iter()
|
||||
.fold(None, |current, entry| context_after(current, &entry.event)),
|
||||
@@ -74,53 +73,43 @@ impl Transcript {
|
||||
|
||||
/// The state the session was last reported to be in, as of opening.
|
||||
///
|
||||
/// Read from the file rather than assumed, because a server that has
|
||||
/// just restarted has been told nothing yet and this is the only thing
|
||||
/// it knows. Assuming idle claimed a session was waiting for you when
|
||||
/// it had exited hours earlier, and would now also claim it of one
|
||||
/// whose process is still mid-turn.
|
||||
/// Read from the file rather than assumed, because a server that has just
|
||||
/// restarted has been told nothing. Assuming idle claimed a session was
|
||||
/// waiting for you when it had exited hours earlier.
|
||||
///
|
||||
/// `None` for a transcript that never carried a status, which is a new
|
||||
/// session and genuinely has no prior state.
|
||||
/// `None` for a transcript that never carried a status.
|
||||
pub fn last_status(&self) -> Option<SessionStatus> {
|
||||
self.last_status
|
||||
}
|
||||
|
||||
/// When this session last did anything, as of opening.
|
||||
///
|
||||
/// Read from the file for the same reason [`Transcript::last_status`]
|
||||
/// is, and it is the same mistake in the other direction: a restarting
|
||||
/// server has been told nothing, and taking the clock instead said every
|
||||
/// session it relaunched had been active this second. On the phone that
|
||||
/// is every row reading "just now" and the list -- which is sorted by
|
||||
/// this -- coming back in an order that means nothing, with the
|
||||
/// conversation somebody was in the middle of buried among sessions
|
||||
/// untouched for days.
|
||||
/// Read from the file for the reason [`Transcript::last_status`] is, and it
|
||||
/// is the same mistake in the other direction: taking the clock instead
|
||||
/// said every session it relaunched had been active this second. On the
|
||||
/// phone that is every row reading "just now" and the list -- sorted by
|
||||
/// this -- in an order that means nothing.
|
||||
///
|
||||
/// `None` for a transcript with no lines in it, which is a session that
|
||||
/// genuinely has not done anything yet. Its caller answers that with
|
||||
/// when the session was created -- not with the clock, which would say
|
||||
/// a session nobody has ever sent anything to was active a moment ago,
|
||||
/// every time this server started.
|
||||
/// `None` for a transcript with no lines, which is a session that genuinely
|
||||
/// has not done anything. Its caller answers with when the session was
|
||||
/// created, not with the clock.
|
||||
pub fn last_activity(&self) -> Option<f64> {
|
||||
self.last_activity
|
||||
}
|
||||
|
||||
/// How much context the session was holding, as of opening.
|
||||
///
|
||||
/// `None` for a transcript nothing has been measured in -- a new
|
||||
/// session, one whose dialect never reported usage, or one whose last
|
||||
/// word on the subject was a clear. That is not zero, and it is why
|
||||
/// this is an option: a server that has just restarted has been told
|
||||
/// nothing, and answering zero would draw an empty context for a
|
||||
/// conversation that may be nearly full.
|
||||
/// `None` for a transcript nothing has been measured in. That is not zero:
|
||||
/// a server that has just restarted has been told nothing, and answering
|
||||
/// zero would draw an empty context for a conversation that may be nearly
|
||||
/// full.
|
||||
pub fn context_tokens(&self) -> Option<u64> {
|
||||
self.context_tokens
|
||||
}
|
||||
|
||||
/// Appends `event`, assigning it the next sequence number. Flushed per
|
||||
/// event: each line is tiny, and the transcript is the source of truth
|
||||
/// a crash must not lose the tail of.
|
||||
/// event: each line is tiny, and the transcript is the source of truth a
|
||||
/// crash must not lose the tail of.
|
||||
pub fn append(&mut self, event: Event, ts: f64) -> Result<SeqEvent> {
|
||||
let entry = SeqEvent {
|
||||
seq: self.next_seq,
|
||||
@@ -139,23 +128,19 @@ impl Transcript {
|
||||
|
||||
/// A window of the transcript ending just before `before`, newest-biased.
|
||||
///
|
||||
/// The screen opens on the end of a conversation, not the start of it, and
|
||||
/// the end is all it can show at once. Replaying the whole file to get
|
||||
/// there costs one network frame per event -- on an 863-event import that
|
||||
/// was several seconds of messages arriving oldest-first, which reads as
|
||||
/// the app loading top-down because that is exactly what it was doing.
|
||||
/// The screen opens on the end of a conversation, and the end is all it can
|
||||
/// show at once. Replaying the whole file to get there costs one network frame
|
||||
/// per event -- on an 863-event import that was several seconds of messages
|
||||
/// arriving oldest-first, which reads as the app loading top-down.
|
||||
///
|
||||
/// `before` pages backwards for history somebody actually scrolls to. Only
|
||||
/// the window is parsed; see [`Indexed`] for why that is the whole cost of
|
||||
/// this call.
|
||||
/// `before` pages backwards for history somebody actually scrolls to. Only the
|
||||
/// window is parsed; see [`Indexed`] for why that is the whole cost.
|
||||
///
|
||||
/// `after` is a floor: nothing at or below it is returned, and the page
|
||||
/// stops there rather than at `limit`. A phone holding a cached run of the
|
||||
/// transcript passes the end of what it already has, so the page it gets
|
||||
/// back is exactly the gap and never overlaps its copy -- an overlap it
|
||||
/// cannot store, since a coalesced event cannot be cut at a seq inside its
|
||||
/// own delta run. A run cut by this floor is emitted as the partial it is,
|
||||
/// exactly as one cut by `limit` already is.
|
||||
/// `after` is a floor: nothing at or below it is returned, and the page stops
|
||||
/// there rather than at `limit`. A phone holding a cached run passes the end of
|
||||
/// what it already has, so the page is exactly the gap and never overlaps its
|
||||
/// copy -- an overlap it cannot store, since a coalesced event cannot be cut at
|
||||
/// a seq inside its own delta run.
|
||||
pub fn read_window(
|
||||
path: &Path,
|
||||
before: Option<u64>,
|
||||
@@ -176,11 +161,11 @@ pub fn read_window(
|
||||
};
|
||||
// A floor above the window is an empty page, not a walk backwards past it.
|
||||
let start = start.min(end);
|
||||
// Coalescing counts *rows*, not events, and would misread the newest window: a message still
|
||||
// streaming there would fold to one event whose seq is its first delta, and the phone resumes
|
||||
// its live stream from the newest seq it applied -- so the deltas the coalesced event hid
|
||||
// would replay and double. Only settled history (`before` set) is safe, and it is the only
|
||||
// place the phone asks for it. See `parse_coalesced`.
|
||||
// Coalescing counts *rows*, not events, and would misread the newest
|
||||
// window: a message still streaming there would fold to one event whose seq
|
||||
// is its first delta, and the phone resumes its live stream from the newest
|
||||
// seq it applied -- so the deltas the coalesced event hid would replay and
|
||||
// double. Only settled history (`before` set) is safe.
|
||||
if coalesce && before.is_some() {
|
||||
indexed.parse_coalesced(start, end, limit)
|
||||
} else {
|
||||
@@ -191,41 +176,34 @@ pub fn read_window(
|
||||
/// How far behind a reconnecting subscriber can be and still be handed the
|
||||
/// backlog one event at a time.
|
||||
///
|
||||
/// Past this it is served better by rebuilding its view from the newest
|
||||
/// window than by receiving everything it missed. The events are the same
|
||||
/// either way; what differs is that one arrives as a single window and the
|
||||
/// other as thousands of frames a screen renders one by one. Set well
|
||||
/// above a screenful (`transcript`'s page is 80) so an ordinary blip -- a
|
||||
/// phone asleep, a tunnel reconnecting, a backend restart -- still streams
|
||||
/// continuously, and only a genuine backlog changes mode.
|
||||
/// Past this it is served better by rebuilding from the newest window. The
|
||||
/// events are the same either way; what differs is that one arrives as a single
|
||||
/// window and the other as thousands of frames a screen renders one by one. Set
|
||||
/// well above a screenful so an ordinary blip still streams continuously.
|
||||
pub const CATCH_UP_LIMIT: usize = 200;
|
||||
|
||||
/// What a subscriber asking for "everything after my cursor" gets back.
|
||||
///
|
||||
/// Two answers rather than one list, because they mean different things to
|
||||
/// the screen holding the cursor: one continues what it already has, the
|
||||
/// other replaces it. Collapsing them into a list would leave the client
|
||||
/// splicing a window onto rows it has no way to know are no longer
|
||||
/// adjacent to it -- a seam that looks exactly like ordinary output.
|
||||
/// Two answers rather than one list, because they mean different things to the
|
||||
/// screen holding the cursor: one continues what it has, the other replaces it.
|
||||
/// Collapsing them would leave the client splicing a window onto rows it has no
|
||||
/// way to know are no longer adjacent -- a seam that looks like ordinary output.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum CatchUp {
|
||||
/// The events after the cursor, continuing what the subscriber holds.
|
||||
Continue(Vec<SeqEvent>),
|
||||
/// The subscriber was further behind than [`CATCH_UP_LIMIT`]: the
|
||||
/// newest window, replacing whatever it holds. Earlier history is
|
||||
/// still there to be paged backwards through, exactly as it is when a
|
||||
/// session is first opened.
|
||||
/// The subscriber was further behind than [`CATCH_UP_LIMIT`]: the newest
|
||||
/// window, replacing whatever it holds. Earlier history is still there to be
|
||||
/// paged backwards through.
|
||||
Restart(Vec<SeqEvent>),
|
||||
}
|
||||
|
||||
/// Everything after `after`, or the newest `limit` when that is more than
|
||||
/// `limit` events.
|
||||
/// Everything after `after`, or the newest `limit` when that is more.
|
||||
///
|
||||
/// The window is chosen before anything is parsed, which matters most in
|
||||
/// the case that looks least interesting: a subscriber with no cursor at
|
||||
/// all asks for the whole conversation and is going to be handed the last
|
||||
/// [`CATCH_UP_LIMIT`] events of it. Parsing the discarded prefix first is
|
||||
/// the whole file's worth of work to produce a screenful.
|
||||
/// The window is chosen before anything is parsed, which matters most in the
|
||||
/// case that looks least interesting: a subscriber with no cursor asks for the
|
||||
/// whole conversation and is handed the last [`CATCH_UP_LIMIT`] events of it,
|
||||
/// so parsing the discarded prefix is the whole file's work for a screenful.
|
||||
pub fn catch_up(path: &Path, after: u64, limit: usize) -> Result<CatchUp> {
|
||||
let Some(indexed) = Indexed::read(path)? else {
|
||||
return Ok(CatchUp::Continue(Vec::new()));
|
||||
@@ -249,26 +227,22 @@ pub fn read_after(path: &Path, after: u64) -> Result<Vec<SeqEvent>> {
|
||||
indexed.parse(start..indexed.lines.len())
|
||||
}
|
||||
|
||||
/// The transcript's lines located but not read, so that a reader can find
|
||||
/// the range it wants and parse only that.
|
||||
/// The transcript's lines located but not read, so a reader can find the range
|
||||
/// it wants and parse only that.
|
||||
///
|
||||
/// Both readers above want a *range* of the file -- everything after a
|
||||
/// cursor, or the window before one -- and both used to reach it by parsing
|
||||
/// every line and discarding the ones outside it. That is the cost that
|
||||
/// grows with the conversation rather than with the answer: measured on a
|
||||
/// 21 MB, 24,000-event transcript, one page took **500 ms of server time to
|
||||
/// return 600 KB**, and it took the same 500 ms whichever page was asked
|
||||
/// for, since the work was the file rather than the window. A phone paging
|
||||
/// back through history pays it per page, and every stream reconnect pays
|
||||
/// it again to discover there is nothing new.
|
||||
/// Both readers above want a *range* of the file, and both used to reach it by
|
||||
/// parsing every line and discarding the ones outside it -- the cost that grows
|
||||
/// with the conversation rather than with the answer. Measured on a 21 MB,
|
||||
/// 24,000-event transcript, one page took **500 ms of server time to return
|
||||
/// 600 KB**, and the same 500 ms whichever page was asked for. A phone paging
|
||||
/// back pays it per page, and every stream reconnect pays it again to discover
|
||||
/// there is nothing new.
|
||||
///
|
||||
/// Sequence numbers only ever increase -- the writer assigns them, one per
|
||||
/// appended line, continuing from the last on reopen -- so the boundary of
|
||||
/// a range is a bisection. This parses one line per halving, and the caller
|
||||
/// parses only what it is going to return. The file is still read whole,
|
||||
/// which is a deliberate stop: finding the tail without reading forwards
|
||||
/// means a chunked backwards reader, and locating a line is not what the
|
||||
/// half-second was going to.
|
||||
/// Sequence numbers only ever increase, so the boundary of a range is a
|
||||
/// bisection: this parses one line per halving, and the caller parses only what
|
||||
/// it returns. The file is still read whole, which is a deliberate stop --
|
||||
/// going further means a chunked backwards reader, and locating a line is not
|
||||
/// what the half-second was going to.
|
||||
struct Indexed<'a> {
|
||||
path: &'a Path,
|
||||
text: String,
|
||||
@@ -302,14 +276,13 @@ impl<'a> Indexed<'a> {
|
||||
Ok(Some(Self { path, text, lines }))
|
||||
}
|
||||
|
||||
/// The index of the first line numbered `seq` or higher, or the end
|
||||
/// when every line is older than that.
|
||||
/// The index of the first line numbered `seq` or higher, or the end when
|
||||
/// every line is older than that.
|
||||
///
|
||||
/// A bisection, which is only correct because the file is in sequence
|
||||
/// order; it is append-only and nothing else writes it. A line that
|
||||
/// cannot be read is reported here rather than silently treated as
|
||||
/// out of range, because the answer would be a window off by however
|
||||
/// much of the file the bad line hid.
|
||||
/// A bisection, which is only correct because the file is in sequence order.
|
||||
/// A line that cannot be read is reported here rather than silently treated
|
||||
/// as out of range, because the answer would be a window off by however much
|
||||
/// of the file the bad line hid.
|
||||
fn first_at_or_after(&self, seq: u64) -> Result<usize> {
|
||||
let (mut low, mut high) = (0, self.lines.len());
|
||||
while low < high {
|
||||
@@ -350,23 +323,20 @@ impl<'a> Indexed<'a> {
|
||||
.with_context(|| format!("bad transcript line in {}", self.path.display()))
|
||||
}
|
||||
|
||||
/// The newest `limit` *rows* ending at line `end`, with each run of consecutive streamed
|
||||
/// [`Event::AssistantText`] deltas concatenated into one.
|
||||
/// The newest `limit` *rows* ending at line `end`, with each run of
|
||||
/// consecutive [`Event::AssistantText`] deltas concatenated into one.
|
||||
///
|
||||
/// A reply is stored a token at a time -- hundreds of `AssistantText` events for one message --
|
||||
/// so a window counted in events is a fraction of a row for a reply and a whole row for a tool
|
||||
/// call, and the phone can neither predict how much a page will show nor fill a screen without
|
||||
/// folding a page's worth of near-duplicate events. Counted in rows, a page is a page: this
|
||||
/// walks back from `end`, joining each delta run into the single event the phone would fold it
|
||||
/// into anyway, and stops once `limit` of them are gathered.
|
||||
/// A reply is stored a token at a time, so a window counted in events is a
|
||||
/// fraction of a row for a reply and a whole row for a tool call, and the
|
||||
/// phone can neither predict how much a page will show nor fill a screen
|
||||
/// without folding a page of near-duplicate events. Counted in rows, a page
|
||||
/// is a page.
|
||||
///
|
||||
/// A run takes the seq and time of its *oldest* delta, matching the phone's own rule that a
|
||||
/// streamed message keeps the seq of its first delta -- so anchors, and the `before` cursor the
|
||||
/// next page pages from, land where they always did. A run cut by the `limit` (its older
|
||||
/// deltas beyond this page) is emitted as the partial it is; the next page carries the rest and
|
||||
/// the phone's `healSplitMessage` welds the two, exactly as it does for a run cut by any page
|
||||
/// boundary. `start` is the same kind of cut from the other end -- the floor `read_window`'s
|
||||
/// `after` computes -- and a run reaching it is partial in the same way.
|
||||
/// A run takes the seq and time of its *oldest* delta, matching the phone's
|
||||
/// own rule -- so anchors and the `before` cursor land where they always
|
||||
/// did. A run cut by the `limit` is emitted as the partial it is, and the
|
||||
/// phone's `healSplitMessage` welds it to the next page. `start` is the same
|
||||
/// kind of cut from the other end.
|
||||
fn parse_coalesced(&self, start: usize, end: usize, limit: usize) -> Result<Vec<SeqEvent>> {
|
||||
// Newest first while walking back, reversed to transcript order at the end.
|
||||
let mut out: Vec<SeqEvent> = Vec::new();
|
||||
@@ -386,9 +356,9 @@ impl<'a> Indexed<'a> {
|
||||
};
|
||||
let mut index = end;
|
||||
while index > start {
|
||||
// A row is counted when it lands in `out`; an open run is the row being gathered, so
|
||||
// stopping while one is open would drop the deltas already read. Break only between
|
||||
// rows, and flush the last run after the loop.
|
||||
// A row is counted when it lands in `out`; an open run is the row
|
||||
// being gathered, so stopping while one is open would drop the
|
||||
// deltas already read. Break only between rows.
|
||||
if out.len() >= limit && run.is_none() {
|
||||
break;
|
||||
}
|
||||
@@ -487,9 +457,9 @@ mod tests {
|
||||
assert_eq!(events[0].seq, 6);
|
||||
assert_eq!(events[4].seq, 10);
|
||||
|
||||
// Exactly at the limit is still a continuation: the boundary
|
||||
// belongs to the cheaper answer, so a client is not reset for
|
||||
// being one event behind the threshold.
|
||||
// Exactly at the limit is still a continuation: the boundary belongs to
|
||||
// the cheaper answer, so a client is not reset for being one event
|
||||
// behind the threshold.
|
||||
assert!(matches!(
|
||||
catch_up(&path, 5, 5).expect("catch up"),
|
||||
CatchUp::Continue(_)
|
||||
@@ -501,8 +471,8 @@ mod tests {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("transcript.jsonl");
|
||||
|
||||
// Nothing recorded yet: no prior state to report, which is not the
|
||||
// same as reporting idle.
|
||||
// Nothing recorded yet: no prior state to report, which is not the same
|
||||
// as reporting idle.
|
||||
assert_eq!(Transcript::open(&path).expect("open").last_status(), None);
|
||||
|
||||
let mut transcript = Transcript::open(&path).expect("open");
|
||||
|
||||
@@ -1,23 +1,19 @@
|
||||
//! 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.
|
||||
//! Whether that becomes a child of this process or an `ssh host …`
|
||||
//! invocation is settled in this module, so a driver carries no transport
|
||||
//! knowledge and a second one cannot forget to handle the remote case. It
|
||||
//! also means the wrapping is honest about drivers that run nothing at
|
||||
//! all: `EchoDriver` builds no [`Launch`], so there is nothing to wrap and
|
||||
//! no host for it to appear to honour.
|
||||
//! A driver says *what* to run -- a [`Launch`] -- and hands it here. Whether
|
||||
//! that becomes a child of this process or an `ssh host …` invocation is
|
||||
//! settled in this module, so a driver carries no transport knowledge and a
|
||||
//! second one cannot forget to handle the remote case. It also means the
|
||||
//! wrapping is honest about drivers that run nothing at all: `EchoDriver`
|
||||
//! builds no [`Launch`], so there is no host for it to appear to honour.
|
||||
//!
|
||||
//! The quoting, the forced ssh options and the remote script are
|
||||
//! `crate::ssh`'s, which this dispatches to. That split is deliberate:
|
||||
//! this module decides *which* transport, that one knows what a correct
|
||||
//! ssh invocation is.
|
||||
//! `crate::ssh`'s: this module decides *which* transport, that one knows what a
|
||||
//! correct ssh invocation is.
|
||||
//!
|
||||
//! Known second operation, not built because nothing needs it yet: a
|
||||
//! managed `llama-server` is spawned as a process but then spoken to over
|
||||
//! HTTP, so a remote one needs a forwarded port (`ssh -L`) as well. A
|
||||
//! transport is eventually "run this" plus "reach this port", where the
|
||||
//! second is a no-op locally. See PLAN.md's SSH section.
|
||||
//! Known second operation, not built because nothing needs it yet: a managed
|
||||
//! `llama-server` is spawned as a process but then spoken to over HTTP, so a
|
||||
//! remote one needs a forwarded port (`ssh -L`) as well.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Stdio;
|
||||
@@ -27,11 +23,9 @@ use tokio::process::Child;
|
||||
|
||||
use crate::config::SshConfig;
|
||||
|
||||
/// 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 needs -- a port, a key, extra ssh options -- is
|
||||
/// the transport's own configuration, not something a driver states.
|
||||
/// 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
|
||||
/// needs is the transport's own configuration, not something a driver states.
|
||||
pub struct Launch {
|
||||
pub program: String,
|
||||
pub args: Vec<String>,
|
||||
@@ -51,22 +45,20 @@ impl Launch {
|
||||
/// How a launched process's standard streams are connected.
|
||||
///
|
||||
/// 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
|
||||
/// asked a question and answers within one call, so pipes this server
|
||||
/// drains are right and dying with it is right. A session is a
|
||||
/// whether the process is expected to outlive this server. A probe answers
|
||||
/// within one call, so pipes this server drains are right. A session is a
|
||||
/// conversation somebody is having, so its streams live in the session
|
||||
/// directory where a later run of this server can pick them up again --
|
||||
/// see `session::process`.
|
||||
/// directory where a later run of this server can pick them up.
|
||||
pub enum Streams {
|
||||
/// Pipes owned by this server; the child is killed when they drop.
|
||||
Piped,
|
||||
/// The same, except that stdin is already open on something this
|
||||
/// server holds -- the file being copied to another machine. Bytes
|
||||
/// this process has in memory do not need this: [`Streams::Piped`]
|
||||
/// gives a pipe to write them into as the child reads.
|
||||
/// The same, except that stdin is already open on something this server
|
||||
/// holds -- the file being copied to another machine. Bytes this process has
|
||||
/// in memory do not need this: [`Streams::Piped`] gives a pipe to write them
|
||||
/// into as the child reads.
|
||||
PipedFrom(Stdio),
|
||||
/// Files -- and, for stdin, a fifo the child itself holds open so it
|
||||
/// never reads EOF -- that outlast this process.
|
||||
/// Files -- and, for stdin, a fifo the child itself holds open so it never
|
||||
/// reads EOF -- that outlast this process.
|
||||
Detached {
|
||||
stdin: Stdio,
|
||||
stdout: Stdio,
|
||||
@@ -80,8 +72,7 @@ pub enum Transport {
|
||||
Here,
|
||||
/// Reached with the system `ssh` client. Owns its entry rather than
|
||||
/// borrowing it, so a session keeps working against the config it was
|
||||
/// spawned with even if the setup is edited afterwards. Carries the
|
||||
/// setup's name only to say where things are running.
|
||||
/// spawned with even if the setup is edited afterwards.
|
||||
Ssh { name: String, ssh: SshConfig },
|
||||
}
|
||||
|
||||
@@ -97,12 +88,10 @@ impl Transport {
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts `launch` with its streams connected as `streams` says.
|
||||
///
|
||||
/// The failure names what to check, and the two transports fail for
|
||||
/// genuinely different reasons -- a missing ssh client here versus a
|
||||
/// program that is not on the remote PATH -- so each says its own
|
||||
/// thing rather than one message hedging between them.
|
||||
/// Starts `launch` with its streams connected as `streams` says. The failure
|
||||
/// names what to check, and the two transports fail for genuinely different
|
||||
/// reasons -- a missing ssh client here versus a program not on the remote
|
||||
/// PATH -- so each says its own thing.
|
||||
pub fn spawn(&self, launch: &Launch, streams: Streams) -> Result<Child> {
|
||||
let host = match self {
|
||||
Self::Here => None,
|
||||
@@ -135,11 +124,9 @@ impl Transport {
|
||||
stderr,
|
||||
} => {
|
||||
command.stdin(stdin).stdout(stdout).stderr(stderr);
|
||||
// No `kill_on_drop`: outliving this server is the point.
|
||||
// Its own process group as well, so a signal sent to the
|
||||
// server's group -- which is how a terminal or a
|
||||
// supervisor stops it -- does not travel to a session that
|
||||
// is meant to survive being stopped.
|
||||
// No `kill_on_drop`: outliving this server is the point. Its own
|
||||
// process group as well, so a signal sent to the server's group
|
||||
// does not travel to a session meant to survive being stopped.
|
||||
command.process_group(0);
|
||||
}
|
||||
}
|
||||
@@ -157,13 +144,10 @@ impl Transport {
|
||||
})
|
||||
}
|
||||
|
||||
/// Runs `launch` to completion and returns its stdout, blocking.
|
||||
///
|
||||
/// The synchronous twin of `capture`, for callers that are already on a
|
||||
/// blocking task and would otherwise need a runtime to ask a machine a
|
||||
/// 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.
|
||||
/// Runs `launch` to completion and returns its stdout, blocking. The
|
||||
/// synchronous twin of `capture`, for callers already on a blocking task that
|
||||
/// would otherwise need a runtime to ask a machine a question. Both build the
|
||||
/// invocation the same way.
|
||||
pub fn capture_blocking(&self, launch: &Launch) -> Result<String> {
|
||||
let host = match self {
|
||||
Self::Here => None,
|
||||
@@ -189,21 +173,19 @@ impl Transport {
|
||||
/// Runs `launch` with `input` on its stdin and reports everything it
|
||||
/// produced -- stdout as bytes, stderr as text, and the exit status.
|
||||
///
|
||||
/// The one description of "run this there, with this on stdin", so
|
||||
/// that shipping an attachment and writing a file through the explorer
|
||||
/// are the same operation rather than two. It is also the only capture
|
||||
/// that hands back the **status**: a script can then answer with an
|
||||
/// exit code the caller distinguishes (the explorer's write says
|
||||
/// `exit 3` for "this file is not the one you read"), which
|
||||
/// [`Transport::capture`] cannot express because it turns every
|
||||
/// failure into one error.
|
||||
/// The one description of "run this there, with this on stdin", so that
|
||||
/// shipping an attachment and writing a file through the explorer are the
|
||||
/// same operation rather than two. It is also the only capture that hands
|
||||
/// back the **status**: a script can answer with an exit code the caller
|
||||
/// distinguishes (the explorer's write says `exit 3` for "this file is not
|
||||
/// the one you read"), which [`Transport::capture`] cannot express.
|
||||
///
|
||||
/// Bytes rather than a `String`, because a file's contents are not
|
||||
/// text until something has checked, and lossy decoding would replace
|
||||
/// the evidence that they are not.
|
||||
/// Bytes rather than a `String`, because a file's contents are not text
|
||||
/// until something has checked, and lossy decoding would replace the
|
||||
/// evidence that they are not.
|
||||
///
|
||||
/// `Err` means the process could not be started at all; a process that
|
||||
/// ran and failed is a [`Captured`] with a status saying so.
|
||||
/// `Err` means the process could not be started at all; a process that ran
|
||||
/// and failed is a [`Captured`] with a status saying so.
|
||||
pub async fn capture_with_input(&self, launch: &Launch, input: Input) -> Result<Captured> {
|
||||
let (streams, to_write) = match input {
|
||||
Input::None => (Streams::Piped, None),
|
||||
@@ -212,13 +194,11 @@ impl Transport {
|
||||
};
|
||||
let mut child = self.spawn(launch, streams)?;
|
||||
if let Some(bytes) = to_write {
|
||||
// Written from a task rather than before the wait, because the
|
||||
// child may not read all of it -- the write script exits
|
||||
// without reading when the file has changed underneath -- and
|
||||
// a caller blocked on filling a pipe nobody is draining would
|
||||
// deadlock instead of getting that answer. The broken pipe is
|
||||
// the expected end of this write, so it is dropped: what
|
||||
// happened is the exit status below.
|
||||
// Written from a task rather than before the wait, because the child
|
||||
// may not read all of it -- the write script exits without reading
|
||||
// when the file has changed underneath -- and a caller blocked on
|
||||
// filling a pipe nobody is draining would deadlock instead of getting
|
||||
// that answer. The broken pipe is the expected end of this write.
|
||||
let mut stdin = child.stdin.take().context("the child has no stdin")?;
|
||||
tokio::spawn(async move {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
@@ -248,11 +228,11 @@ impl Transport {
|
||||
|
||||
/// What a command is given on its standard input.
|
||||
///
|
||||
/// Three cases rather than an `Option<Stdio>` because they are three
|
||||
/// genuinely different arrangements and only this knows which: nothing to
|
||||
/// say, bytes this process is holding, or a file it has open. The last one
|
||||
/// is how a several-hundred-megabyte attachment reaches another machine
|
||||
/// without passing through this server's memory.
|
||||
/// Three cases rather than an `Option<Stdio>` because they are three genuinely
|
||||
/// different arrangements and only this knows which: nothing to say, bytes this
|
||||
/// process is holding, or a file it has open. The last is how a
|
||||
/// several-hundred-megabyte attachment reaches another machine without passing
|
||||
/// through this server's memory.
|
||||
pub enum Input {
|
||||
None,
|
||||
Bytes(Vec<u8>),
|
||||
@@ -263,10 +243,9 @@ pub enum Input {
|
||||
pub struct Captured {
|
||||
pub status: std::process::ExitStatus,
|
||||
pub stdout: Vec<u8>,
|
||||
/// Trimmed, and what a failure is reported as: ssh's own refusals and
|
||||
/// a tool's own message about the file it could not open are both the
|
||||
/// useful half of why something did not work, and both are written to
|
||||
/// name the thing.
|
||||
/// Trimmed, and what a failure is reported as: ssh's own refusals and a
|
||||
/// tool's own message about the file it could not open are both the useful
|
||||
/// half of why something did not work.
|
||||
pub stderr: String,
|
||||
}
|
||||
|
||||
|
||||
+53
-70
@@ -1,50 +1,43 @@
|
||||
//! Finding out what a machine can run, rather than being told.
|
||||
//!
|
||||
//! The phone adds a machine by giving connection details; this asks the
|
||||
//! machine itself which of the known programs it has, and the answer
|
||||
//! becomes its providers. That is a security property, not a convenience:
|
||||
//! **no route accepts a command from the phone.** If it did, the enrolled
|
||||
//! token would be able to introduce arbitrary programs to run on every
|
||||
//! machine a setup names, and the transport already reaches those over
|
||||
//! ssh. Here the phone's authority is "add this machine", never "run
|
||||
//! this".
|
||||
//! The phone adds a machine by giving connection details; this asks the machine
|
||||
//! itself which of the known programs it has, and the answer becomes its
|
||||
//! providers. That is a security property, not a convenience: **no route accepts
|
||||
//! a command from the phone.** If it did, the enrolled token could introduce
|
||||
//! arbitrary programs to run on every machine a setup names.
|
||||
//!
|
||||
//! It is also the better interface. Nobody wants to type an absolute path
|
||||
//! on a phone keyboard, and a machine that has moved its binaries answers
|
||||
//! correctly on the next probe without anyone editing anything.
|
||||
//! It is also the better interface: nobody wants to type an absolute path on a
|
||||
//! phone keyboard, and a machine that has moved its binaries answers correctly
|
||||
//! on the next probe.
|
||||
//!
|
||||
//! The cost is that a program somewhere unusual is invisible. That is a
|
||||
//! deliberate trade rather than an oversight: the escape hatch is editing
|
||||
//! `config.ron` on the backend, which is exactly the authority the phone
|
||||
//! is not being given.
|
||||
//! The cost is that a program somewhere unusual is invisible. The escape hatch
|
||||
//! is editing `config.ron` on the backend, which is exactly the authority the
|
||||
//! phone is not being given.
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::config::{DriverKind, ProviderConfig};
|
||||
use crate::session::transport::{Launch, Transport};
|
||||
|
||||
/// 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 what the provider gets called, so it is
|
||||
/// what the phone shows and what a session stores.
|
||||
/// 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
|
||||
/// what the provider gets called, so it is what the phone shows and what a
|
||||
/// session stores.
|
||||
const PROBES: &[(&str, &str, DriverKind)] = &[
|
||||
("claude-cli", "claude", DriverKind::ClaudeCli),
|
||||
("local-llama", "llama-server", DriverKind::LlamaCpp),
|
||||
];
|
||||
|
||||
/// Models offered for a discovered Claude CLI. A shortcut list for the
|
||||
/// spawn screen, not a restriction -- the field stays free text.
|
||||
/// Models offered for a discovered Claude CLI. A shortcut list for the spawn
|
||||
/// screen, not a restriction -- the field stays free text.
|
||||
const CLAUDE_MODELS: &[&str] = &["fable", "opus", "sonnet", "haiku"];
|
||||
|
||||
/// Asks `transport`'s machine which of [`PROBES`] it has.
|
||||
///
|
||||
/// One round trip rather than one per program: over ssh each would be a
|
||||
/// separate connection and handshake, and a person waiting on "test this
|
||||
/// setup" notices. `command -v` is POSIX and a shell builtin, so it works
|
||||
/// whatever is installed -- and `|| true` keeps a missing program from
|
||||
/// ending the loop, since the caller wants the whole answer rather than
|
||||
/// the first failure.
|
||||
/// One round trip rather than one per program: over ssh each would be a separate
|
||||
/// connection and handshake. `command -v` is POSIX and a shell builtin, so it
|
||||
/// works whatever is installed -- and `|| true` keeps a missing program from
|
||||
/// ending the loop, since the caller wants the whole answer.
|
||||
pub async fn discover(transport: &Transport) -> Result<Vec<ProviderConfig>> {
|
||||
let wanted: Vec<&str> = PROBES.iter().map(|(_, binary, _)| *binary).collect();
|
||||
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 mut providers = Vec::new();
|
||||
// Echo runs inside this server, so it exists exactly where this server
|
||||
// does and nowhere else. Nothing to probe for, and offering it on a
|
||||
// remote machine would be a choice that changes nothing.
|
||||
// Echo runs inside this server, so it exists exactly where this server does
|
||||
// and nowhere else. Offering it on a remote machine would be a choice that
|
||||
// changes nothing.
|
||||
if matches!(transport, Transport::Here) {
|
||||
providers.push(ProviderConfig {
|
||||
name: crate::config::ECHO_PROVIDER.to_string(),
|
||||
@@ -78,8 +71,8 @@ pub async fn discover(transport: &Transport) -> Result<Vec<ProviderConfig>> {
|
||||
name: (*name).to_string(),
|
||||
kind: *kind,
|
||||
// The resolved path rather than the bare name: PATH under a
|
||||
// non-interactive ssh session is not the one a person sees
|
||||
// when they log in, so "it is on my PATH" is not enough.
|
||||
// non-interactive ssh session is not the one a person sees when they
|
||||
// log in, so "it is on my PATH" is not enough.
|
||||
command: Some(path.to_string()),
|
||||
models: match kind {
|
||||
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.
|
||||
///
|
||||
/// ssh's messages are written for someone at a terminal on the backend,
|
||||
/// which is exactly who is not reading this one. Host key verification is
|
||||
/// the case that matters: **every** machine fails it the first time,
|
||||
/// because its key is not in `known_hosts` yet -- so without this, adding
|
||||
/// a machine from the phone looks broken rather than unfinished.
|
||||
/// ssh's messages are written for someone at a terminal on the backend, which is
|
||||
/// exactly who is not reading this one. Host key verification is the case that
|
||||
/// matters: **every** machine fails it the first time, so without this, adding a
|
||||
/// machine from the phone looks broken rather than unfinished.
|
||||
///
|
||||
/// Deliberately not fixed by relaxing the check. `StrictHostKeyChecking`
|
||||
/// stays at its default, so a first connection is a decision somebody
|
||||
/// makes on the backend with the key in front of them, rather than
|
||||
/// something this app quietly accepts on their behalf.
|
||||
/// Deliberately not fixed by relaxing the check. `StrictHostKeyChecking` stays
|
||||
/// at its default, so a first connection is a decision somebody makes on the
|
||||
/// backend with the key in front of them.
|
||||
fn explain(err: anyhow::Error) -> anyhow::Error {
|
||||
let message = format!("{err:#}");
|
||||
if message.contains("Host key verification failed") {
|
||||
@@ -120,11 +111,9 @@ fn explain(err: anyhow::Error) -> anyhow::Error {
|
||||
err
|
||||
}
|
||||
|
||||
/// 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 resolved by the caller, which is the only
|
||||
/// place that knows what already exists.
|
||||
/// 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
|
||||
/// resolved by the caller, which is the only place that knows what exists.
|
||||
pub fn id_from(label: &str) -> String {
|
||||
let slug: String = label
|
||||
.chars()
|
||||
@@ -160,25 +149,20 @@ pub fn tidy(value: &str) -> Option<String> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The inverse of [`tidy`]'s expansion: an absolute path under this
|
||||
/// machine's home, written back as `~/…`.
|
||||
/// The inverse of [`tidy`]'s expansion: an absolute path under this machine's
|
||||
/// 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
|
||||
/// hand. `/home/bob/repos/ai-app-2` is most of a line on that screen and
|
||||
/// almost all of it is the part nobody is reading.
|
||||
///
|
||||
/// 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`).
|
||||
/// 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 and the remote shell is what expands it.
|
||||
pub fn shorten_home(path: &str) -> String {
|
||||
let Some(home) = std::env::home_dir() else {
|
||||
return path.to_string();
|
||||
};
|
||||
let home = home.to_string_lossy();
|
||||
// The separator has to be part of the match, or `/home/bobby` would be
|
||||
// read as a path inside `/home/bob`.
|
||||
// The separator has to be part of the match, or `/home/bobby` would be read
|
||||
// as a path inside `/home/bob`.
|
||||
match path.strip_prefix(home.as_ref()) {
|
||||
Some("") => "~".to_string(),
|
||||
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.
|
||||
///
|
||||
/// The common case of [`Transport::capture_with_input`]: nothing on stdin,
|
||||
/// a failure reported as the machine's own words (ssh's "Permission
|
||||
/// denied" or "Could not resolve hostname" is the useful half of why a
|
||||
/// setup cannot be reached), and the output read as text because every
|
||||
/// caller here is asking a question whose answer is words.
|
||||
/// The common case of [`Transport::capture_with_input`]: nothing on stdin, a
|
||||
/// failure reported as the machine's own words (ssh's "Permission denied" is the
|
||||
/// useful half of why a setup cannot be reached), and the output read as text
|
||||
/// because every caller here is asking a question whose answer is words.
|
||||
impl Transport {
|
||||
pub async fn capture(&self, launch: &Launch) -> Result<String> {
|
||||
let captured = self
|
||||
@@ -206,9 +189,9 @@ impl Transport {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The two halves of a home-relative path, which have to be inverses:
|
||||
/// what is stored is what the phone draws, and what the phone sends
|
||||
/// back is what a process is started in.
|
||||
/// The two halves of a home-relative path, which have to be inverses: what
|
||||
/// is stored is what the phone draws, and what the phone sends back is what
|
||||
/// a process is started in.
|
||||
#[test]
|
||||
fn a_home_path_shortens_and_expands_back() {
|
||||
let Some(home) = std::env::home_dir() else {
|
||||
@@ -220,8 +203,8 @@ mod tests {
|
||||
assert_eq!(shorten_home(&home.to_string_lossy()), "~");
|
||||
assert_eq!(tidy("~/repos/ai-app-2").as_deref(), Some(full.as_ref()));
|
||||
|
||||
// Not a prefix match on the characters: a sibling directory whose
|
||||
// name merely starts with the home directory's is not inside it.
|
||||
// Not a prefix match on the characters: a sibling directory whose name
|
||||
// merely starts with the home directory's is not inside it.
|
||||
let sibling = format!("{}-backup/notes", home.to_string_lossy());
|
||||
assert_eq!(shorten_home(&sibling), sibling);
|
||||
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
|
||||
//! `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 child process's stdio and doesn't care what that child is. A remote
|
||||
//! session is therefore the identical command with `ssh host …` in front:
|
||||
//! stdio doesn't care, so nothing downstream of here changes.
|
||||
//! A driver speaks JSONL over a child process's stdio and doesn't care what
|
||||
//! that child is, so a remote session is the identical command with `ssh host …`
|
||||
//! in front.
|
||||
//!
|
||||
//! Uses the system `ssh` client rather than a Rust SSH library, so
|
||||
//! `~/.ssh/config`, agents, and jump hosts all keep working and there is
|
||||
//! only one place to configure connections (PLAN.md, rule 23).
|
||||
//! `~/.ssh/config`, agents and jump hosts all keep working and there is only
|
||||
//! one place to configure connections.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use crate::config::SshConfig;
|
||||
|
||||
/// Options forced onto every connection. `BatchMode` makes a missing key
|
||||
/// fail immediately with a readable message instead of hanging on a
|
||||
/// password prompt that nothing can answer; the keepalives turn a silently
|
||||
/// dropped link into a process exit, which the session reports as `exited`
|
||||
/// rather than appearing to hang forever.
|
||||
/// Options forced onto every connection. `BatchMode` makes a missing key fail
|
||||
/// immediately with a readable message instead of hanging on a password prompt
|
||||
/// nothing can answer; the keepalives turn a silently dropped link into a
|
||||
/// process exit, which the session reports as `exited` rather than hanging.
|
||||
const SSH_OPTIONS: [&str; 3] = [
|
||||
"BatchMode=yes",
|
||||
"ServerAliveInterval=30",
|
||||
"ServerAliveCountMax=3",
|
||||
];
|
||||
|
||||
/// Builds the child process for `program args…`, run in `cwd`, either on
|
||||
/// this machine (`ssh` absent) or on the machine it describes.
|
||||
/// Builds the child process for `program args…`, run in `cwd`, either on this
|
||||
/// machine (`ssh` absent) or on the machine it describes.
|
||||
///
|
||||
/// Stdio is left alone: how the streams are connected is the caller's
|
||||
/// decision and differs by more than the transport does -- a probe wants
|
||||
/// pipes it will drain, a session wants files that outlive this server --
|
||||
/// so `Transport::spawn` applies it rather than this.
|
||||
/// Stdio is left alone: how the streams are connected is the caller's decision
|
||||
/// and differs by more than the transport does -- a probe wants pipes it will
|
||||
/// drain, a session wants files that outlive this server.
|
||||
///
|
||||
/// A plain [`std::process::Command`], which `tokio` converts from, because
|
||||
/// not every caller is async: the usage fetch is blocking by nature (it
|
||||
/// makes a blocking HTTP call) and reads a file from the same machine on
|
||||
/// 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.
|
||||
/// A plain [`std::process::Command`], which `tokio` converts from, because not
|
||||
/// every caller is async: the usage fetch is blocking by nature and should not
|
||||
/// have to build an ssh invocation of its own.
|
||||
pub fn command(
|
||||
remote: Option<&SshConfig>,
|
||||
program: &str,
|
||||
@@ -50,22 +44,19 @@ pub fn command(
|
||||
let mut command = Command::new(program);
|
||||
command.args(args);
|
||||
if let Some(cwd) = cwd {
|
||||
// Expanded here for the same reason `quote_path` expands it on
|
||||
// the far side: a working directory typed as `~/repos/ai-app`
|
||||
// has to mean the same thing whichever machine runs it. There
|
||||
// is no shell in this branch, so nothing else would --
|
||||
// `current_dir` would be handed the literal one-character
|
||||
// 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.
|
||||
// Expanded here for the same reason `quote_path` expands it on the
|
||||
// far side: a working directory typed as `~/repos/ai-app` has to
|
||||
// mean the same thing whichever machine runs it. There is no shell
|
||||
// in this branch, so nothing else would -- `current_dir` would be
|
||||
// handed the literal one-character directory `~`.
|
||||
command.current_dir(expand_home(cwd));
|
||||
}
|
||||
return command;
|
||||
};
|
||||
|
||||
let mut command = Command::new("ssh");
|
||||
// -T: no pty. This carries JSONL, and a pty would rewrite it (echo,
|
||||
// CRLF translation, ^C handling) into something the parser can't read.
|
||||
// -T: no pty. This carries JSONL, and a pty would rewrite it (echo, CRLF
|
||||
// translation, ^C handling) into something the parser can't read.
|
||||
command.arg("-T");
|
||||
for option in SSH_OPTIONS {
|
||||
command.args(["-o", option]);
|
||||
@@ -78,9 +69,8 @@ pub fn command(
|
||||
}
|
||||
if let Some(identity) = &ssh.identity_file {
|
||||
command.arg("-i").arg(identity);
|
||||
// Without this, ssh may offer an agent key first and authenticate
|
||||
// as somebody else entirely -- silently, and with different
|
||||
// permissions than intended.
|
||||
// Without this, ssh may offer an agent key first and authenticate as
|
||||
// somebody else entirely -- silently, and with different permissions.
|
||||
command.args(["-o", "IdentitiesOnly=yes"]);
|
||||
}
|
||||
command.arg(&ssh.address);
|
||||
@@ -88,11 +78,10 @@ pub fn command(
|
||||
command
|
||||
}
|
||||
|
||||
/// 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 CLI itself, and dropping the connection takes
|
||||
/// it down rather than leaving an orphan behind a live wrapper.
|
||||
/// 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
|
||||
/// CLI itself, and dropping the connection takes it down rather than leaving an
|
||||
/// orphan behind a live wrapper.
|
||||
fn remote_script(program: &str, args: &[String], cwd: Option<&Path>) -> String {
|
||||
let mut script = String::new();
|
||||
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.
|
||||
///
|
||||
/// 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`
|
||||
/// 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 at all leaves the path alone, which fails with the operating
|
||||
/// system's own message rather than with a guess.
|
||||
/// deliberately the same shape: the tilde is expanded, `~user` is not, and
|
||||
/// nothing else in the path gains a meaning. A machine with no home directory
|
||||
/// leaves the path alone, which fails with the operating system's own message.
|
||||
pub(crate) fn expand_home(path: &Path) -> PathBuf {
|
||||
let Some(rest) = path.to_str().and_then(|p| {
|
||||
if p == "~" {
|
||||
@@ -136,23 +123,19 @@ pub(crate) fn expand_home(path: &Path) -> PathBuf {
|
||||
/// Quotes a path, expanding a leading `~` and nothing else.
|
||||
///
|
||||
/// [`quote`] is right for every other word crossing to the remote side and
|
||||
/// wrong for exactly one character. `~` means "expand me", and single
|
||||
/// quotes are what stop expansion -- so a working directory typed as
|
||||
/// `~/repos/ai-app` arrived as the literal four-character directory `~`,
|
||||
/// and the remote shell said it did not exist. Which is true, and reads
|
||||
/// like the path being wrong rather than the quoting.
|
||||
/// wrong for exactly one character. `~` means "expand me", and single quotes
|
||||
/// are what stop expansion -- so a working directory typed as `~/repos/ai-app`
|
||||
/// arrived as the literal four-character directory `~`, and the remote shell
|
||||
/// said it did not exist, which reads like the path being wrong.
|
||||
///
|
||||
/// `"$HOME"` rather than handing the tilde to the shell unquoted: the
|
||||
/// variable is expanded, the expansion is not re-split or globbed because
|
||||
/// it is double-quoted, and everything after it stays single-quoted and
|
||||
/// literal. So the one character that has to mean something keeps meaning
|
||||
/// it, and nothing else gains a meaning. `$HOME` is set by every shell
|
||||
/// 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.
|
||||
/// `"$HOME"` rather than handing the tilde to the shell unquoted: the variable
|
||||
/// is expanded, the expansion is not re-split or globbed because it is
|
||||
/// double-quoted, and everything after it stays single-quoted and literal.
|
||||
/// `$HOME` is set by every shell this can land in, including the fish login
|
||||
/// shell on the dev VM, so this does not depend on the remote shell being POSIX.
|
||||
///
|
||||
/// `~user` is deliberately not handled: there is no portable expansion for
|
||||
/// it, and inventing one would mean guessing another account's home
|
||||
/// directory. It stays literal and fails with the shell's own message.
|
||||
/// `~user` is deliberately not handled: there is no portable expansion for it,
|
||||
/// and inventing one would mean guessing another account's home directory.
|
||||
pub(crate) fn quote_path(path: &str) -> String {
|
||||
if path == "~" {
|
||||
return "\"$HOME\"".to_string();
|
||||
@@ -163,15 +146,13 @@ pub(crate) fn quote_path(path: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 attacker-adjacent input in a
|
||||
/// server whose whole job is running commands, and unquoted they would be
|
||||
/// shell syntax rather than data.
|
||||
/// 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
|
||||
/// attacker-adjacent input in a server whose whole job is running commands, and
|
||||
/// unquoted they would be shell syntax rather than data.
|
||||
pub(crate) fn quote(word: &str) -> String {
|
||||
// Inside single quotes every character is literal except `'` itself,
|
||||
// which is closed, escaped, and reopened.
|
||||
// Inside single quotes every character is literal except `'` itself, which
|
||||
// is closed, escaped, and reopened.
|
||||
format!("'{}'", word.replace('\'', r"'\''"))
|
||||
}
|
||||
|
||||
@@ -192,8 +173,8 @@ mod tests {
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// its own when it was not told to.
|
||||
/// decides everything else -- the case that proves this adds no flags of its
|
||||
/// own when it was not told to.
|
||||
fn bare_host() -> SshConfig {
|
||||
SshConfig {
|
||||
address: "vm".to_string(),
|
||||
@@ -256,19 +237,17 @@ mod tests {
|
||||
assert!(!rendered.contains(&"IdentitiesOnly=yes".to_string()));
|
||||
}
|
||||
|
||||
/// The one character quoting must not swallow.
|
||||
///
|
||||
/// A working directory typed as `~/repos/ai-app` was arriving as the
|
||||
/// literal directory `~`, and the remote shell reported it missing --
|
||||
/// which reads as the path being wrong rather than the quoting being
|
||||
/// wrong, and cost an evening on exactly that misreading.
|
||||
/// The one character quoting must not swallow. A working directory typed as
|
||||
/// `~/repos/ai-app` was arriving as the literal directory `~`, and the
|
||||
/// remote shell reported it missing -- which reads as the path being wrong
|
||||
/// rather than the quoting being wrong, and cost an evening.
|
||||
#[test]
|
||||
fn a_leading_tilde_expands_and_nothing_else_does() {
|
||||
assert_eq!(quote_path("~"), "\"$HOME\"");
|
||||
assert_eq!(quote_path("~/repos/ai-app"), "\"$HOME\"/'repos/ai-app'");
|
||||
// Only leading, and only its own segment: a tilde anywhere else is
|
||||
// an ordinary character in a filename, and `~user` has no portable
|
||||
// expansion so it stays literal and fails with the shell's message.
|
||||
// Only leading, and only its own segment: a tilde anywhere else is an
|
||||
// ordinary character in a filename, and `~user` has no portable
|
||||
// expansion so it stays literal.
|
||||
assert_eq!(quote_path("/tmp/~/x"), "'/tmp/~/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 local branch runs the program directly, so a working directory
|
||||
/// of `~/repos/ai-app` would reach `current_dir` as the literal
|
||||
/// one-character directory `~` -- a session that fails to start,
|
||||
/// naming a path nobody typed. The two transports have to agree about
|
||||
/// what a tilde means or a path is only portable by accident.
|
||||
/// The same character, on the transport with no shell to expand it. The
|
||||
/// local branch runs the program directly, so a working directory of
|
||||
/// `~/repos/ai-app` would reach `current_dir` as the literal one-character
|
||||
/// directory `~`. The two transports have to agree about what a tilde means
|
||||
/// or a path is only portable by accident.
|
||||
#[test]
|
||||
fn a_local_cwd_expands_its_tilde_the_same_way() {
|
||||
let Some(home) = std::env::home_dir() else {
|
||||
@@ -306,9 +283,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn shell_metacharacters_cross_as_data_not_syntax() {
|
||||
// Expanding $HOME must not open a door for anything else: the rest
|
||||
// stays single-quoted, so this remains one absurd path rather than
|
||||
// three commands.
|
||||
// Expanding $HOME must not open a door for anything else: the rest stays
|
||||
// single-quoted, so this remains one absurd path rather than three
|
||||
// commands.
|
||||
assert_eq!(
|
||||
quote_path("~/'; touch /tmp/pwned; '"),
|
||||
r#""$HOME"/''\''; touch /tmp/pwned; '\'''"#,
|
||||
@@ -320,8 +297,8 @@ mod tests {
|
||||
assert_eq!(quote("$(whoami)"), "'$(whoami)'");
|
||||
assert_eq!(quote("it's"), r"'it'\''s'");
|
||||
|
||||
// The end-to-end version of the same worry: a working directory
|
||||
// that tries to close the quote and start a new command.
|
||||
// The end-to-end version of the same worry: a working directory that
|
||||
// tries to close the quote and start a new command.
|
||||
let ssh = bare_host();
|
||||
let evil = Path::new("/tmp/'; touch /tmp/pwned; '");
|
||||
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
|
||||
//! token from Claude Code's local credential store. The endpoint is
|
||||
//! undocumented and has changed before, so everything here is best-effort:
|
||||
//! every field is optional, and failure degrades to an "unavailable"
|
||||
//! snapshot with the reason, never an error that breaks the screen.
|
||||
//! every field is optional, and failure degrades to an "unavailable" snapshot
|
||||
//! with the reason, never an error that breaks the screen.
|
||||
//!
|
||||
//! Two rules learned from others hitting this endpoint (see PLAN.md's
|
||||
//! references): send `User-Agent: claude-code/<version>` (without it,
|
||||
//! requests land in an aggressively rate-limited bucket) and poll no more
|
||||
//! often than every 180 s. The cache below enforces the latter across any
|
||||
//! number of phone refreshes; there is no background poll at all -- the
|
||||
//! screen's fetch is the trigger, so no session activity means no traffic.
|
||||
//! Two rules learned from others hitting this endpoint: send `User-Agent:
|
||||
//! claude-code/<version>` (without it, requests land in an aggressively
|
||||
//! rate-limited bucket) and poll no more often than every 180 s. The cache
|
||||
//! below enforces the latter across any number of phone refreshes; there is no
|
||||
//! background poll at all.
|
||||
//!
|
||||
//! One [`UsageProvider`] per paid service, so a second service later is a
|
||||
//! new impl behind the same snapshot shape, not a parallel screen.
|
||||
//! One [`UsageProvider`] per paid service, so a second service later is a new
|
||||
//! impl behind the same snapshot shape, not a parallel screen.
|
||||
//!
|
||||
//! **Asked of the machine that spends the tokens, not of this one.** A
|
||||
//! session runs wherever its setup says, so the account being billed is
|
||||
//! that machine's, and reading this machine's credentials reports on an
|
||||
//! account that may have run nothing. In the layout this project is aiming
|
||||
//! at that is not a rounding error: `ai-server` belongs on the host, the
|
||||
//! host has no `claude` CLI, and the CLI machine is a remote -- so the one
|
||||
//! set of numbers the screen could show would be the numbers of an account
|
||||
//! with no sessions. Credentials are therefore read through the session
|
||||
//! `Transport`, one snapshot per setup that offers Claude.
|
||||
//! **Asked of the machine that spends the tokens, not of this one.** A session
|
||||
//! runs wherever its setup says, so the account being billed is that machine's.
|
||||
//! In the layout this project aims at, `ai-server` is on the host, the host has
|
||||
//! no `claude` CLI, and the CLI machine is a remote -- so the one set of numbers
|
||||
//! the screen could show would be an account with no sessions. Credentials are
|
||||
//! 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,
|
||||
//! rather than running the request on the far machine: it needs no tooling
|
||||
//! there beyond a shell, and it keeps the one place that knows the wire
|
||||
//! format in one place. The cost is that a remote machine's token is in
|
||||
//! this process's memory for the length of a fetch, which is the same
|
||||
//! trust the backend already has over that machine (it can start processes
|
||||
//! on it).
|
||||
//! The token is read *to* the backend and the HTTP call is made from here, so
|
||||
//! the far machine needs nothing beyond a shell and the wire format stays in
|
||||
//! one place. The cost is that a remote machine's token is in this process's
|
||||
//! memory for the length of a fetch, which is the same trust the backend
|
||||
//! already has over that machine.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
@@ -54,15 +49,13 @@ const USER_AGENT: &str = "claude-code/2.1.237";
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UsageWindow {
|
||||
/// The API's own word for which window this is -- `session` for the
|
||||
/// five-hour one, `weekly_all`, `weekly_scoped`, or whatever new kind
|
||||
/// it starts sending.
|
||||
/// five-hour one, `weekly_all`, `weekly_scoped`, or whatever new kind it
|
||||
/// starts sending.
|
||||
///
|
||||
/// Carried beside the label because a caller that wants one
|
||||
/// particular window has to be able to ask for it without matching on
|
||||
/// display text: the label is written for a person, is translated the
|
||||
/// moment anybody translates this app, and would silently select
|
||||
/// nothing the day it changes. The session screen's bar picks
|
||||
/// `session` by this field.
|
||||
/// Carried beside the label because a caller that wants one particular
|
||||
/// window has to ask for it without matching on display text: the label is
|
||||
/// written for a person and would silently select nothing the day it
|
||||
/// changes.
|
||||
pub kind: String,
|
||||
pub label: String,
|
||||
/// 0-100.
|
||||
@@ -77,13 +70,11 @@ pub struct UsageWindow {
|
||||
/// 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
|
||||
/// treat them differently and a reader has to. "Nobody is logged in here"
|
||||
/// is a machine working exactly as configured -- somebody chose not to put
|
||||
/// an account on it -- while "I could not reach it" is a fault worth
|
||||
/// chasing, and "the endpoint refused me" is a third thing that says
|
||||
/// nothing about the machine at all. Collapsing them into one `error`
|
||||
/// string made the first look like the last, so a perfectly healthy setup
|
||||
/// read as broken.
|
||||
/// treat them differently. "Nobody is logged in here" is a machine working
|
||||
/// exactly as configured, while "I could not reach it" is a fault worth
|
||||
/// chasing, and "the endpoint refused me" says nothing about the machine at
|
||||
/// all. Collapsing them into one `error` string made the first look like the
|
||||
/// last, so a perfectly healthy setup read as broken.
|
||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||
#[serde(tag = "state", rename_all = "camelCase")]
|
||||
pub enum UsageState {
|
||||
@@ -102,11 +93,11 @@ pub enum UsageState {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UsageSnapshot {
|
||||
pub provider: String,
|
||||
/// Which machine these are the numbers for. The point of the whole
|
||||
/// module: they belong to an account on a particular box.
|
||||
/// Which machine these are the numbers for. The point of the whole module:
|
||||
/// they belong to an account on a particular box.
|
||||
pub setup: String,
|
||||
/// That machine's current label, resolved when the snapshot is built,
|
||||
/// so renaming a setup renames it here too.
|
||||
/// That machine's current label, resolved when the snapshot is built, so
|
||||
/// renaming a setup renames it here too.
|
||||
pub setup_name: String,
|
||||
#[serde(flatten)]
|
||||
pub state: UsageState,
|
||||
@@ -122,9 +113,9 @@ pub trait UsageProvider: Send + Sync {
|
||||
fn fetch(&self) -> UsageSnapshot;
|
||||
}
|
||||
|
||||
/// Reads the numbers behind Claude Code's `/usage` from one machine, using
|
||||
/// the credentials that machine stores -- nothing to configure, and it
|
||||
/// reports on exactly the account whose CLI runs the sessions there.
|
||||
/// Reads the numbers behind Claude Code's `/usage` from one machine, using the
|
||||
/// credentials that machine stores -- nothing to configure, and it reports on
|
||||
/// exactly the account whose CLI runs the sessions there.
|
||||
pub struct ClaudeUsage {
|
||||
pub setup: String,
|
||||
pub setup_name: String,
|
||||
@@ -132,9 +123,9 @@ pub struct ClaudeUsage {
|
||||
pub transport: Transport,
|
||||
}
|
||||
|
||||
/// Where Claude Code keeps its credentials, as a shell word rather than a
|
||||
/// path: `$HOME` is expanded by the shell on the machine being asked,
|
||||
/// which is the only place that knows what it is.
|
||||
/// Where Claude Code keeps its credentials, as a shell word rather than a path:
|
||||
/// `$HOME` is expanded by the shell on the machine being asked, which is the
|
||||
/// only place that knows what it is.
|
||||
const CREDENTIALS: &str = "$HOME/.claude/.credentials.json";
|
||||
|
||||
impl ClaudeUsage {
|
||||
@@ -149,12 +140,9 @@ impl ClaudeUsage {
|
||||
}
|
||||
}
|
||||
|
||||
/// The machine's stored OAuth token, or which of the two ways of not
|
||||
/// having 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, which over ssh
|
||||
/// is somebody else's.
|
||||
/// The machine's stored OAuth token, or which of the two ways of not having
|
||||
/// 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.
|
||||
fn access_token(&self) -> Result<String, UsageState> {
|
||||
let launch = Launch::new(
|
||||
"sh",
|
||||
@@ -174,8 +162,8 @@ impl ClaudeUsage {
|
||||
.as_str()
|
||||
.map(String::from)
|
||||
})
|
||||
// A file that exists but carries no token is the same situation
|
||||
// as no file: nobody has logged in here yet.
|
||||
// A file that exists but carries no token is the same situation as
|
||||
// no file: nobody has logged in here yet.
|
||||
.ok_or(UsageState::NotLoggedIn)
|
||||
}
|
||||
}
|
||||
@@ -225,19 +213,17 @@ impl UsageProvider for ClaudeUsage {
|
||||
|
||||
/// Which kind of "no credentials" a failed read was.
|
||||
///
|
||||
/// The distinction is the point of having both states. `cat` failing
|
||||
/// because the file is not there is a machine nobody has logged in on --
|
||||
/// a decision somebody made, with nothing to fix. Anything else is a
|
||||
/// machine this server could not ask, which is a fault and reads as one.
|
||||
/// The distinction is the point of having both states. `cat` failing because
|
||||
/// the file is not there is a machine nobody has logged in on -- a decision
|
||||
/// somebody made, with nothing to fix. Anything else is a machine this server
|
||||
/// could not ask, which is a fault and reads as one.
|
||||
///
|
||||
/// Matched on the shell's own words rather than an exit status because
|
||||
/// there is only one: `cat` exits 1 for a missing file and ssh exits 255
|
||||
/// for a connection it could not make, but the message is what survives
|
||||
/// being wrapped in `sh -c` and passed back through ssh.
|
||||
/// Matched on the shell's own words rather than an exit status because there is
|
||||
/// only one that survives being wrapped in `sh -c` and passed back through ssh.
|
||||
fn why_no_credentials(detail: &str) -> UsageState {
|
||||
// "No such file or directory" is GNU and BSD coreutils; busybox says
|
||||
// "can't open". Anything unrecognised is treated as unreachable,
|
||||
// which is the answer that gets looked at rather than ignored.
|
||||
// "No such file or directory" is GNU and BSD coreutils; busybox says "can't
|
||||
// open". Anything unrecognised is treated as unreachable, which is the
|
||||
// answer that gets looked at rather than ignored.
|
||||
let missing = ["No such file", "no such file", "can't open", "cannot open"];
|
||||
if missing.iter().any(|phrase| detail.contains(phrase)) {
|
||||
UsageState::NotLoggedIn
|
||||
@@ -248,10 +234,9 @@ fn why_no_credentials(detail: &str) -> UsageState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pulls the `limits` array apart, defensively: entries with no percent
|
||||
/// are skipped, unknown kinds keep their raw name as the label rather
|
||||
/// than being dropped -- a new window appearing should show up, not
|
||||
/// vanish.
|
||||
/// Pulls the `limits` array apart, defensively: entries with no percent are
|
||||
/// skipped, and unknown kinds keep their raw name as the label rather than
|
||||
/// being dropped -- a new window appearing should show up, not vanish.
|
||||
fn parse_windows(body: &Value) -> Vec<UsageWindow> {
|
||||
let Some(limits) = body.get("limits").and_then(Value::as_array) else {
|
||||
return Vec::new();
|
||||
@@ -295,10 +280,10 @@ fn parse_windows(body: &Value) -> Vec<UsageWindow> {
|
||||
|
||||
/// Which paid services a machine can be asked about.
|
||||
///
|
||||
/// Derived from what the setup says it can run, so a machine with no
|
||||
/// Claude provider is not asked about Claude limits -- it has none, and a
|
||||
/// row saying so would be a fact about nothing. A second service later
|
||||
/// adds a branch here and an impl beside [`ClaudeUsage`], not a screen.
|
||||
/// Derived from what the setup says it can run, so a machine with no Claude
|
||||
/// provider is not asked about Claude limits -- it has none, and a row saying
|
||||
/// so would be a fact about nothing. A second service later adds a branch here
|
||||
/// and an impl beside [`ClaudeUsage`], not a screen.
|
||||
fn providers_for(setup: &SetupConfig) -> Vec<Box<dyn UsageProvider>> {
|
||||
let mut found: Vec<Box<dyn UsageProvider>> = Vec::new();
|
||||
if setup
|
||||
@@ -315,19 +300,17 @@ fn providers_for(setup: &SetupConfig) -> Vec<Box<dyn UsageProvider>> {
|
||||
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.
|
||||
///
|
||||
/// Keyed by the machine and the service rather than by position: the set
|
||||
/// is no longer fixed at startup -- setups are added, renamed and removed
|
||||
/// from the phone -- and a positional cache would hand one machine's
|
||||
/// numbers to another the moment the list shifted.
|
||||
/// Keyed by the machine and the service rather than by position: the set is not
|
||||
/// fixed at startup -- setups are added, renamed and removed from the phone --
|
||||
/// and a positional cache would hand one machine's numbers to another the
|
||||
/// moment the list shifted.
|
||||
type Cached = HashMap<(String, &'static str), (Instant, UsageSnapshot)>;
|
||||
|
||||
#[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 {
|
||||
cache: Mutex<Cached>,
|
||||
}
|
||||
@@ -337,12 +320,12 @@ impl UsageMonitor {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// One snapshot per machine that offers a paid service, in the order
|
||||
/// the machines are configured.
|
||||
/// One snapshot per machine that offers a paid service, in the order the
|
||||
/// machines are configured.
|
||||
///
|
||||
/// Blocking -- call via `spawn_blocking`. Takes the setups rather than
|
||||
/// holding the manager, so this module stays below the session layer
|
||||
/// rather than reaching up into it.
|
||||
/// holding the manager, so this module stays below the session layer rather
|
||||
/// than reaching up into it.
|
||||
pub fn snapshots(&self, setups: &[SetupConfig]) -> Vec<UsageSnapshot> {
|
||||
let mut fresh = Vec::new();
|
||||
for setup in setups {
|
||||
@@ -351,19 +334,17 @@ impl UsageMonitor {
|
||||
if let Some((fetched, snapshot)) = self.cache.lock().unwrap().get(&key)
|
||||
&& fetched.elapsed() < MIN_POLL_INTERVAL
|
||||
{
|
||||
// Cached numbers, but the machine's *name* is read
|
||||
// fresh: a rename should show immediately rather than
|
||||
// waiting out the poll interval it has nothing to do
|
||||
// with.
|
||||
// Cached numbers, but the machine's *name* is read fresh: a
|
||||
// rename should show immediately rather than waiting out a
|
||||
// poll interval it has nothing to do with.
|
||||
let mut snapshot = snapshot.clone();
|
||||
snapshot.setup_name = setup.name.clone();
|
||||
fresh.push(snapshot);
|
||||
continue;
|
||||
}
|
||||
// Fetched without the lock held: this makes a network call
|
||||
// per machine, and holding the cache across them would
|
||||
// serialise every phone asking for the screen behind the
|
||||
// slowest ssh connection.
|
||||
// Fetched without the lock held: this makes a network call per
|
||||
// machine, and holding the cache across them would serialise
|
||||
// every phone asking for the screen behind the slowest ssh.
|
||||
let snapshot = provider.fetch();
|
||||
self.cache
|
||||
.lock()
|
||||
@@ -412,8 +393,8 @@ mod tests {
|
||||
assert_eq!(windows[3].resets_at, None);
|
||||
}
|
||||
|
||||
/// A setup naming a machine that cannot be dialled, so nothing here
|
||||
/// touches the network beyond ssh failing to resolve it.
|
||||
/// A setup naming a machine that cannot be dialled, so nothing here touches
|
||||
/// the network beyond ssh failing to resolve it.
|
||||
fn unreachable_setup() -> SetupConfig {
|
||||
SetupConfig {
|
||||
id: "far".to_string(),
|
||||
@@ -442,9 +423,9 @@ mod tests {
|
||||
transport: Transport::for_setup(&unreachable_setup()),
|
||||
};
|
||||
let snapshot = provider.fetch();
|
||||
// The distinction the old single `error` string could not make:
|
||||
// this machine was never reached, which is not the same as a
|
||||
// machine that answered and has nobody logged in.
|
||||
// The distinction the old single `error` string could not make: this
|
||||
// machine was never reached, which is not the same as a machine that
|
||||
// answered and has nobody logged in.
|
||||
assert!(
|
||||
matches!(snapshot.state, UsageState::Unreachable { .. }),
|
||||
"{:?}",
|
||||
@@ -457,8 +438,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
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
|
||||
// machine. Nothing to fix, so it must not read as an error.
|
||||
// What a real shell says when nobody has logged in on that machine.
|
||||
// Nothing to fix, so it must not read as an error.
|
||||
assert_eq!(
|
||||
why_no_credentials("cat: /home/x/.claude/.credentials.json: No such file or directory"),
|
||||
UsageState::NotLoggedIn
|
||||
@@ -468,16 +449,16 @@ mod tests {
|
||||
UsageState::NotLoggedIn
|
||||
);
|
||||
|
||||
// What ssh says when the machine is not there. Worth chasing, and
|
||||
// the detail is carried so somebody can.
|
||||
// What ssh says when the machine is not there. Worth chasing, and the
|
||||
// detail is carried so somebody can.
|
||||
let refused = why_no_credentials("ssh: connect to host vm port 22: Connection refused");
|
||||
assert!(
|
||||
matches!(&refused, UsageState::Unreachable { detail } if detail.contains("refused")),
|
||||
"{refused:?}"
|
||||
);
|
||||
|
||||
// Anything unrecognised errs towards the state that gets looked
|
||||
// at, rather than silently claiming nobody is logged in.
|
||||
// Anything unrecognised errs towards the state that gets looked at,
|
||||
// rather than silently claiming nobody is logged in.
|
||||
assert!(matches!(
|
||||
why_no_credentials("something nobody has seen before"),
|
||||
UsageState::Unreachable { .. }
|
||||
|
||||
Reference in new issue
Block a user