Merge branch 'main' of git.arirex.me:iris/ai-app
# Conflicts: # AGENTS.md # PLAN.md # app/androidApp/src/main/kotlin/com/example/aiapp/SessionUsageBar.kt # app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt # server/src/config.rs # server/src/main.rs # server/src/routes.rs # server/src/session/echo.rs # server/src/session/llama.rs # server/src/session/transport.rs # server/src/ssh.rs # server/src/usage.rs
This commit is contained in:
commit
3c0214ece8
94 files changed
+8299
-9454
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;
|
||||
|
||||
+127
-192
@@ -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 {
|
||||
@@ -120,59 +108,49 @@ pub struct SshConfig {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub models_dir: Option<PathBuf>,
|
||||
/// 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),
|
||||
@@ -180,30 +158,22 @@ impl DriverKind {
|
||||
}
|
||||
}
|
||||
|
||||
/// Which paid service meters a session of this kind, and `None` for
|
||||
/// one that costs nothing.
|
||||
/// Which paid service meters a session of this kind, and `None` for one
|
||||
/// that costs nothing.
|
||||
///
|
||||
/// The rate-limit bars answer a question about an *account*, and what
|
||||
/// decides which account -- if any -- is the provider a session runs,
|
||||
/// not the machine it runs on. Those were the same thing only for as
|
||||
/// long as a machine ran one kind of session: an echo session on a
|
||||
/// laptop that also has the Claude CLI was drawn with that CLI's
|
||||
/// five-hour window under its header, reporting a quota it cannot
|
||||
/// spend and could not run down. A llama.cpp session is the same
|
||||
/// story with the model on the far side.
|
||||
/// What decides which account -- if any -- a rate-limit bar is about is the
|
||||
/// provider a session runs, not the machine it runs on: an echo session on
|
||||
/// a machine that also has the Claude CLI was drawn with that CLI's
|
||||
/// five-hour window, a quota it cannot spend.
|
||||
///
|
||||
/// [`DriverKind::Echo`] names a meter of its own, which exists only
|
||||
/// when a test has asked for one (`/usage` in `session::echo`). That
|
||||
/// is what makes the bar's states -- a number, a machine nobody
|
||||
/// logged into, one that could not be reached -- reachable without an
|
||||
/// account and without spending a turn on somebody else's. With no
|
||||
/// fixture set there is no snapshot for it, which the phone draws as
|
||||
/// nothing at all.
|
||||
/// Echo names a meter of its own that exists only when a test has asked for
|
||||
/// one (`/usage` in `session::echo`), which is how the bar's states are
|
||||
/// reached without an account. With none set there is no snapshot, and the
|
||||
/// phone draws nothing.
|
||||
///
|
||||
/// The string is a [`crate::usage::UsageProvider::name`], and it is
|
||||
/// what pairs a session with one of the snapshots `GET /usage`
|
||||
/// returns; the two lists have to agree, so `usage::providers_for`
|
||||
/// reads this rather than matching on kinds a second time.
|
||||
/// The string is a [`crate::usage::UsageProvider::name`], and it is what
|
||||
/// pairs a session with one of `GET /usage`'s snapshots -- so
|
||||
/// `usage::providers_for` reads this rather than matching on kinds again.
|
||||
pub fn usage_provider(self) -> Option<&'static str> {
|
||||
match self {
|
||||
Self::ClaudeCli => Some(crate::usage::CLAUDE),
|
||||
@@ -212,21 +182,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,
|
||||
@@ -238,11 +204,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,
|
||||
}
|
||||
|
||||
@@ -251,73 +215,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,
|
||||
}
|
||||
|
||||
@@ -326,29 +273,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")
|
||||
}
|
||||
@@ -358,22 +301,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(),
|
||||
@@ -383,12 +323,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(),
|
||||
@@ -402,8 +339,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())
|
||||
@@ -414,12 +351,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 {
|
||||
|
||||
+71
-94
@@ -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,18 +261,15 @@ 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.
|
||||
// The fixture is the manager's, because that is where the `/usage`
|
||||
// command that sets it is typed; the monitor is what serves it.
|
||||
// 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.
|
||||
// The fixture is the manager's, because that is where the `/usage` command
|
||||
// that sets it is typed; the monitor is what serves it.
|
||||
let monitor = Arc::new(usage::UsageMonitor::new(manager.usage_fixture()));
|
||||
|
||||
// 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)))
|
||||
@@ -299,9 +278,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 => {
|
||||
@@ -318,13 +297,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")?;
|
||||
@@ -333,9 +310,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};
|
||||
@@ -42,35 +36,33 @@ use crate::session::transport::{Launch, Transport};
|
||||
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,
|
||||
@@ -82,20 +74,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,
|
||||
@@ -154,9 +143,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,
|
||||
}
|
||||
@@ -173,11 +161,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('/')) {
|
||||
@@ -193,11 +180,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);
|
||||
@@ -213,12 +198,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)?;
|
||||
@@ -253,8 +236,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);
|
||||
@@ -277,8 +260,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 {
|
||||
@@ -305,7 +288,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);
|
||||
@@ -313,9 +295,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());
|
||||
@@ -333,13 +314,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",
|
||||
@@ -351,11 +330,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
|
||||
@@ -380,12 +357,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)
|
||||
@@ -399,9 +374,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();
|
||||
}
|
||||
@@ -427,12 +401,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)?;
|
||||
@@ -448,8 +420,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();
|
||||
@@ -457,9 +429,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 =
|
||||
@@ -473,8 +444,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()
|
||||
@@ -489,9 +460,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))
|
||||
}
|
||||
@@ -508,8 +478,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");
|
||||
@@ -646,18 +616,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",
|
||||
@@ -685,11 +654,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()?;
|
||||
@@ -738,10 +705,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()
|
||||
|
||||
+284
-339
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),
|
||||
|
||||
+190
-255
@@ -1,63 +1,51 @@
|
||||
//! 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.
|
||||
//! - `/usage [what]` -- puts up an invented rate-limit answer, or takes
|
||||
//! it away again (`/usage off`). An echo session meters nothing, so it
|
||||
//! draws no usage bar at all until this is set; what it exists for is
|
||||
//! the states the bar can be in, which otherwise cost real quota to
|
||||
//! reach. `/usage 42`, `/usage 95 20`, `/usage 42 never`,
|
||||
//! `/usage notloggedin`, `/usage unreachable`, `/usage failed`. The
|
||||
//! vocabulary is `usage::Fixture`'s, which is where the states live.
|
||||
//! - `/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.
|
||||
//! - `/usage [what]` -- an invented rate-limit answer, or `/usage off` to take
|
||||
//! it away. An echo session meters nothing, so it draws no usage bar until
|
||||
//! this is set; what it exists for is the states that bar can be in, which
|
||||
//! otherwise cost real quota to reach. `/usage 42`, `/usage 95 20`,
|
||||
//! `/usage 42 never`, `/usage notloggedin`, `/usage unreachable`,
|
||||
//! `/usage failed`. The vocabulary is `usage::Fixture`'s, where the states
|
||||
//! live.
|
||||
//! - `/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};
|
||||
@@ -69,24 +57,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>,
|
||||
@@ -96,42 +78,35 @@ 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>>,
|
||||
/// The invented rate-limit answer `/usage` sets, shared with the
|
||||
/// usage monitor that serves it. An echo session meters nothing, so
|
||||
/// this is unset until a test asks for something -- see
|
||||
/// [`crate::usage::Fixture`].
|
||||
/// The invented rate-limit answer `/usage` sets, shared with the usage
|
||||
/// monitor that serves it. An echo session meters nothing, so this is unset
|
||||
/// until a test asks for something -- see [`crate::usage::Fixture`].
|
||||
usage: crate::usage::Fixture,
|
||||
/// 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());
|
||||
@@ -149,16 +124,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",
|
||||
@@ -248,8 +222,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()),
|
||||
});
|
||||
}
|
||||
@@ -260,23 +233,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()
|
||||
@@ -292,17 +263,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 {
|
||||
@@ -357,11 +322,10 @@ impl EchoDriver {
|
||||
return;
|
||||
}
|
||||
|
||||
// Answered here rather than in the turn below, because it is not
|
||||
// a turn: nothing is generated, and what is being exercised is
|
||||
// the *other* screens -- the bar under the header, the button
|
||||
// beside it and the dialog it opens, all of which read the usage
|
||||
// route rather than this transcript.
|
||||
// Answered here rather than in the turn below, because it is not a
|
||||
// turn: nothing is generated, and what is being exercised is the
|
||||
// *other* screens -- the bar under the header, the button beside it and
|
||||
// the dialog it opens, which read the usage route, not this transcript.
|
||||
if let Some(rest) = text.strip_prefix("/usage") {
|
||||
if announce {
|
||||
self.emit(Event::MessageTaken {
|
||||
@@ -380,9 +344,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 {
|
||||
@@ -438,23 +402,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()
|
||||
@@ -473,21 +434,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));
|
||||
@@ -507,10 +466,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,
|
||||
@@ -523,8 +481,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 {
|
||||
@@ -567,16 +524,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}
|
||||
@@ -600,9 +555,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;
|
||||
@@ -667,7 +622,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(),
|
||||
@@ -675,8 +629,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,
|
||||
@@ -703,36 +656,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,
|
||||
@@ -741,9 +690,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;
|
||||
@@ -767,8 +715,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());
|
||||
@@ -815,30 +763,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",
|
||||
@@ -890,17 +835,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(),
|
||||
@@ -921,12 +865,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 {
|
||||
@@ -939,18 +881,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);
|
||||
}
|
||||
@@ -966,8 +905,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()
|
||||
@@ -982,9 +921,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 {
|
||||
@@ -997,17 +936,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) {
|
||||
@@ -1022,14 +960,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);
|
||||
@@ -1041,10 +976,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.
|
||||
|
||||
+135
-166
@@ -1,43 +1,32 @@
|
||||
//! 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 second half of what a transport
|
||||
//! is -- "run this" plus "reach this port" -- and it is what lets a
|
||||
//! session run on another machine: [`Transport::reserve_port`] hands back
|
||||
//! a port the server binds *there* and a port that reaches it *here*, and
|
||||
//! the ssh connection carrying the command carries the tunnel between
|
||||
//! them. The far `llama-server` binds loopback only, so a model is never
|
||||
//! served to that machine's network.
|
||||
//! through the same [`Transport`] as any other and then reached over HTTP on a
|
||||
//! loopback port. That is the second half of what a transport is -- "run this"
|
||||
//! plus "reach this port" -- and it is what lets a session run on another
|
||||
//! machine: [`Transport::reserve_port`] hands back a port the server binds
|
||||
//! *there* and one that reaches it *here*, and the ssh connection carrying the
|
||||
//! command carries the tunnel between them. The far `llama-server` binds
|
||||
//! loopback only, so a model is never served to that machine's network.
|
||||
//!
|
||||
//! **The model file is the far machine's, not this one's.** A session
|
||||
//! serves a GGUF from the machine that runs `llama-server`, so a remote
|
||||
//! setup names its own models directory (`SshConfig::models_dir`,
|
||||
//! defaulting to the same place this backend keeps its own downloads).
|
||||
//! What this backend has downloaded is on that machine only when they are
|
||||
//! the same machine -- so the file is looked for *there*, and a session
|
||||
//! that names a model the machine does not have says so instead of
|
||||
//! starting a server that will never load one. Downloading to another
|
||||
//! machine is not built; the model gets there however anything else
|
||||
//! gets there.
|
||||
//! **The model file is the far machine's, not this one's.** A remote setup
|
||||
//! names its own models directory (`SshConfig::models_dir`, defaulting to where
|
||||
//! this backend keeps its downloads), and the file is looked for *there* -- so
|
||||
//! a session naming a model that machine does not have says so, instead of
|
||||
//! starting a server that will never load one. Downloading to another machine
|
||||
//! is not built; the model gets there however anything else does.
|
||||
//!
|
||||
//! **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;
|
||||
@@ -52,10 +41,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.
|
||||
@@ -76,20 +64,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,
|
||||
@@ -104,10 +91,10 @@ impl LlamaDriver {
|
||||
)?;
|
||||
let path = model_on(transport, 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,
|
||||
@@ -144,9 +131,9 @@ impl LlamaDriver {
|
||||
"--port".into(),
|
||||
forward.there.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"),
|
||||
@@ -162,8 +149,8 @@ impl LlamaDriver {
|
||||
let launch = Launch::new(program, args, meta.cwd.as_deref()).reaching(forward);
|
||||
// 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 {
|
||||
@@ -183,10 +170,9 @@ impl LlamaDriver {
|
||||
forward.there,
|
||||
forward.here,
|
||||
);
|
||||
// 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;
|
||||
@@ -214,10 +200,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,
|
||||
@@ -226,9 +212,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,
|
||||
});
|
||||
@@ -287,11 +273,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
|
||||
@@ -306,22 +290,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 {
|
||||
@@ -360,34 +343,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:#}"),
|
||||
@@ -400,17 +382,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) {
|
||||
@@ -445,23 +425,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);
|
||||
}
|
||||
@@ -477,16 +455,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();
|
||||
@@ -494,8 +471,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[..],
|
||||
@@ -543,46 +520,40 @@ fn model_path(models_dir: &Path, key: &str) -> Result<PathBuf> {
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// The model file's path **on the machine that will serve it**, confirmed
|
||||
/// to be there.
|
||||
/// The model file's path **on the machine that will serve it**, confirmed to be
|
||||
/// there.
|
||||
///
|
||||
/// Local and remote answer the same question and it has to be asked of
|
||||
/// two different filesystems, which is why this is one function rather
|
||||
/// than a check beside the local path and hope for the other case. The
|
||||
/// remote answer is measured for the same reason the local one is: a
|
||||
/// missing file otherwise becomes a `llama-server` that starts, fails to
|
||||
/// load, and reports as a session that never became ready -- which reads
|
||||
/// as the machine being slow.
|
||||
/// One function rather than a local check and hope for the other case: the same
|
||||
/// question has to be asked of two filesystems. The remote answer is measured
|
||||
/// for the reason the local one is -- a missing file otherwise becomes a
|
||||
/// `llama-server` that starts, fails to load, and reports as a session that
|
||||
/// never became ready, which reads as the machine being slow.
|
||||
///
|
||||
/// One blocking round trip on a remote spawn, which is the same cost the
|
||||
/// spawn is already paying to start ssh. The alternative is a path built
|
||||
/// here from a `~` this machine cannot expand.
|
||||
/// One blocking round trip on a remote spawn, which is what the spawn is
|
||||
/// already paying to start ssh. The alternative is a path built here from a `~`
|
||||
/// this machine cannot expand.
|
||||
fn model_on(transport: &Transport, models_dir: &Path, key: &str) -> Result<String> {
|
||||
let Transport::Ssh { name, .. } = transport else {
|
||||
return Ok(model_path(models_dir, key)?.to_string_lossy().into_owned());
|
||||
};
|
||||
// The same directory the spawn screen listed for this machine, and
|
||||
// for the same reason it is one function: a list from one place and a
|
||||
// load from another is a model that appears and then fails.
|
||||
// The same directory the spawn screen listed for this machine, and one
|
||||
// function for the same reason: a list from one place and a load from
|
||||
// another is a model that appears and then fails.
|
||||
let dir = crate::models::dir_on(transport, models_dir);
|
||||
// Checked here rather than in the script: `..` in a key would walk
|
||||
// out of the models directory on a machine this server can start
|
||||
// processes on, and the phone is where the key comes from.
|
||||
// Checked here rather than in the script: `..` in a key would walk out of
|
||||
// the models directory on a machine this server can start processes on,
|
||||
// and the phone is where the key comes from.
|
||||
for part in key.split('/') {
|
||||
if part.is_empty() || part == "." || part == ".." {
|
||||
bail!("\"{key}\" is not a model key this can resolve");
|
||||
}
|
||||
}
|
||||
let path = format!("{}/{key}", dir.trim_end_matches('/'));
|
||||
// `$HOME` on the far side, which is the only machine that knows what
|
||||
// it is -- and the resolved path is printed back so the launch below
|
||||
// hands `llama-server` something absolute.
|
||||
//
|
||||
// "the file is not there" is answered rather than failed, because the
|
||||
// two are different things to a reader and only one of them is a
|
||||
// fault: a machine that could not be asked at all has to say so in
|
||||
// its own words, and it would otherwise arrive as this same sentence
|
||||
// about a missing model.
|
||||
// `$HOME` on the far side, which is the only machine that knows what it is,
|
||||
// and the resolved path printed back so the launch hands `llama-server`
|
||||
// something absolute. "Not there" is answered rather than failed, because a
|
||||
// machine that could not be asked at all has to say so in its own words --
|
||||
// it would otherwise arrive as this same sentence about a missing model.
|
||||
let script = "p=$1; case $p in \"~\") p=$HOME;; \"~/\"*) p=$HOME/${p#\"~/\"};; esac; \
|
||||
[ -f \"$p\" ] && printf 'at\\t%s\\n' \"$p\" || printf 'missing\\n'"
|
||||
.to_string();
|
||||
@@ -664,8 +635,8 @@ fn log_tail(session_dir: &Path) -> String {
|
||||
const LOG_TAIL_LINES: usize = 6;
|
||||
|
||||
/// 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],
|
||||
@@ -691,16 +662,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;
|
||||
};
|
||||
@@ -748,8 +718,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");
|
||||
@@ -802,11 +772,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 {
|
||||
@@ -858,9 +827,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");
|
||||
|
||||
+228
-129
@@ -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,18 +128,23 @@ 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 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>,
|
||||
after: Option<u64>,
|
||||
limit: usize,
|
||||
coalesce: bool,
|
||||
) -> Result<Vec<SeqEvent>> {
|
||||
@@ -161,56 +155,55 @@ pub fn read_window(
|
||||
Some(before) => indexed.first_at_or_after(before)?,
|
||||
None => indexed.lines.len(),
|
||||
};
|
||||
// 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`.
|
||||
let start = match after {
|
||||
Some(after) => indexed.first_at_or_after(after.saturating_add(1))?,
|
||||
None => 0,
|
||||
};
|
||||
// A floor above the window is an empty page, not a walk backwards past it.
|
||||
let start = start.min(end);
|
||||
// Coalescing counts *rows*, not events, and would misread the newest
|
||||
// window: a message still 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(end, limit)
|
||||
indexed.parse_coalesced(start, end, limit)
|
||||
} else {
|
||||
indexed.parse(end.saturating_sub(limit)..end)
|
||||
indexed.parse(start.max(end.saturating_sub(limit))..end)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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()));
|
||||
@@ -234,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,
|
||||
@@ -287,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 {
|
||||
@@ -335,23 +323,21 @@ 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.
|
||||
fn parse_coalesced(&self, end: usize, limit: usize) -> Result<Vec<SeqEvent>> {
|
||||
/// 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();
|
||||
// The run currently being gathered: its oldest seq/ts so far, and its deltas newest-first.
|
||||
@@ -369,10 +355,10 @@ impl<'a> Indexed<'a> {
|
||||
}
|
||||
};
|
||||
let mut index = end;
|
||||
while index > 0 {
|
||||
// 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.
|
||||
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.
|
||||
if out.len() >= limit && run.is_none() {
|
||||
break;
|
||||
}
|
||||
@@ -471,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(_)
|
||||
@@ -485,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");
|
||||
@@ -528,7 +514,7 @@ mod tests {
|
||||
}
|
||||
|
||||
// No cursor is the newest page, which is what opening a session asks for.
|
||||
let newest = read_window(&path, None, 3, false).expect("window");
|
||||
let newest = read_window(&path, None, None, 3, false).expect("window");
|
||||
assert_eq!(
|
||||
newest.iter().map(|entry| entry.seq).collect::<Vec<_>>(),
|
||||
[8, 9, 10]
|
||||
@@ -536,7 +522,7 @@ mod tests {
|
||||
|
||||
// Then backwards from the oldest of those, exclusive: the page a phone
|
||||
// scrolling up asks for must not repeat the row it is scrolling from.
|
||||
let older = read_window(&path, Some(8), 3, false).expect("window");
|
||||
let older = read_window(&path, Some(8), None, 3, false).expect("window");
|
||||
assert_eq!(
|
||||
older.iter().map(|entry| entry.seq).collect::<Vec<_>>(),
|
||||
[5, 6, 7]
|
||||
@@ -544,24 +530,108 @@ mod tests {
|
||||
|
||||
// Asking for more than there is gives what there is, rather than failing.
|
||||
assert_eq!(
|
||||
read_window(&path, None, 100, false).expect("window").len(),
|
||||
read_window(&path, None, None, 100, false)
|
||||
.expect("window")
|
||||
.len(),
|
||||
10
|
||||
);
|
||||
|
||||
// Nothing before the first event, which is how the phone learns to stop
|
||||
// paging. An empty answer here is the end of the history, not a fault.
|
||||
assert!(
|
||||
read_window(&path, Some(1), 3, false)
|
||||
read_window(&path, Some(1), None, 3, false)
|
||||
.expect("window")
|
||||
.is_empty()
|
||||
);
|
||||
assert!(
|
||||
read_window(&dir.path().join("nope.jsonl"), None, 3, false)
|
||||
read_window(&dir.path().join("nope.jsonl"), None, None, 3, false)
|
||||
.expect("window")
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_floor_stops_a_page_at_what_the_caller_already_holds() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("transcript.jsonl");
|
||||
let mut transcript = Transcript::open(&path).expect("open");
|
||||
for n in 1..=10 {
|
||||
transcript
|
||||
.append(text(&n.to_string()), 0.0)
|
||||
.expect("append");
|
||||
}
|
||||
|
||||
// The floor is exclusive, like the SSE route's `after`, and it -- not the
|
||||
// limit -- is what the page stops at. This is the gap between a phone's
|
||||
// cached run and the window on its screen, fetched exactly.
|
||||
let page = read_window(&path, Some(9), Some(5), 100, false).expect("window");
|
||||
assert_eq!(
|
||||
page.iter().map(|entry| entry.seq).collect::<Vec<_>>(),
|
||||
[6, 7, 8]
|
||||
);
|
||||
|
||||
// A limit smaller than the gap still bites; the floor is a bound, not a
|
||||
// replacement for one.
|
||||
let page = read_window(&path, Some(9), Some(2), 3, false).expect("window");
|
||||
assert_eq!(
|
||||
page.iter().map(|entry| entry.seq).collect::<Vec<_>>(),
|
||||
[6, 7, 8]
|
||||
);
|
||||
|
||||
// A floor at or above the window is an empty page, not a walk past it.
|
||||
assert!(
|
||||
read_window(&path, Some(4), Some(9), 10, false)
|
||||
.expect("window")
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_floor_inside_a_delta_run_leaves_the_partial_run_it_cuts() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("transcript.jsonl");
|
||||
let mut transcript = Transcript::open(&path).expect("open");
|
||||
for d in ["a", "b", "c", "d"] {
|
||||
transcript.append(text(d), 0.0).expect("append"); // seq 1..4
|
||||
}
|
||||
transcript
|
||||
.append(
|
||||
Event::ToolStart {
|
||||
id: "t".into(),
|
||||
tool: "Bash".into(),
|
||||
input: serde_json::Value::Null,
|
||||
},
|
||||
0.0,
|
||||
)
|
||||
.expect("append"); // seq 5
|
||||
|
||||
// Cut inside the run: what comes back is the deltas above the floor, seq'd
|
||||
// at the first of them -- the partial the phone's `healSplitMessage` welds
|
||||
// onto the rest, the same as a run cut by the limit.
|
||||
let rows = read_window(&path, Some(6), Some(2), 10, true).expect("window");
|
||||
assert_eq!(rows.len(), 2);
|
||||
assert!(matches!(
|
||||
&rows[0],
|
||||
SeqEvent { seq: 3, event: Event::AssistantText { delta }, .. } if delta == "cd"
|
||||
));
|
||||
assert!(matches!(
|
||||
&rows[1],
|
||||
SeqEvent {
|
||||
seq: 5,
|
||||
event: Event::ToolStart { .. },
|
||||
..
|
||||
}
|
||||
));
|
||||
|
||||
// And with no floor the whole run is one row, as before.
|
||||
let rows = read_window(&path, Some(6), None, 10, true).expect("window");
|
||||
assert_eq!(rows.len(), 2);
|
||||
assert!(matches!(
|
||||
&rows[0],
|
||||
SeqEvent { seq: 1, event: Event::AssistantText { delta }, .. } if delta == "abcd"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coalescing_counts_rows_and_joins_delta_runs() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
@@ -588,7 +658,7 @@ mod tests {
|
||||
|
||||
// Three rows asked for, three rows returned -- each delta run one event -- where a raw
|
||||
// window of three would have shown one and a half tokens of the newer reply.
|
||||
let rows = read_window(&path, Some(8), 3, true).expect("window");
|
||||
let rows = read_window(&path, Some(8), None, 3, true).expect("window");
|
||||
assert_eq!(rows.len(), 3);
|
||||
// A run keeps its oldest delta's seq, so the phone anchors and pages from where it always
|
||||
// did.
|
||||
@@ -610,14 +680,43 @@ mod tests {
|
||||
));
|
||||
|
||||
// The next page pages from the oldest row's seq and returns the rest, no repeat, no gap.
|
||||
let older = read_window(&path, Some(1), 3, true).expect("window");
|
||||
let older = read_window(&path, Some(1), None, 3, true).expect("window");
|
||||
assert!(older.is_empty());
|
||||
|
||||
// The newest window never coalesces even when asked: the live cursor depends on real seqs.
|
||||
let newest = read_window(&path, None, 2, true).expect("window");
|
||||
let newest = read_window(&path, None, None, 2, true).expect("window");
|
||||
assert_eq!(newest.iter().map(|e| e.seq).collect::<Vec<_>>(), [6, 7]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_line_read_back_is_the_line_that_was_written() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("transcript.jsonl");
|
||||
// A timestamp with enough digits to be lost: the clock produces these all day, and this
|
||||
// one is real (2026-09-04). serde_json's default float parser is not correctly rounded,
|
||||
// so it read this back as ...0755 and every reader got a line one bit different from the
|
||||
// one in the file -- while the SSE stream, which serializes the same struct, had already
|
||||
// sent the original. Two answers to "what is line 1", indistinguishable by eye.
|
||||
//
|
||||
// Nothing on screen showed it: a `ts` is drawn as a relative time. What found it was the
|
||||
// phone's transcript cache, which keeps the line it was sent and checks it against the
|
||||
// server's own answer before resuming a stream from it -- so the mismatch turned into a
|
||||
// cache thrown away and a transcript downloaded again, silently and only sometimes. The
|
||||
// `float_roundtrip` feature in Cargo.toml is the fix; this is what keeps it.
|
||||
let mut transcript = Transcript::open(&path).expect("open");
|
||||
transcript
|
||||
.append(text("hello"), 1788546972.6030757)
|
||||
.expect("append");
|
||||
drop(transcript);
|
||||
|
||||
let written = std::fs::read_to_string(&path).expect("read");
|
||||
let entry = read_window(&path, None, None, 10, false).expect("window");
|
||||
assert_eq!(
|
||||
serde_json::to_string(&entry[0]).expect("serialize"),
|
||||
written.trim()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_file_reads_as_empty() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
|
||||
@@ -1,26 +1,23 @@
|
||||
//! 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.
|
||||
//!
|
||||
//! A transport is therefore two operations rather than one: **run this**,
|
||||
//! and **reach this port**. The second is what a managed `llama-server`
|
||||
//! needs -- it is spawned as a process and then spoken to over HTTP -- and
|
||||
//! it is a no-op locally, where the port a program binds is already a port
|
||||
//! this machine can dial. Over ssh it is an `-L` tunnel carried by the
|
||||
//! same connection that runs the command, so the model server binds
|
||||
//! loopback on the far machine and is never exposed to its network. See
|
||||
//! [`Transport::reserve_port`] and PLAN.md's SSH section.
|
||||
//! A transport is therefore two operations rather than one: **run this** and
|
||||
//! **reach this port**. The second is what a managed `llama-server` needs -- it
|
||||
//! is spawned as a process and then spoken to over HTTP -- and it is a no-op
|
||||
//! locally, where the port a program binds is already one this machine can
|
||||
//! dial. Over ssh it is an `-L` tunnel on the same connection that runs the
|
||||
//! command, so the model server binds loopback on the far machine and is never
|
||||
//! exposed to its network. See [`Transport::reserve_port`].
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Stdio;
|
||||
@@ -31,12 +28,10 @@ use tokio::process::Child;
|
||||
use crate::config::SshConfig;
|
||||
pub use crate::ssh::Forward;
|
||||
|
||||
/// What a driver needs run in order to exist as a process.
|
||||
///
|
||||
/// Deliberately just what every transport can carry: the command, where
|
||||
/// it runs, and a port the caller needs to reach. Anything a particular
|
||||
/// machine needs -- a key, extra ssh options, which address to dial -- 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
|
||||
/// what every transport can carry -- the command, where it runs, and a port the
|
||||
/// caller needs to reach; 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>,
|
||||
@@ -74,22 +69,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,
|
||||
@@ -103,8 +96,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 },
|
||||
}
|
||||
|
||||
@@ -120,12 +112,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,
|
||||
@@ -159,11 +149,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);
|
||||
}
|
||||
}
|
||||
@@ -181,13 +169,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,
|
||||
@@ -216,21 +201,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),
|
||||
@@ -239,13 +222,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;
|
||||
@@ -308,11 +289,11 @@ const FAR_PORTS: std::ops::Range<u16> = 20000..30000;
|
||||
|
||||
/// 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>),
|
||||
@@ -323,10 +304,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,33 +1,28 @@
|
||||
//! 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),
|
||||
// Named for the program rather than for where it runs: it runs
|
||||
@@ -36,18 +31,16 @@ const PROBES: &[(&str, &str, DriverKind)] = &[
|
||||
("llama-cpp", "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!(
|
||||
@@ -58,9 +51,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(),
|
||||
@@ -81,8 +74,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(),
|
||||
@@ -95,16 +88,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") {
|
||||
@@ -123,11 +114,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()
|
||||
@@ -163,25 +152,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}"),
|
||||
@@ -191,11 +175,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
|
||||
@@ -209,9 +192,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 {
|
||||
@@ -223,8 +206,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");
|
||||
|
||||
+98
-134
@@ -1,28 +1,26 @@
|
||||
//! 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;
|
||||
|
||||
/// A port on the machine a command runs on, and the port that reaches it
|
||||
/// from the backend.
|
||||
/// A port on the machine a command runs on, and the port that reaches it from
|
||||
/// the backend.
|
||||
///
|
||||
/// The second half of what a transport is (PLAN.md's SSH section): "run
|
||||
/// this" plus "reach this port". Locally the two numbers are the same one
|
||||
/// and nothing is forwarded; over ssh the connection carries an `-L`
|
||||
/// tunnel, so a model server binds loopback on the far machine and is
|
||||
/// never exposed to its network.
|
||||
/// The second half of what a transport is (PLAN.md's SSH section): "run this"
|
||||
/// plus "reach this port". Locally the two numbers are one and nothing is
|
||||
/// forwarded; over ssh the connection carries an `-L` tunnel, so a model server
|
||||
/// binds loopback on the far machine and is never exposed to its network.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Forward {
|
||||
/// What the launched program should listen on, on its own machine.
|
||||
@@ -32,31 +30,26 @@ pub struct Forward {
|
||||
pub here: u16,
|
||||
}
|
||||
|
||||
/// 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,
|
||||
@@ -68,14 +61,11 @@ 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;
|
||||
@@ -83,39 +73,28 @@ pub fn command(
|
||||
|
||||
let mut command = Command::new("ssh");
|
||||
if let Some(forward) = forward {
|
||||
// A forwarded process is not spoken to over stdio, and that
|
||||
// changes how it has to be shut down. Everything else here is a
|
||||
// CLI reading its stdin, so killing the ssh client closes that
|
||||
// stdin and the far process ends; a `llama-server` never reads
|
||||
// its own, so the same kill left it running on the far machine
|
||||
// holding the model in memory -- measured 2026-09-04, an orphan
|
||||
// per stopped session. A pty is what makes sshd hang the far side
|
||||
// up: when the connection goes, the master closes and the session
|
||||
// takes SIGHUP. `-tt` because this client has no terminal of its
|
||||
// own to inherit one from.
|
||||
//
|
||||
// The cost is that its log arrives through a line discipline
|
||||
// (CRLF, and whatever the program does when it thinks it is on a
|
||||
// terminal). Nothing parses that log, so it is a fair trade for a
|
||||
// process that reliably goes away.
|
||||
// A forwarded process is not spoken to over stdio, and that changes how
|
||||
// it is shut down. Everything else here is a CLI reading its stdin, so
|
||||
// killing the ssh client ends it; a `llama-server` never reads its own,
|
||||
// so the same kill left it running on the far machine with the model
|
||||
// loaded -- measured 2026-09-04, an orphan per stopped session. A pty
|
||||
// is what makes sshd hang the far side up. `-tt` because this client
|
||||
// has no terminal to inherit one from. The cost is a log that arrives
|
||||
// through a line discipline, which nothing parses.
|
||||
command.arg("-tt");
|
||||
// Loopback on both ends: the far side binds 127.0.0.1, so the
|
||||
// port it serves is reachable only through this connection and
|
||||
// never from that machine's network -- and the near end is bound
|
||||
// to this host alone for the same reason.
|
||||
// Loopback at both ends: the far side binds 127.0.0.1, so what it
|
||||
// serves is reachable only through this connection.
|
||||
command.args([
|
||||
"-L",
|
||||
&format!("127.0.0.1:{}:127.0.0.1:{}", forward.here, forward.there),
|
||||
]);
|
||||
// Without this a forward that cannot be set up is a warning on
|
||||
// stderr and a session that runs anyway, answering nothing: the
|
||||
// failure would arrive as "the model never became ready", which
|
||||
// is the wrong thing to go looking at.
|
||||
// Without this a forward that cannot be set up is a warning on stderr
|
||||
// and a session that runs anyway, answering nothing -- which would
|
||||
// arrive as "the model never became ready".
|
||||
command.args(["-o", "ExitOnForwardFailure=yes"]);
|
||||
} else {
|
||||
// -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 {
|
||||
@@ -129,9 +108,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);
|
||||
@@ -139,11 +117,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 {
|
||||
@@ -163,11 +140,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 == "~" {
|
||||
@@ -187,23 +162,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();
|
||||
@@ -214,15 +185,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"'\''"))
|
||||
}
|
||||
|
||||
@@ -243,8 +212,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(),
|
||||
@@ -311,13 +280,13 @@ mod tests {
|
||||
assert!(!rendered.contains(&"IdentitiesOnly=yes".to_string()));
|
||||
}
|
||||
|
||||
/// The second half of a transport: the connection that runs the
|
||||
/// command also carries the port that reaches it.
|
||||
/// The second half of a transport: the connection that runs the command also
|
||||
/// carries the port that reaches it.
|
||||
///
|
||||
/// Both ends are pinned to loopback, which is the property that keeps
|
||||
/// a model server off the far machine's network -- asserted here
|
||||
/// rather than trusted, because dropping the addresses is a one-word
|
||||
/// edit that still works on a machine nobody else can reach.
|
||||
/// Both ends are pinned to loopback, which is what keeps a model server off
|
||||
/// the far machine's network -- asserted rather than trusted, because
|
||||
/// dropping the addresses is a one-word edit that still works on a machine
|
||||
/// nobody else can reach.
|
||||
#[test]
|
||||
fn a_forwarded_port_rides_the_same_connection_as_the_command() {
|
||||
let ssh = bare_host();
|
||||
@@ -337,9 +306,8 @@ mod tests {
|
||||
.expect("a forward");
|
||||
assert_eq!(rendered[forward + 1], "127.0.0.1:41000:127.0.0.1:24242");
|
||||
assert!(rendered.contains(&"ExitOnForwardFailure=yes".to_string()));
|
||||
// The half that is easy to lose: without a pty the far process
|
||||
// outlives the connection, because nothing closes a stdin it
|
||||
// never reads.
|
||||
// The half that is easy to lose: without a pty the far process outlives
|
||||
// the connection, because nothing closes a stdin it never reads.
|
||||
assert!(rendered.contains(&"-tt".to_string()));
|
||||
assert!(!rendered.contains(&"-T".to_string()));
|
||||
// Options come before the host, or ssh reads them as part of the
|
||||
@@ -350,8 +318,8 @@ mod tests {
|
||||
"exec 'llama-server' '--port' '24242'"
|
||||
);
|
||||
|
||||
// Nothing forwarded is nothing added: every other session is one
|
||||
// of these, and an -L on it would bind a port for no reason.
|
||||
// Nothing forwarded is nothing added: every other session is one of
|
||||
// these, and an -L on it would bind a port for no reason.
|
||||
let plain = argv(&command(Some(&ssh), "claude", &args(["-p"]), None, None));
|
||||
assert!(!plain.contains(&"-L".to_string()));
|
||||
// And a session that *is* spoken to over stdio keeps its raw pipe.
|
||||
@@ -359,19 +327,17 @@ mod tests {
|
||||
assert!(!plain.contains(&"-tt".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'");
|
||||
|
||||
@@ -382,13 +348,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 {
|
||||
@@ -415,9 +379,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; '\'''"#,
|
||||
@@ -429,8 +393,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), None));
|
||||
|
||||
+94
-114
@@ -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::{Arc, 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,
|
||||
@@ -142,9 +133,9 @@ pub trait UsageProvider: Send + Sync {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
@@ -152,9 +143,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 {
|
||||
@@ -169,12 +160,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",
|
||||
@@ -194,8 +182,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)
|
||||
}
|
||||
}
|
||||
@@ -245,19 +233,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
|
||||
@@ -268,10 +254,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();
|
||||
@@ -529,16 +514,15 @@ impl UsageProvider for EchoUsage {
|
||||
|
||||
/// 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.
|
||||
/// 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.
|
||||
///
|
||||
/// Which meter a provider has is [`DriverKind::usage_provider`]'s answer
|
||||
/// rather than a second match on kinds here, because the phone pairs a
|
||||
/// session with one of these rows by that same name: two lists that
|
||||
/// disagree would leave a session looking for a snapshot nothing
|
||||
/// produces, and nothing on screen could say why. A second service later
|
||||
/// is a name there and an impl beside [`ClaudeUsage`], not a screen.
|
||||
/// Which meter a provider has is [`DriverKind::usage_provider`]'s answer rather
|
||||
/// than a second match on kinds here, because the phone pairs a session with
|
||||
/// one of these rows by that same name: two lists that disagreed would leave a
|
||||
/// session looking for a snapshot nothing produces. A second service later is a
|
||||
/// name there and an impl beside [`ClaudeUsage`], not a screen.
|
||||
fn providers_for(setup: &SetupConfig, fixture: &Fixture) -> Vec<Box<dyn UsageProvider>> {
|
||||
let mut found: Vec<Box<dyn UsageProvider>> = Vec::new();
|
||||
for provider in &setup.providers {
|
||||
@@ -571,19 +555,17 @@ fn providers_for(setup: &SetupConfig, fixture: &Fixture) -> Vec<Box<dyn UsagePro
|
||||
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>,
|
||||
/// The invented meter an echo session can put up; empty unless one
|
||||
@@ -600,12 +582,12 @@ impl UsageMonitor {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@@ -614,19 +596,17 @@ impl UsageMonitor {
|
||||
if let Some((fetched, snapshot)) = self.cache.lock().unwrap().get(&key)
|
||||
&& fetched.elapsed() < provider.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()
|
||||
@@ -676,8 +656,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(),
|
||||
@@ -707,9 +687,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 { .. }),
|
||||
"{:?}",
|
||||
@@ -722,8 +702,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
|
||||
@@ -733,16 +713,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