Rename setups and add provider reauthentication
This commit is contained in:
1 parent
e9a0f1b9da
commit
7d9df5d572
36 files changed
+1866
-684
No files matched your search
+78
-38
@@ -28,7 +28,11 @@ pub struct Config {
|
||||
/// 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>,
|
||||
pub setups: Vec<SetupConfig>,
|
||||
/// `setups` is the persisted spelling before the machine/provider boundary
|
||||
/// was named correctly. Read it once so an update does not discard the
|
||||
/// machines already configured; every subsequent write uses `machines`.
|
||||
#[serde(alias = "setups")]
|
||||
pub machines: Vec<MachineConfig>,
|
||||
pub sessions: Vec<SessionConfig>,
|
||||
/// What a new session's thinking level is when nothing chose one.
|
||||
///
|
||||
@@ -52,8 +56,8 @@ pub struct Config {
|
||||
/// host and offered the whole cross-product.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SetupConfig {
|
||||
/// Stable identifier, minted when the setup is added and never
|
||||
pub struct MachineConfig {
|
||||
/// Stable identifier, minted when the machine is added and never
|
||||
/// changed. Sessions reference this rather than the label, so
|
||||
/// renaming a machine on the phone does not orphan its sessions --
|
||||
/// which is the whole reason the two are separate fields.
|
||||
@@ -62,19 +66,19 @@ pub struct SetupConfig {
|
||||
/// 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
|
||||
/// What can be spawned here. Names are unique within a machine, and only
|
||||
/// within it: two machines may each have a `claude-cli`, which is the point.
|
||||
#[serde(default)]
|
||||
pub providers: Vec<ProviderConfig>,
|
||||
}
|
||||
|
||||
impl SetupConfig {
|
||||
impl MachineConfig {
|
||||
pub fn provider(&self, name: &str) -> Option<&ProviderConfig> {
|
||||
self.providers.iter().find(|provider| provider.name == name)
|
||||
}
|
||||
}
|
||||
|
||||
/// One thing a setup can run: which driver, and how to invoke it.
|
||||
/// One thing a machine can run: which driver, and how to invoke it.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProviderConfig {
|
||||
@@ -100,7 +104,7 @@ impl ProviderConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// How to reach a setup that isn't this machine, with the system `ssh` client
|
||||
/// How to reach a machine 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.
|
||||
@@ -302,12 +306,15 @@ 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,
|
||||
/// Id of the [`MachineConfig`] 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 takes effect on the next relaunch; a session
|
||||
/// whose setup or provider is gone reports as exited and can still be
|
||||
/// `setup` is accepted only as the on-disk migration from builds that used
|
||||
/// that word for a machine. The API and newly written records say `machine`.
|
||||
#[serde(alias = "setup")]
|
||||
pub machine: String,
|
||||
/// Name of the provider within that machine. Both stored by name rather than
|
||||
/// resolved, so an edited machine takes effect on the next relaunch; a session
|
||||
/// whose machine or provider is gone reports as exited and can still be
|
||||
/// deleted.
|
||||
pub provider: String,
|
||||
pub title: String,
|
||||
@@ -419,17 +426,17 @@ fn not_set(flag: &bool) -> bool {
|
||||
!*flag
|
||||
}
|
||||
|
||||
/// The name of the echo provider, and of the setup this machine gets on first
|
||||
/// The name of the echo provider, and of the local machine created on first
|
||||
/// run.
|
||||
///
|
||||
/// 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
|
||||
pub const LOCAL_MACHINE: &str = "this machine";
|
||||
/// The id of the machine 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";
|
||||
pub const LOCAL_MACHINE_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.
|
||||
@@ -438,15 +445,15 @@ pub fn pending_enrollments_dir(config_path: &Path) -> PathBuf {
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn setup(&self, id: &str) -> Option<&SetupConfig> {
|
||||
self.setups.iter().find(|setup| setup.id == id)
|
||||
pub fn machine(&self, id: &str) -> Option<&MachineConfig> {
|
||||
self.machines.iter().find(|machine| machine.id == id)
|
||||
}
|
||||
|
||||
/// A setup by the label a person sees, for messages and for the one place a
|
||||
/// A machine 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)
|
||||
pub fn machine_named(&self, name: &str) -> Option<&MachineConfig> {
|
||||
self.machines.iter().find(|machine| machine.name == name)
|
||||
}
|
||||
|
||||
/// This machine, offering whatever was found on it.
|
||||
@@ -455,10 +462,10 @@ impl Config {
|
||||
/// 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(),
|
||||
name: LOCAL_SETUP.to_string(),
|
||||
pub fn seed(providers: Vec<ProviderConfig>) -> MachineConfig {
|
||||
MachineConfig {
|
||||
id: LOCAL_MACHINE_ID.to_string(),
|
||||
name: LOCAL_MACHINE.to_string(),
|
||||
ssh: None,
|
||||
providers,
|
||||
}
|
||||
@@ -533,11 +540,11 @@ mod tests {
|
||||
let path = dir.path().join("config.ron");
|
||||
|
||||
// A missing file is the ordinary first-run state, not an error.
|
||||
// Nothing is conjured to fill it: the seed setup is written by the
|
||||
// Nothing is conjured to fill it: the seed machine is written by the
|
||||
// manager, so the file always says what there is.
|
||||
let first_run = Config::load(&path).expect("load");
|
||||
assert!(first_run.tokens.is_empty());
|
||||
assert!(first_run.setups.is_empty());
|
||||
assert!(first_run.machines.is_empty());
|
||||
assert!(first_run.sessions.is_empty());
|
||||
|
||||
let config = Config {
|
||||
@@ -545,7 +552,7 @@ mod tests {
|
||||
name: "phone".to_string(),
|
||||
sha256: "ab".repeat(32),
|
||||
}],
|
||||
setups: vec![
|
||||
machines: vec![
|
||||
Config::seed(vec![
|
||||
Config::echo_provider(),
|
||||
ProviderConfig {
|
||||
@@ -555,7 +562,7 @@ mod tests {
|
||||
models: Vec::new(),
|
||||
},
|
||||
]),
|
||||
SetupConfig {
|
||||
MachineConfig {
|
||||
id: "vm".to_string(),
|
||||
name: "the vm".to_string(),
|
||||
ssh: Some(SshConfig {
|
||||
@@ -577,7 +584,7 @@ mod tests {
|
||||
default_effort: Some("low".to_string()),
|
||||
sessions: vec![SessionConfig {
|
||||
id: "abc123".to_string(),
|
||||
setup: "vm".to_string(),
|
||||
machine: "vm".to_string(),
|
||||
provider: "claude-cli".to_string(),
|
||||
title: "test".to_string(),
|
||||
model: None,
|
||||
@@ -597,14 +604,14 @@ mod tests {
|
||||
|
||||
let loaded = Config::load(&path).expect("reload");
|
||||
assert_eq!(loaded.tokens[0].name, "phone");
|
||||
assert_eq!(loaded.sessions[0].setup, "vm");
|
||||
assert_eq!(loaded.sessions[0].machine, "vm");
|
||||
// The label and the id are separate, and the session holds the id.
|
||||
assert_eq!(loaded.setup("vm").expect("setup").name, "the vm");
|
||||
assert_eq!(loaded.machine("vm").expect("machine").name, "the vm");
|
||||
assert_eq!(loaded.sessions[0].provider, "claude-cli");
|
||||
assert_eq!(
|
||||
loaded
|
||||
.setup("vm")
|
||||
.expect("setup")
|
||||
.machine("vm")
|
||||
.expect("machine")
|
||||
.ssh
|
||||
.as_ref()
|
||||
.expect("ssh")
|
||||
@@ -612,15 +619,21 @@ mod tests {
|
||||
Some(2222),
|
||||
);
|
||||
// The same provider name on two machines is the point, not a
|
||||
// collision: names are unique within a setup and only within one.
|
||||
// collision: names are unique within a machine and only within one.
|
||||
assert!(
|
||||
loaded
|
||||
.setup(LOCAL_SETUP_ID)
|
||||
.machine(LOCAL_MACHINE_ID)
|
||||
.expect("local")
|
||||
.provider("claude-cli")
|
||||
.is_some()
|
||||
);
|
||||
assert!(loaded.setup(LOCAL_SETUP_ID).expect("local").ssh.is_none());
|
||||
assert!(
|
||||
loaded
|
||||
.machine(LOCAL_MACHINE_ID)
|
||||
.expect("local")
|
||||
.ssh
|
||||
.is_none()
|
||||
);
|
||||
|
||||
// The house rule both halves of `format` depend on: what is written
|
||||
// is the *body* of the struct, with no outer parentheses and
|
||||
@@ -642,6 +655,33 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_setup_spelling_from_existing_configs_but_writes_machine_spelling() {
|
||||
let old = r#"
|
||||
setups: [(
|
||||
id: "vm",
|
||||
name: "the vm",
|
||||
providers: [],
|
||||
)],
|
||||
sessions: [(
|
||||
id: "abc123",
|
||||
setup: "vm",
|
||||
provider: "echo",
|
||||
title: "old words",
|
||||
created: 1234.5,
|
||||
)],
|
||||
"#;
|
||||
let config: Config = format::parse(old).expect("old setup spelling still loads");
|
||||
assert_eq!(config.machines[0].id, "vm");
|
||||
assert_eq!(config.sessions[0].machine, "vm");
|
||||
|
||||
let written = format::render(&config).expect("render migrated config");
|
||||
assert!(written.contains("machines:"), "{written}");
|
||||
assert!(written.contains("machine: \"vm\""), "{written}");
|
||||
assert!(!written.contains("setups:"), "{written}");
|
||||
assert!(!written.contains("setup: \"vm\""), "{written}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// The seed is this machine and nothing more: a name, no ssh, and
|
||||
/// exactly the providers it was handed.
|
||||
@@ -653,7 +693,7 @@ mod tests {
|
||||
/// this can check is that the seed does not invent anything.
|
||||
fn the_seed_is_this_machine_and_claims_only_what_it_was_given() {
|
||||
let seed = Config::seed(vec![Config::echo_provider()]);
|
||||
assert_eq!(seed.name, LOCAL_SETUP);
|
||||
assert_eq!(seed.name, LOCAL_MACHINE);
|
||||
assert!(seed.ssh.is_none());
|
||||
assert_eq!(
|
||||
seed.provider(ECHO_PROVIDER).expect("echo").kind,
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
//! Reading and changing files on the machine a setup names.
|
||||
//! Reading and changing files on a configured machine.
|
||||
//!
|
||||
//! Every operation here is one small POSIX shell script handed to `Transport`,
|
||||
//! the way `setups::discover` and `import::list` already ask a machine a
|
||||
//! the way `machines::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
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! 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.
|
||||
//! arbitrary programs to run on every machine already configured here.
|
||||
//!
|
||||
//! 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
|
||||
@@ -28,7 +28,7 @@ const PROBES: &[(&str, &str, DriverKind)] = &[
|
||||
("claude-cli", "claude", DriverKind::ClaudeCli),
|
||||
("codex-cli", "codex", DriverKind::CodexCli),
|
||||
// Named for the program rather than for where it runs: it runs
|
||||
// wherever the setup is, and "local" was true only while a llama
|
||||
// wherever the machine is, and "local" was true only while a llama
|
||||
// session could not be spawned on another machine.
|
||||
("llama-cpp", "llama-server", DriverKind::LlamaCpp),
|
||||
];
|
||||
@@ -171,7 +171,7 @@ fn explain(err: anyhow::Error) -> anyhow::Error {
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// machine 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
|
||||
@@ -233,7 +233,7 @@ pub fn shorten_home(path: &str) -> String {
|
||||
///
|
||||
/// 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
|
||||
/// useful half of why a machine 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> {
|
||||
+23
-13
@@ -14,12 +14,13 @@
|
||||
mod auth;
|
||||
mod config;
|
||||
mod files;
|
||||
mod machines;
|
||||
mod media;
|
||||
mod models;
|
||||
mod provider_auth;
|
||||
mod resume;
|
||||
mod routes;
|
||||
mod session;
|
||||
mod setups;
|
||||
mod ssh;
|
||||
mod usage;
|
||||
|
||||
@@ -143,7 +144,7 @@ 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
|
||||
// Before the manager exists, on purpose: constructing it and seeding machines
|
||||
// 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.
|
||||
@@ -188,19 +189,19 @@ async fn main() -> Result<()> {
|
||||
// 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?;
|
||||
manager.seed_machine().await?;
|
||||
|
||||
tracing::info!("config: {}", config_path.display());
|
||||
tracing::info!("models: {}", models_dir.display());
|
||||
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)".
|
||||
None => tracing::info!(" setup \"{}\" runs here", setup.name),
|
||||
for machine in manager.machines() {
|
||||
match &machine.ssh {
|
||||
Some(ssh) => tracing::info!(" machine \"{}\" -> {}", machine.name, ssh.address),
|
||||
// No parenthetical naming the local machine: the default machine is
|
||||
// *called* "this machine", so repeating a local qualifier read
|
||||
// like a stutter.
|
||||
None => tracing::info!(" machine \"{}\" runs here", machine.name),
|
||||
}
|
||||
for provider in &setup.providers {
|
||||
for provider in &machine.providers {
|
||||
tracing::info!(" provider {} ({:?})", provider.name, provider.kind);
|
||||
}
|
||||
}
|
||||
@@ -263,11 +264,12 @@ async fn main() -> Result<()> {
|
||||
.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
|
||||
// what, comes from the machines 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()));
|
||||
let provider_logins = Arc::new(provider_auth::LoginManager::new(Arc::clone(&monitor)));
|
||||
|
||||
// The one thing in here that acts without a request behind it: a session
|
||||
// switched to auto-resume waits out its account's usage limit and picks
|
||||
@@ -279,7 +281,14 @@ async fn main() -> Result<()> {
|
||||
// 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::usage_router(
|
||||
Arc::clone(&monitor),
|
||||
Arc::clone(&manager),
|
||||
))
|
||||
.merge(routes::provider_auth_router(
|
||||
Arc::clone(&provider_logins),
|
||||
Arc::clone(&manager),
|
||||
))
|
||||
.merge(routes::models_router(Arc::clone(&models)))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
Arc::clone(&manager),
|
||||
@@ -322,6 +331,7 @@ async fn main() -> Result<()> {
|
||||
// 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();
|
||||
provider_logins.cancel_all();
|
||||
manager.detach_all();
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -523,13 +523,13 @@ fn collect(root: &Path, dir: &Path, found: &mut Vec<LocalModel>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a machine reached over ssh keeps its models, when its setup does
|
||||
/// Where a machine reached over ssh keeps its models, when its machine does
|
||||
/// not say.
|
||||
///
|
||||
/// The same place this backend puts its own downloads, written out rather
|
||||
/// than derived: `$XDG_DATA_HOME` here describes *this* machine's
|
||||
/// environment, and the far machine's is the far machine's business. A
|
||||
/// setup whose models are elsewhere says so (`SshConfig::models_dir`).
|
||||
/// machine whose models are elsewhere says so (`SshConfig::models_dir`).
|
||||
const FAR_MODELS_DIR: &str = "~/.local/share/ai-app/models";
|
||||
|
||||
/// Which directory holds the models on the machine `transport` reaches.
|
||||
@@ -550,11 +550,11 @@ pub fn dir_on(transport: &Transport, local: &Path) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Every GGUF on the machine a setup names, which is the machine that
|
||||
/// Every GGUF on a configured machine, which is the machine that
|
||||
/// would have to serve it.
|
||||
///
|
||||
/// The local half of this is [`ModelStore::list`], reading the same shape
|
||||
/// off this machine's disk; a caller picks by transport, since a setup
|
||||
/// off this machine's disk; a caller picks by transport, since a machine
|
||||
/// with no ssh *is* this machine and asking a shell about it would be a
|
||||
/// slower way to the same answer. What must not happen is offering this
|
||||
/// backend's downloads for a session on another machine: the file has to
|
||||
|
||||
@@ -0,0 +1,467 @@
|
||||
//! Interactive provider login carried between a CLI on a configured machine
|
||||
//! and the phone. The CLI remains the only credential writer: this layer keeps
|
||||
//! its short-lived process and relays only the authorization URL and the code
|
||||
//! a person copies back from the browser.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::process::Stdio;
|
||||
use std::sync::{Arc, Mutex, mpsc};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use rand::Rng;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::config::{MachineConfig, ProviderConfig};
|
||||
use crate::session::transport::Transport;
|
||||
use crate::usage::UsageMonitor;
|
||||
|
||||
const LOGIN_TIMEOUT: Duration = Duration::from_secs(10 * 60);
|
||||
const AUTHORIZATION_URL_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
const OUTPUT_POLL: Duration = Duration::from_millis(50);
|
||||
const START_WAIT: Duration = Duration::from_secs(16);
|
||||
|
||||
type Key = (String, String);
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(tag = "state", rename_all = "camelCase")]
|
||||
pub enum LoginState {
|
||||
Starting,
|
||||
WaitingForCode {
|
||||
#[serde(rename = "authorizationUrl")]
|
||||
authorization_url: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
detail: Option<String>,
|
||||
},
|
||||
Submitting,
|
||||
Succeeded,
|
||||
Failed {
|
||||
detail: String,
|
||||
},
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
impl LoginState {
|
||||
fn terminal(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::Succeeded | Self::Failed { .. } | Self::Cancelled
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LoginInfo {
|
||||
pub attempt: String,
|
||||
#[serde(flatten)]
|
||||
pub state: LoginState,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Attempt {
|
||||
id: String,
|
||||
state: Arc<Mutex<LoginState>>,
|
||||
input: mpsc::Sender<Input>,
|
||||
}
|
||||
|
||||
impl Attempt {
|
||||
fn info(&self) -> LoginInfo {
|
||||
LoginInfo {
|
||||
attempt: self.id.clone(),
|
||||
state: self.state.lock().unwrap().clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum Input {
|
||||
Code(String),
|
||||
Cancel,
|
||||
}
|
||||
|
||||
/// The active login per machine and provider. Completed attempts stay until a
|
||||
/// new one replaces them, so a phone that briefly loses its connection can ask
|
||||
/// how the operation ended rather than being handed an ambiguous 404.
|
||||
pub struct LoginManager {
|
||||
attempts: Mutex<HashMap<Key, Attempt>>,
|
||||
usage: Arc<UsageMonitor>,
|
||||
}
|
||||
|
||||
impl LoginManager {
|
||||
pub fn new(usage: Arc<UsageMonitor>) -> Self {
|
||||
Self {
|
||||
attempts: Mutex::new(HashMap::new()),
|
||||
usage,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start(&self, machine: MachineConfig, provider: ProviderConfig) -> LoginInfo {
|
||||
let provider_key = provider
|
||||
.kind
|
||||
.usage_provider()
|
||||
.expect("a login route only accepts a metered provider")
|
||||
.to_string();
|
||||
let key = (machine.id.clone(), provider_key);
|
||||
let mut attempts = self.attempts.lock().unwrap();
|
||||
if let Some(attempt) = attempts.get(&key)
|
||||
&& !attempt.state.lock().unwrap().terminal()
|
||||
{
|
||||
return attempt.info();
|
||||
}
|
||||
|
||||
let id = attempt_id();
|
||||
let state = Arc::new(Mutex::new(LoginState::Starting));
|
||||
let (input, commands) = mpsc::channel();
|
||||
let attempt = Attempt {
|
||||
id: id.clone(),
|
||||
state: Arc::clone(&state),
|
||||
input,
|
||||
};
|
||||
attempts.insert(key, attempt.clone());
|
||||
drop(attempts);
|
||||
|
||||
// Seed the cache before the worker takes the gate. A usage request in
|
||||
// that small handoff window sees a fresh, truthful state and cannot
|
||||
// start a second CLI against the same credential.
|
||||
let gate = self.usage.claude_authentication_started(&machine);
|
||||
let usage = Arc::clone(&self.usage);
|
||||
std::thread::spawn(move || {
|
||||
let _guard = gate.lock().unwrap();
|
||||
run_login(&machine, &provider, commands, &state);
|
||||
usage.claude_authentication_finished(&machine.id);
|
||||
});
|
||||
|
||||
attempt.info()
|
||||
}
|
||||
|
||||
pub fn wait_until_ready(&self, machine: &str, provider: &str, attempt: &str) -> LoginInfo {
|
||||
let started = Instant::now();
|
||||
loop {
|
||||
let info = self.read(machine, provider, attempt).unwrap_or(LoginInfo {
|
||||
attempt: attempt.to_string(),
|
||||
state: LoginState::Failed {
|
||||
detail: "the sign-in attempt disappeared".to_string(),
|
||||
},
|
||||
});
|
||||
if !matches!(info.state, LoginState::Starting) || started.elapsed() >= START_WAIT {
|
||||
return info;
|
||||
}
|
||||
std::thread::sleep(OUTPUT_POLL);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read(&self, machine: &str, provider: &str, attempt: &str) -> Option<LoginInfo> {
|
||||
let attempts = self.attempts.lock().unwrap();
|
||||
let found = attempts.get(&(machine.to_string(), provider.to_string()))?;
|
||||
(found.id == attempt).then(|| found.info())
|
||||
}
|
||||
|
||||
pub fn submit(
|
||||
&self,
|
||||
machine: &str,
|
||||
provider: &str,
|
||||
attempt: &str,
|
||||
code: &str,
|
||||
) -> Result<LoginInfo> {
|
||||
let code = valid_code(code)?;
|
||||
let attempts = self.attempts.lock().unwrap();
|
||||
let found = attempts
|
||||
.get(&(machine.to_string(), provider.to_string()))
|
||||
.filter(|found| found.id == attempt)
|
||||
.context("no such sign-in attempt")?;
|
||||
if found.state.lock().unwrap().terminal() {
|
||||
return Ok(found.info());
|
||||
}
|
||||
*found.state.lock().unwrap() = LoginState::Submitting;
|
||||
if found.input.send(Input::Code(code.to_string())).is_err() {
|
||||
*found.state.lock().unwrap() = LoginState::Failed {
|
||||
detail: "the sign-in process has stopped".to_string(),
|
||||
};
|
||||
anyhow::bail!("the sign-in process has stopped");
|
||||
}
|
||||
Ok(found.info())
|
||||
}
|
||||
|
||||
pub fn cancel(&self, machine: &str, provider: &str, attempt: &str) -> Result<LoginInfo> {
|
||||
let attempts = self.attempts.lock().unwrap();
|
||||
let found = attempts
|
||||
.get(&(machine.to_string(), provider.to_string()))
|
||||
.filter(|found| found.id == attempt)
|
||||
.context("no such sign-in attempt")?;
|
||||
if !found.state.lock().unwrap().terminal() {
|
||||
let _ = found.input.send(Input::Cancel);
|
||||
}
|
||||
Ok(found.info())
|
||||
}
|
||||
|
||||
/// Interactive helpers are unlike sessions: nothing adopts them after a
|
||||
/// server restart. End every one while the process is still here to reap
|
||||
/// the child it launched.
|
||||
pub fn cancel_all(&self) {
|
||||
let attempts = self.attempts.lock().unwrap();
|
||||
for attempt in attempts.values() {
|
||||
if !attempt.state.lock().unwrap().terminal() {
|
||||
let _ = attempt.input.send(Input::Cancel);
|
||||
}
|
||||
}
|
||||
let pending: Vec<_> = attempts
|
||||
.values()
|
||||
.map(|attempt| Arc::clone(&attempt.state))
|
||||
.collect();
|
||||
drop(attempts);
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
while Instant::now() < deadline
|
||||
&& pending
|
||||
.iter()
|
||||
.any(|state| !state.lock().unwrap().terminal())
|
||||
{
|
||||
std::thread::sleep(OUTPUT_POLL);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_login(
|
||||
machine: &MachineConfig,
|
||||
provider: &ProviderConfig,
|
||||
commands: mpsc::Receiver<Input>,
|
||||
state: &Arc<Mutex<LoginState>>,
|
||||
) {
|
||||
if let Err(err) = run_login_inner(machine, provider, commands, state) {
|
||||
*state.lock().unwrap() = LoginState::Failed {
|
||||
detail: format!("couldn't sign in to Claude on {}: {err:#}", machine.name),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
fn run_login_inner(
|
||||
machine: &MachineConfig,
|
||||
provider: &ProviderConfig,
|
||||
commands: mpsc::Receiver<Input>,
|
||||
state: &Arc<Mutex<LoginState>>,
|
||||
) -> Result<()> {
|
||||
let transport = Transport::for_machine(machine);
|
||||
let args = vec![
|
||||
"BROWSER=/bin/false".to_string(),
|
||||
provider.program().to_string(),
|
||||
"auth".to_string(),
|
||||
"login".to_string(),
|
||||
"--claudeai".to_string(),
|
||||
];
|
||||
let host = match &transport {
|
||||
Transport::Here => None,
|
||||
Transport::Ssh { ssh, .. } => Some(ssh),
|
||||
};
|
||||
let mut command = crate::ssh::command(host, "env", &args, None, None);
|
||||
command
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
let mut child = command
|
||||
.spawn()
|
||||
.with_context(|| format!("couldn't run {} auth login", provider.program()))?;
|
||||
let mut stdin = child
|
||||
.stdin
|
||||
.take()
|
||||
.context("the login process has no stdin")?;
|
||||
let stdout = child
|
||||
.stdout
|
||||
.take()
|
||||
.context("the login process has no stdout")?;
|
||||
let stderr = child
|
||||
.stderr
|
||||
.take()
|
||||
.context("the login process has no stderr")?;
|
||||
let (output, lines) = mpsc::channel();
|
||||
read_lines(stdout, output.clone());
|
||||
read_lines(stderr, output);
|
||||
|
||||
let started = Instant::now();
|
||||
let mut authorization_url = None;
|
||||
let mut last_line = None;
|
||||
loop {
|
||||
while let Ok(line) = lines.try_recv() {
|
||||
if let Some(url) = authorization_url_in(&line) {
|
||||
authorization_url = Some(url.to_string());
|
||||
*state.lock().unwrap() = LoginState::WaitingForCode {
|
||||
authorization_url: url.to_string(),
|
||||
detail: None,
|
||||
};
|
||||
} else if line.to_ascii_lowercase().contains("invalid code") {
|
||||
if let Some(url) = &authorization_url {
|
||||
*state.lock().unwrap() = LoginState::WaitingForCode {
|
||||
authorization_url: url.clone(),
|
||||
detail: Some(
|
||||
"That code was not accepted. Copy the complete code and try again."
|
||||
.to_string(),
|
||||
),
|
||||
};
|
||||
}
|
||||
} else if !line.trim().is_empty() {
|
||||
last_line = Some(line.trim().chars().take(500).collect::<String>());
|
||||
}
|
||||
}
|
||||
|
||||
match commands.recv_timeout(OUTPUT_POLL) {
|
||||
Ok(Input::Code(code)) => {
|
||||
*state.lock().unwrap() = LoginState::Submitting;
|
||||
writeln!(stdin, "{code}").context("couldn't send the login code")?;
|
||||
stdin.flush().context("couldn't send the login code")?;
|
||||
}
|
||||
Ok(Input::Cancel) => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
*state.lock().unwrap() = LoginState::Cancelled;
|
||||
return Ok(());
|
||||
}
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
anyhow::bail!("the phone disconnected from the sign-in attempt");
|
||||
}
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => {}
|
||||
}
|
||||
|
||||
if let Some(status) = child
|
||||
.try_wait()
|
||||
.context("couldn't check the login process")?
|
||||
{
|
||||
*state.lock().unwrap() = if status.success() {
|
||||
LoginState::Succeeded
|
||||
} else {
|
||||
LoginState::Failed {
|
||||
detail: last_line
|
||||
.unwrap_or_else(|| format!("Claude's login process exited with {status}")),
|
||||
}
|
||||
};
|
||||
return Ok(());
|
||||
}
|
||||
if authorization_url.is_none() && started.elapsed() >= AUTHORIZATION_URL_TIMEOUT {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
anyhow::bail!("the Claude CLI did not provide an authorization URL");
|
||||
}
|
||||
if started.elapsed() >= LOGIN_TIMEOUT {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
anyhow::bail!("the sign-in attempt expired; start it again");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read_lines(reader: impl Read + Send + 'static, output: mpsc::Sender<String>) {
|
||||
std::thread::spawn(move || {
|
||||
for line in BufReader::new(reader).lines().map_while(Result::ok) {
|
||||
let _ = output.send(line);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn authorization_url_in(line: &str) -> Option<&str> {
|
||||
let start = line.find("https://")?;
|
||||
let tail = &line[start..];
|
||||
let end = tail
|
||||
.find(|character: char| character.is_whitespace() || character == '\u{1b}')
|
||||
.unwrap_or(tail.len());
|
||||
let url = &tail[..end];
|
||||
(url.starts_with("https://claude.com/") || url.starts_with("https://platform.claude.com/"))
|
||||
.then_some(url)
|
||||
}
|
||||
|
||||
fn valid_code(code: &str) -> Result<&str> {
|
||||
let code = code.trim();
|
||||
anyhow::ensure!(!code.is_empty(), "the login code is empty");
|
||||
anyhow::ensure!(code.len() <= 4096, "the login code is too long");
|
||||
anyhow::ensure!(
|
||||
!code.chars().any(char::is_control),
|
||||
"the login code contains a line break or control character"
|
||||
);
|
||||
Ok(code)
|
||||
}
|
||||
|
||||
fn attempt_id() -> String {
|
||||
let mut bytes = [0u8; 16];
|
||||
rand::rng().fill_bytes(&mut bytes);
|
||||
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::DriverKind;
|
||||
|
||||
#[test]
|
||||
fn extracts_only_anthropics_https_login_url() {
|
||||
assert_eq!(
|
||||
authorization_url_in("visit: https://claude.com/cai/oauth/authorize?state=x"),
|
||||
Some("https://claude.com/cai/oauth/authorize?state=x")
|
||||
);
|
||||
assert!(authorization_url_in("visit: http://claude.com/nope").is_none());
|
||||
assert!(authorization_url_in("visit: https://example.com/nope").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn login_code_is_one_bounded_line() {
|
||||
assert_eq!(valid_code(" abc#state ").unwrap(), "abc#state");
|
||||
assert!(valid_code("\n").is_err());
|
||||
assert!(valid_code("a\nb").is_err());
|
||||
assert!(valid_code(&"x".repeat(4097)).is_err());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn relays_a_headless_cli_login_without_taking_over_its_credentials() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let cli = dir.path().join("fake-claude");
|
||||
std::fs::write(
|
||||
&cli,
|
||||
"#!/bin/sh\necho 'https://claude.com/cai/oauth/authorize?state=test'\nIFS= read -r code\n[ \"$code\" = 'the-code' ]\n",
|
||||
)
|
||||
.expect("write fake CLI");
|
||||
std::fs::set_permissions(&cli, std::fs::Permissions::from_mode(0o700))
|
||||
.expect("make fake CLI executable");
|
||||
|
||||
let monitor = Arc::new(UsageMonitor::new(Default::default()));
|
||||
let logins = LoginManager::new(monitor);
|
||||
let machine = MachineConfig {
|
||||
id: "vm".to_string(),
|
||||
name: "test vm".to_string(),
|
||||
ssh: None,
|
||||
providers: Vec::new(),
|
||||
};
|
||||
let provider = ProviderConfig {
|
||||
name: "claude-cli".to_string(),
|
||||
kind: DriverKind::ClaudeCli,
|
||||
command: Some(cli.display().to_string()),
|
||||
models: Vec::new(),
|
||||
};
|
||||
|
||||
let started = logins.start(machine, provider);
|
||||
let ready = logins.wait_until_ready("vm", "claude", &started.attempt);
|
||||
assert!(matches!(ready.state, LoginState::WaitingForCode { .. }));
|
||||
let wire = serde_json::to_value(&ready).expect("serialize login state");
|
||||
assert!(wire.get("authorizationUrl").is_some(), "{wire}");
|
||||
assert!(wire.get("authorization_url").is_none(), "{wire}");
|
||||
let submitted = logins
|
||||
.submit("vm", "claude", &started.attempt, "the-code")
|
||||
.expect("submit code");
|
||||
assert!(matches!(submitted.state, LoginState::Submitting));
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
loop {
|
||||
let finished = logins
|
||||
.read("vm", "claude", &started.attempt)
|
||||
.expect("attempt remains readable");
|
||||
if matches!(finished.state, LoginState::Succeeded) {
|
||||
break;
|
||||
}
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"login did not finish: {finished:?}"
|
||||
);
|
||||
std::thread::sleep(OUTPUT_POLL);
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
-11
@@ -48,7 +48,7 @@ const AT_LEAST: f64 = 60.0;
|
||||
/// How long after the limit was hit to stop waiting.
|
||||
///
|
||||
/// Something has to bound it, or a machine that can never be asked -- an
|
||||
/// unplugged laptop, a setup somebody edited away -- is retried for ever with
|
||||
/// unplugged laptop, a machine somebody edited away -- is retried for ever with
|
||||
/// nothing on screen saying so. A day is past the longest window Claude
|
||||
/// reports, so reaching this means the wait was never going to end on its own.
|
||||
const GIVE_UP: f64 = 24.0 * 60.0 * 60.0;
|
||||
@@ -123,7 +123,7 @@ async fn sweep(manager: &SessionManager, monitor: &Arc<UsageMonitor>) {
|
||||
Step::Send => match manager.resume_now(&owed.session_id) {
|
||||
Ok(message) => tracing::info!(
|
||||
"the limit on {} has lifted; sent \"{message}\" to {}",
|
||||
owed.setup,
|
||||
owed.machine,
|
||||
owed.session_id
|
||||
),
|
||||
Err(err) => {
|
||||
@@ -144,7 +144,7 @@ async fn sweep(manager: &SessionManager, monitor: &Arc<UsageMonitor>) {
|
||||
// to read on a phone.
|
||||
let why = match snapshot.as_ref().map(|snapshot| &snapshot.state) {
|
||||
Some(UsageState::Ok) => "the limit has not lifted in a day".to_string(),
|
||||
_ => format!("{} could not be asked for a day", owed.setup),
|
||||
_ => format!("{} could not be asked for a day", owed.machine),
|
||||
};
|
||||
if let Err(err) = manager.abandon_resume(&owed.session_id, &why) {
|
||||
tracing::error!("couldn't clear {}'s resume: {err:#}", owed.session_id);
|
||||
@@ -164,18 +164,18 @@ async fn snapshot_for(
|
||||
manager: &SessionManager,
|
||||
owed: &OwedResume,
|
||||
) -> Option<UsageSnapshot> {
|
||||
let setups: Vec<_> = manager
|
||||
.setups()
|
||||
let machines: Vec<_> = manager
|
||||
.machines()
|
||||
.into_iter()
|
||||
.filter(|setup| setup.id == owed.setup)
|
||||
.filter(|machine| machine.id == owed.machine)
|
||||
.collect();
|
||||
if setups.is_empty() {
|
||||
if machines.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let provider = owed.provider;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
monitor
|
||||
.snapshots(&setups)
|
||||
.snapshots(&machines)
|
||||
.into_iter()
|
||||
.find(|snapshot| snapshot.provider == provider)
|
||||
})
|
||||
@@ -258,7 +258,7 @@ mod tests {
|
||||
fn owed(since: f64) -> OwedResume {
|
||||
OwedResume {
|
||||
session_id: "s1".to_string(),
|
||||
setup: "local".to_string(),
|
||||
machine: "local".to_string(),
|
||||
provider: crate::usage::CLAUDE,
|
||||
scheduled: ScheduledResume { at: since, since },
|
||||
}
|
||||
@@ -267,8 +267,8 @@ mod tests {
|
||||
fn snapshot(state: UsageState, windows: Vec<UsageWindow>) -> UsageSnapshot {
|
||||
UsageSnapshot {
|
||||
provider: crate::usage::CLAUDE.to_string(),
|
||||
setup: "local".to_string(),
|
||||
setup_name: "this machine".to_string(),
|
||||
machine: "local".to_string(),
|
||||
machine_name: "this machine".to_string(),
|
||||
limit_id: None,
|
||||
limit_name: None,
|
||||
state,
|
||||
|
||||
+249
-124
@@ -3,27 +3,31 @@
|
||||
//! wraps the whole router in.
|
||||
//!
|
||||
//! ```text
|
||||
//! GET /setups machines, each with what it can run
|
||||
//! POST /setups add {name, ssh?} -- providers are discovered
|
||||
//! POST /setups/probe dry run {ssh?}: what would be found there
|
||||
//! GET /setups/{id} one machine, for refetching after a change
|
||||
//! GET /setups/{id}/models GGUFs on that machine, for a llama session
|
||||
//! GET /setups/{id}/providers/{provider}/models models a CLI currently offers
|
||||
//! GET /setups/{id}/dir?path=P entries of directory P, and P resolved
|
||||
//! GET /setups/{id}/file?path=P content of file P, or why not
|
||||
//! PUT /setups/{id}/file {path, content, ifSha256} -> new size/mtime/sha256
|
||||
//! GET /machines machines, each with what it can run
|
||||
//! POST /machines add {name, ssh?} -- providers are discovered
|
||||
//! POST /machines/probe dry run {ssh?}: what would be found there
|
||||
//! GET /machines/{id} one machine, for refetching after a change
|
||||
//! GET /machines/{id}/models GGUFs on that machine, for a llama session
|
||||
//! GET /machines/{id}/providers/{provider}/models models a CLI currently offers
|
||||
//! POST /machines/{id}/providers/{provider}/auth begin provider sign-in
|
||||
//! GET /machines/{id}/providers/{provider}/auth/{attempt} sign-in state
|
||||
//! POST /machines/{id}/providers/{provider}/auth/{attempt}/code submit browser code
|
||||
//! DELETE /machines/{id}/providers/{provider}/auth/{attempt} cancel sign-in
|
||||
//! GET /machines/{id}/dir?path=P entries of directory P, and P resolved
|
||||
//! GET /machines/{id}/file?path=P content of file P, or why not
|
||||
//! PUT /machines/{id}/file {path, content, ifSha256} -> new size/mtime/sha256
|
||||
//! (409 when the file no longer matches ifSha256)
|
||||
//! POST /setups/{id}/file {path} create empty; refused if it exists
|
||||
//! POST /setups/{id}/dir {path} create; refused if it exists
|
||||
//! GET /setups/{id}/importable Claude Code sessions on it that could be continued
|
||||
//! POST /setups/{id}/importable/import {sessions} -> 202; runs on the server
|
||||
//! POST /setups/{id}/importable/delete {sessions} -> 202; removes the machine's transcripts
|
||||
//! GET /setups/{id}/importable/events SSE: what is in flight against them
|
||||
//! PUT /setups/{id} rename {name?} and/or re-probe {rediscover?}
|
||||
//! DELETE /setups/{id} remove, refused while sessions use it
|
||||
//! POST /machines/{id}/file {path} create empty; refused if it exists
|
||||
//! POST /machines/{id}/dir {path} create; refused if it exists
|
||||
//! GET /machines/{id}/importable Claude Code sessions on it that could be continued
|
||||
//! POST /machines/{id}/importable/import {sessions} -> 202; runs on the server
|
||||
//! POST /machines/{id}/importable/delete {sessions} -> 202; removes the machine's transcripts
|
||||
//! GET /machines/{id}/importable/events SSE: what is in flight against them
|
||||
//! PUT /machines/{id} rename {name?} and/or re-probe {rediscover?}
|
||||
//! DELETE /machines/{id} remove, refused while sessions use it
|
||||
//! GET /sessions list (id, provider, title, model, status, last activity)
|
||||
//! GET /sessions/{id} one session, for refetching after a change
|
||||
//! POST /sessions spawn {setup, provider, title?, model?, cwd?, params?}
|
||||
//! POST /sessions spawn {machine, provider, title?, model?, cwd?, params?}
|
||||
//! GET /sessions/{id}/events?after=N SSE: backlog after N, then live
|
||||
//! (a backlog past CATCH_UP_LIMIT arrives as a
|
||||
//! `reset` frame plus the newest window)
|
||||
@@ -114,30 +118,30 @@ use crate::session::{LiveSession, SessionInfo, SessionManager, SpawnSpec};
|
||||
|
||||
pub fn router(manager: Arc<SessionManager>) -> Router {
|
||||
Router::new()
|
||||
.route("/setups", get(list_setups).post(add_setup))
|
||||
.route("/setups/probe", post(probe_setup))
|
||||
.route("/setups/{id}/importable", get(list_importable))
|
||||
.route("/machines", get(list_machines).post(add_machine))
|
||||
.route("/machines/probe", post(probe_machine))
|
||||
.route("/machines/{id}/importable", get(list_importable))
|
||||
// A batch at a time, never a session at a time -- see
|
||||
// [`delete_importable`].
|
||||
.route("/setups/{id}/importable/delete", post(delete_importable))
|
||||
.route("/setups/{id}/importable/import", post(start_import))
|
||||
.route("/setups/{id}/importable/events", get(importable_events))
|
||||
.route("/machines/{id}/importable/delete", post(delete_importable))
|
||||
.route("/machines/{id}/importable/import", post(start_import))
|
||||
.route("/machines/{id}/importable/events", get(importable_events))
|
||||
.route(
|
||||
"/setups/{id}",
|
||||
get(read_setup).put(update_setup).delete(delete_setup),
|
||||
"/machines/{id}",
|
||||
get(read_machine).put(update_machine).delete(delete_machine),
|
||||
)
|
||||
// The models on the machine a setup names, for a llama session there.
|
||||
.route("/setups/{id}/models", get(setup_models))
|
||||
// The models on a configured machine, for a llama session there.
|
||||
.route("/machines/{id}/models", get(machine_models))
|
||||
.route(
|
||||
"/setups/{id}/providers/{provider}/models",
|
||||
"/machines/{id}/providers/{provider}/models",
|
||||
get(provider_models),
|
||||
)
|
||||
// The filesystem of the machine a setup names. Under the setup
|
||||
// The filesystem of a configured machine. Under the machine
|
||||
// rather than under a session because a filesystem is a property of
|
||||
// a machine; a session only says where to start looking.
|
||||
.route("/setups/{id}/dir", get(list_dir).post(create_dir))
|
||||
.route("/machines/{id}/dir", get(list_dir).post(create_dir))
|
||||
.route(
|
||||
"/setups/{id}/file",
|
||||
"/machines/{id}/file",
|
||||
get(read_file).put(write_file).post(create_file),
|
||||
)
|
||||
.route("/sessions", get(list_sessions).post(spawn_session))
|
||||
@@ -255,7 +259,7 @@ async fn list_sessions(State(manager): State<Arc<SessionManager>>) -> axum::Json
|
||||
/// opened from a row carries that snapshot with it. Fine for what a row
|
||||
/// *says* and wrong for what a control is *set to*: a switch drawn from a
|
||||
/// stale row shows the position it had when the list was fetched, and the
|
||||
/// person reading it cannot tell. Same reason `GET /setups/{id}` exists.
|
||||
/// person reading it cannot tell. Same reason `GET /machines/{id}` exists.
|
||||
async fn read_session(
|
||||
State(manager): State<Arc<SessionManager>>,
|
||||
UrlPath(id): UrlPath<String>,
|
||||
@@ -269,7 +273,7 @@ async fn read_session(
|
||||
}
|
||||
|
||||
/// What the spawn screen needs to render itself, so the phone holds no
|
||||
/// hardcoded list: a setup added to `config.ron` shows up with no app
|
||||
/// hardcoded list: a machine added to `config.ron` shows up with no app
|
||||
/// rebuild.
|
||||
///
|
||||
/// One list rather than two, because the halves are not independent. A
|
||||
@@ -278,12 +282,12 @@ async fn read_session(
|
||||
/// the box that hasn't got it".
|
||||
#[derive(serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SetupInfo {
|
||||
struct MachineInfo {
|
||||
/// Stable; what a session stores and what these routes address.
|
||||
id: String,
|
||||
/// The editable label.
|
||||
name: String,
|
||||
/// Where it runs, for telling two setups apart. Absent for this machine.
|
||||
/// Where it runs, for telling two machines apart. Absent for this machine.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
address: Option<String>,
|
||||
providers: Vec<ProviderInfo>,
|
||||
@@ -300,16 +304,16 @@ struct ProviderInfo {
|
||||
default_permission_mode: Option<&'static str>,
|
||||
}
|
||||
|
||||
async fn list_setups(State(manager): State<Arc<SessionManager>>) -> axum::Json<Vec<SetupInfo>> {
|
||||
axum::Json(manager.setups().into_iter().map(info_for).collect())
|
||||
async fn list_machines(State(manager): State<Arc<SessionManager>>) -> axum::Json<Vec<MachineInfo>> {
|
||||
axum::Json(manager.machines().into_iter().map(info_for).collect())
|
||||
}
|
||||
|
||||
fn info_for(setup: crate::config::SetupConfig) -> SetupInfo {
|
||||
SetupInfo {
|
||||
id: setup.id,
|
||||
name: setup.name,
|
||||
address: setup.ssh.map(|ssh| ssh.address),
|
||||
providers: setup
|
||||
fn info_for(machine: crate::config::MachineConfig) -> MachineInfo {
|
||||
MachineInfo {
|
||||
id: machine.id,
|
||||
name: machine.name,
|
||||
address: machine.ssh.map(|ssh| ssh.address),
|
||||
providers: machine
|
||||
.providers
|
||||
.into_iter()
|
||||
.map(|provider| ProviderInfo {
|
||||
@@ -326,7 +330,7 @@ fn info_for(setup: crate::config::SetupConfig) -> SetupInfo {
|
||||
/// How to reach a machine, as the phone describes it.
|
||||
///
|
||||
/// Note what is absent: nothing here names a program. Providers are found by
|
||||
/// asking the machine (`crate::setups`), never sent, so the enrolled token
|
||||
/// asking the machine (`crate::machines`), never sent, so the enrolled token
|
||||
/// cannot introduce something to run.
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -353,7 +357,7 @@ impl SshRequest {
|
||||
/// Tidied at the boundary rather than stored as typed -- this came from a
|
||||
/// phone keyboard, so it may have a stray space or a `~`.
|
||||
fn into_config(self) -> Result<crate::config::SshConfig, ApiError> {
|
||||
let address = crate::setups::tidy(&self.address)
|
||||
let address = crate::machines::tidy(&self.address)
|
||||
.ok_or_else(|| ApiError::BadRequest("a machine needs an address".to_string()))?;
|
||||
Ok(crate::config::SshConfig {
|
||||
address,
|
||||
@@ -361,12 +365,12 @@ impl SshRequest {
|
||||
identity_file: self
|
||||
.identity_file
|
||||
.as_deref()
|
||||
.and_then(crate::setups::tidy)
|
||||
.and_then(crate::machines::tidy)
|
||||
.map(std::path::PathBuf::from),
|
||||
options: self
|
||||
.options
|
||||
.iter()
|
||||
.filter_map(|o| crate::setups::tidy(o))
|
||||
.filter_map(|o| crate::machines::tidy(o))
|
||||
.collect(),
|
||||
// Not `tidy`: that expands `~` to *this* machine's home, and this
|
||||
// path is on the other one. The remote shell expands it there.
|
||||
@@ -391,7 +395,7 @@ impl SshRequest {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct AddSetupRequest {
|
||||
struct AddMachineRequest {
|
||||
name: String,
|
||||
/// Absent means this machine.
|
||||
#[serde(default)]
|
||||
@@ -410,11 +414,11 @@ struct ProbeRequest {
|
||||
ssh: Option<SshRequest>,
|
||||
}
|
||||
|
||||
async fn probe_setup(
|
||||
async fn probe_machine(
|
||||
axum::Json(body): axum::Json<ProbeRequest>,
|
||||
) -> Result<axum::Json<Vec<ProviderInfo>>, ApiError> {
|
||||
let ssh = body.ssh.map(SshRequest::into_config).transpose()?;
|
||||
let providers = probe(ssh, "this setup").await?;
|
||||
let providers = probe(ssh, "this machine").await?;
|
||||
Ok(axum::Json(
|
||||
providers
|
||||
.into_iter()
|
||||
@@ -443,50 +447,50 @@ async fn probe(
|
||||
},
|
||||
None => crate::session::transport::Transport::Here,
|
||||
};
|
||||
crate::setups::discover(&transport)
|
||||
crate::machines::discover(&transport)
|
||||
.await
|
||||
.map_err(bad_request)
|
||||
}
|
||||
|
||||
async fn add_setup(
|
||||
async fn add_machine(
|
||||
State(manager): State<Arc<SessionManager>>,
|
||||
axum::Json(body): axum::Json<AddSetupRequest>,
|
||||
) -> Result<axum::Json<SetupInfo>, ApiError> {
|
||||
axum::Json(body): axum::Json<AddMachineRequest>,
|
||||
) -> Result<axum::Json<MachineInfo>, ApiError> {
|
||||
let ssh = body.ssh.map(SshRequest::into_config).transpose()?;
|
||||
// Ask the machine being added what it has, before writing anything, so a
|
||||
// bad address fails here rather than leaving a setup that can never
|
||||
// bad address fails here rather than leaving a machine that can never
|
||||
// spawn.
|
||||
let providers = probe(ssh.clone(), &body.name).await?;
|
||||
let setup = manager
|
||||
.add_setup(&body.name, ssh, providers)
|
||||
let machine = manager
|
||||
.add_machine(&body.name, ssh, providers)
|
||||
.map_err(bad_request)?;
|
||||
Ok(axum::Json(info_for(setup)))
|
||||
Ok(axum::Json(info_for(machine)))
|
||||
}
|
||||
|
||||
/// One setup by id, or the 404 that says so. Three handlers ask this same
|
||||
/// One machine by id, or the 404 that says so. Three handlers ask this same
|
||||
/// question; the answer, and the wording of the refusal, belong in one place.
|
||||
fn setup_by_id(
|
||||
fn machine_by_id(
|
||||
manager: &Arc<SessionManager>,
|
||||
id: &str,
|
||||
) -> Result<crate::config::SetupConfig, ApiError> {
|
||||
) -> Result<crate::config::MachineConfig, ApiError> {
|
||||
manager
|
||||
.setups()
|
||||
.machines()
|
||||
.into_iter()
|
||||
.find(|setup| setup.id == id)
|
||||
.ok_or_else(|| ApiError::NotFound(format!("no setup {id}")))
|
||||
.find(|machine| machine.id == id)
|
||||
.ok_or_else(|| ApiError::NotFound(format!("no machine {id}")))
|
||||
}
|
||||
|
||||
async fn read_setup(
|
||||
async fn read_machine(
|
||||
State(manager): State<Arc<SessionManager>>,
|
||||
UrlPath(id): UrlPath<String>,
|
||||
) -> Result<axum::Json<SetupInfo>, ApiError> {
|
||||
setup_by_id(&manager, &id).map(|setup| axum::Json(info_for(setup)))
|
||||
) -> Result<axum::Json<MachineInfo>, ApiError> {
|
||||
machine_by_id(&manager, &id).map(|machine| axum::Json(info_for(machine)))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct UpdateSetupRequest {
|
||||
struct UpdateMachineRequest {
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
/// Ask the machine again what it has -- after installing something
|
||||
@@ -495,37 +499,37 @@ struct UpdateSetupRequest {
|
||||
rediscover: bool,
|
||||
}
|
||||
|
||||
async fn update_setup(
|
||||
async fn update_machine(
|
||||
State(manager): State<Arc<SessionManager>>,
|
||||
UrlPath(id): UrlPath<String>,
|
||||
axum::Json(body): axum::Json<UpdateSetupRequest>,
|
||||
) -> Result<axum::Json<SetupInfo>, ApiError> {
|
||||
axum::Json(body): axum::Json<UpdateMachineRequest>,
|
||||
) -> Result<axum::Json<MachineInfo>, ApiError> {
|
||||
let providers = if body.rediscover {
|
||||
let existing = manager
|
||||
.setups()
|
||||
.machines()
|
||||
.into_iter()
|
||||
.find(|setup| setup.id == id)
|
||||
.ok_or_else(|| ApiError::NotFound(format!("no setup {id}")))?;
|
||||
let transport = crate::session::transport::Transport::for_setup(&existing);
|
||||
.find(|machine| machine.id == id)
|
||||
.ok_or_else(|| ApiError::NotFound(format!("no machine {id}")))?;
|
||||
let transport = crate::session::transport::Transport::for_machine(&existing);
|
||||
Some(
|
||||
crate::setups::discover(&transport)
|
||||
crate::machines::discover(&transport)
|
||||
.await
|
||||
.map_err(bad_request)?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let setup = manager
|
||||
.update_setup(&id, body.name.as_deref(), providers)
|
||||
let machine = manager
|
||||
.update_machine(&id, body.name.as_deref(), providers)
|
||||
.map_err(bad_request)?;
|
||||
Ok(axum::Json(info_for(setup)))
|
||||
Ok(axum::Json(info_for(machine)))
|
||||
}
|
||||
|
||||
async fn delete_setup(
|
||||
async fn delete_machine(
|
||||
State(manager): State<Arc<SessionManager>>,
|
||||
UrlPath(id): UrlPath<String>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
manager.delete_setup(&id).map_err(bad_request)?;
|
||||
manager.delete_machine(&id).map_err(bad_request)?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
@@ -538,10 +542,10 @@ fn files_on(
|
||||
id: &str,
|
||||
path: &str,
|
||||
) -> Result<(crate::session::transport::Transport, String), ApiError> {
|
||||
let setup = setup_by_id(manager, id)?;
|
||||
let machine = machine_by_id(manager, id)?;
|
||||
let path = crate::files::check_path(path).map_err(bad_request)?;
|
||||
Ok((
|
||||
crate::session::transport::Transport::for_setup(&setup),
|
||||
crate::session::transport::Transport::for_machine(&machine),
|
||||
path,
|
||||
))
|
||||
}
|
||||
@@ -566,15 +570,15 @@ struct PathQuery {
|
||||
///
|
||||
/// Not `GET /models`, which is this backend's own downloads: those are on
|
||||
/// the machine a session runs on only when they are the same machine. A
|
||||
/// spawn screen offering this backend's list for a remote setup would be
|
||||
/// spawn screen offering this backend's list for a remote machine would be
|
||||
/// naming files that are not there, and the session would fail at the
|
||||
/// point of loading rather than at the point of choosing.
|
||||
async fn setup_models(
|
||||
async fn machine_models(
|
||||
State(manager): State<Arc<SessionManager>>,
|
||||
UrlPath(id): UrlPath<String>,
|
||||
) -> Result<axum::Json<Vec<crate::models::LocalModel>>, ApiError> {
|
||||
let setup = setup_by_id(&manager, &id)?;
|
||||
let transport = crate::session::transport::Transport::for_setup(&setup);
|
||||
let machine = machine_by_id(&manager, &id)?;
|
||||
let transport = crate::session::transport::Transport::for_machine(&machine);
|
||||
let dir = crate::models::dir_on(&transport, manager.models_dir());
|
||||
crate::models::on_machine(&transport, &dir)
|
||||
.await
|
||||
@@ -582,19 +586,19 @@ async fn setup_models(
|
||||
.map_err(from_machine)
|
||||
}
|
||||
|
||||
/// The models a CLI provider currently offers on the setup's machine.
|
||||
/// The models a CLI provider currently offers on its configured machine.
|
||||
/// Codex answers from its live account catalog; providers with a configured
|
||||
/// shortcut list return that list.
|
||||
async fn provider_models(
|
||||
State(manager): State<Arc<SessionManager>>,
|
||||
UrlPath((id, provider_name)): UrlPath<(String, String)>,
|
||||
) -> Result<axum::Json<Vec<String>>, ApiError> {
|
||||
let setup = setup_by_id(&manager, &id)?;
|
||||
let provider = setup.provider(&provider_name).ok_or_else(|| {
|
||||
ApiError::NotFound(format!("no provider {provider_name} on {}", setup.name))
|
||||
let machine = machine_by_id(&manager, &id)?;
|
||||
let provider = machine.provider(&provider_name).ok_or_else(|| {
|
||||
ApiError::NotFound(format!("no provider {provider_name} on {}", machine.name))
|
||||
})?;
|
||||
let transport = crate::session::transport::Transport::for_setup(&setup);
|
||||
crate::setups::provider_models(&transport, provider)
|
||||
let transport = crate::session::transport::Transport::for_machine(&machine);
|
||||
crate::machines::provider_models(&transport, provider)
|
||||
.await
|
||||
.map(axum::Json)
|
||||
.map_err(from_machine)
|
||||
@@ -704,7 +708,7 @@ async fn create_dir(
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct SpawnRequest {
|
||||
/// Which machine, and which of the things it offers.
|
||||
setup: String,
|
||||
machine: String,
|
||||
provider: String,
|
||||
#[serde(default)]
|
||||
title: Option<String>,
|
||||
@@ -721,7 +725,7 @@ struct SpawnRequest {
|
||||
#[serde(default)]
|
||||
params: std::collections::BTreeMap<String, String>,
|
||||
/// Continue a Claude Code session the machine already has, named by the id
|
||||
/// `GET /setups/{id}/importable` reported.
|
||||
/// `GET /machines/{id}/importable` reported.
|
||||
///
|
||||
/// An id and not a path, deliberately: the server looks the path up again
|
||||
/// among the sessions it enumerated, so an enrolled token cannot turn this
|
||||
@@ -735,8 +739,8 @@ async fn list_importable(
|
||||
State(manager): State<Arc<SessionManager>>,
|
||||
UrlPath(id): UrlPath<String>,
|
||||
) -> Result<axum::Json<Vec<ImportableRow>>, ApiError> {
|
||||
let setup = setup_by_id(&manager, &id)?;
|
||||
let transport = crate::session::transport::Transport::for_setup(&setup);
|
||||
let machine = machine_by_id(&manager, &id)?;
|
||||
let transport = crate::session::transport::Transport::for_machine(&machine);
|
||||
let mut found = crate::session::import::list(&transport)
|
||||
.await
|
||||
.map_err(bad_request)?;
|
||||
@@ -814,8 +818,8 @@ async fn delete_importable(
|
||||
UrlPath(id): UrlPath<String>,
|
||||
axum::Json(body): axum::Json<DeleteBatch>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let setup = setup_by_id(&manager, &id)?;
|
||||
let transport = crate::session::transport::Transport::for_setup(&setup);
|
||||
let machine = machine_by_id(&manager, &id)?;
|
||||
let transport = crate::session::transport::Transport::for_machine(&machine);
|
||||
// Registered before anything is spawned, so the 202 is only sent once
|
||||
// every row is already showing "deleting" -- a phone that refetches the
|
||||
// instant it gets the reply cannot catch a row that has not started.
|
||||
@@ -892,10 +896,10 @@ async fn start_import(
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
// Checked before accepting, so an unknown machine is an error the caller
|
||||
// sees rather than one it has to go and read off a row.
|
||||
setup_by_id(&manager, &id)?;
|
||||
machine_by_id(&manager, &id)?;
|
||||
for session in body.sessions {
|
||||
let request = SpawnRequest {
|
||||
setup: id.clone(),
|
||||
machine: id.clone(),
|
||||
provider: body.provider.clone(),
|
||||
// Nothing to say: `spawn` titles an import from the session it
|
||||
// continues, and the cwd comes from the same place.
|
||||
@@ -946,22 +950,25 @@ struct ImportRequest {
|
||||
/// is the pending registry.
|
||||
fn in_background<F>(
|
||||
manager: &Arc<SessionManager>,
|
||||
setup: String,
|
||||
machine: String,
|
||||
session: String,
|
||||
operation: Operation,
|
||||
work: F,
|
||||
) where
|
||||
F: std::future::Future<Output = anyhow::Result<()>> + Send + 'static,
|
||||
{
|
||||
let running = manager.pending().begin(&setup, &session, operation);
|
||||
let running = manager.pending().begin(&machine, &session, operation);
|
||||
tokio::spawn(async move {
|
||||
match work.await {
|
||||
Ok(()) => {
|
||||
tracing::info!("{} {session} on {setup}: done", operation.label());
|
||||
tracing::info!("{} {session} on {machine}: done", operation.label());
|
||||
running.succeeded();
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!("{} {session} on {setup} failed: {err:#}", operation.label());
|
||||
tracing::warn!(
|
||||
"{} {session} on {machine} failed: {err:#}",
|
||||
operation.label()
|
||||
);
|
||||
// The server's own words, the way every other failure in this
|
||||
// app reaches a person.
|
||||
running.failed(format!("{err:#}"));
|
||||
@@ -970,7 +977,7 @@ fn in_background<F>(
|
||||
});
|
||||
}
|
||||
|
||||
/// Every change to what is in flight against one machine. Scoped to the setup
|
||||
/// Every change to what is in flight against one machine. Scoped to the machine
|
||||
/// the screen is showing, the same way a session's events are scoped to that
|
||||
/// session.
|
||||
async fn importable_events(
|
||||
@@ -983,7 +990,7 @@ async fn importable_events(
|
||||
// that is what the listing is for: the screen refetches on arrival and
|
||||
// carries the truth whatever this stream missed.
|
||||
let change = item.ok()?;
|
||||
if change.setup() != id {
|
||||
if change.machine() != id {
|
||||
return None;
|
||||
}
|
||||
Some(Ok(SseEvent::default().json_data(&change).ok()?))
|
||||
@@ -1010,15 +1017,15 @@ async fn spawn(manager: &Arc<SessionManager>, body: SpawnRequest) -> Result<Sess
|
||||
// not the phone's: which file that id names, and what is in it.
|
||||
let seed = match &body.import {
|
||||
Some(want) => {
|
||||
let setup = setup_by_id(manager, &body.setup)?;
|
||||
let transport = crate::session::transport::Transport::for_setup(&setup);
|
||||
let machine = machine_by_id(manager, &body.machine)?;
|
||||
let transport = crate::session::transport::Transport::for_machine(&machine);
|
||||
let chosen = crate::session::import::find(&transport, want)
|
||||
.await
|
||||
.map_err(bad_request)?
|
||||
.ok_or_else(|| {
|
||||
ApiError::NotFound(format!(
|
||||
"setup \"{}\" has no Claude Code session {want} to import",
|
||||
body.setup
|
||||
"machine \"{}\" has no Claude Code session {want} to import",
|
||||
body.machine
|
||||
))
|
||||
})?;
|
||||
if let Some(existing) = manager.session_driving(want) {
|
||||
@@ -1065,7 +1072,7 @@ async fn spawn(manager: &Arc<SessionManager>, body: SpawnRequest) -> Result<Sess
|
||||
};
|
||||
|
||||
let spec = SpawnSpec {
|
||||
setup: body.setup,
|
||||
machine: body.machine,
|
||||
provider: body.provider,
|
||||
// An imported session is recognised by what it was about, so its
|
||||
// opening message is the title unless one was typed. Blank normalised
|
||||
@@ -1155,8 +1162,8 @@ async fn delete_session(
|
||||
// everything as it was rather than a deleted session and a transcript the
|
||||
// phone has already promised is gone.
|
||||
if let Some(foreign) = &foreign {
|
||||
let setup = setup_by_id(&manager, &foreign.setup)?;
|
||||
let transport = crate::session::transport::Transport::for_setup(&setup);
|
||||
let machine = machine_by_id(&manager, &foreign.machine)?;
|
||||
let transport = crate::session::transport::Transport::for_machine(&machine);
|
||||
foreign.delete(&transport).await.map_err(bad_request)?;
|
||||
tracing::info!(
|
||||
"deleted {} session {} with ai-app session {id}",
|
||||
@@ -1307,15 +1314,133 @@ pub fn usage_router(
|
||||
.with_state(UsageState { monitor, manager })
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ProviderAuthState {
|
||||
logins: Arc<crate::provider_auth::LoginManager>,
|
||||
manager: Arc<SessionManager>,
|
||||
}
|
||||
|
||||
/// Separate state from the session routes because login attempts are
|
||||
/// short-lived provider processes, not conversations to persist or adopt.
|
||||
pub fn provider_auth_router(
|
||||
logins: Arc<crate::provider_auth::LoginManager>,
|
||||
manager: Arc<SessionManager>,
|
||||
) -> Router {
|
||||
Router::new()
|
||||
.route(
|
||||
"/machines/{id}/providers/{provider}/auth",
|
||||
post(start_provider_auth),
|
||||
)
|
||||
.route(
|
||||
"/machines/{id}/providers/{provider}/auth/{attempt}",
|
||||
get(read_provider_auth).delete(cancel_provider_auth),
|
||||
)
|
||||
.route(
|
||||
"/machines/{id}/providers/{provider}/auth/{attempt}/code",
|
||||
post(submit_provider_auth_code),
|
||||
)
|
||||
.with_state(ProviderAuthState { logins, manager })
|
||||
}
|
||||
|
||||
fn provider_auth_target(
|
||||
manager: &Arc<SessionManager>,
|
||||
machine_id: &str,
|
||||
provider_name: &str,
|
||||
) -> Result<
|
||||
(
|
||||
crate::config::MachineConfig,
|
||||
crate::config::ProviderConfig,
|
||||
&'static str,
|
||||
),
|
||||
ApiError,
|
||||
> {
|
||||
let machine = machine_by_id(manager, machine_id)?;
|
||||
let provider = machine.provider(provider_name).cloned().ok_or_else(|| {
|
||||
ApiError::NotFound(format!("no provider {provider_name} on {}", machine.name))
|
||||
})?;
|
||||
let key = provider.kind.usage_provider().ok_or_else(|| {
|
||||
ApiError::BadRequest(format!(
|
||||
"{} does not have an account to sign in to",
|
||||
provider.name
|
||||
))
|
||||
})?;
|
||||
if key != crate::usage::CLAUDE {
|
||||
return Err(ApiError::BadRequest(format!(
|
||||
"{} does not support sign-in through the app",
|
||||
provider.name
|
||||
)));
|
||||
}
|
||||
Ok((machine, provider, key))
|
||||
}
|
||||
|
||||
async fn start_provider_auth(
|
||||
State(state): State<ProviderAuthState>,
|
||||
UrlPath((machine_id, provider_name)): UrlPath<(String, String)>,
|
||||
) -> Result<axum::Json<crate::provider_auth::LoginInfo>, ApiError> {
|
||||
let (machine, provider, key) =
|
||||
provider_auth_target(&state.manager, &machine_id, &provider_name)?;
|
||||
let info = state.logins.start(machine, provider);
|
||||
let logins = Arc::clone(&state.logins);
|
||||
let info = tokio::task::spawn_blocking(move || {
|
||||
logins.wait_until_ready(&machine_id, key, &info.attempt)
|
||||
})
|
||||
.await
|
||||
.context("provider sign-in worker panicked")?;
|
||||
Ok(axum::Json(info))
|
||||
}
|
||||
|
||||
async fn read_provider_auth(
|
||||
State(state): State<ProviderAuthState>,
|
||||
UrlPath((machine_id, provider_name, attempt)): UrlPath<(String, String, String)>,
|
||||
) -> Result<axum::Json<crate::provider_auth::LoginInfo>, ApiError> {
|
||||
let (_, _, key) = provider_auth_target(&state.manager, &machine_id, &provider_name)?;
|
||||
state
|
||||
.logins
|
||||
.read(&machine_id, key, &attempt)
|
||||
.map(axum::Json)
|
||||
.ok_or_else(|| ApiError::NotFound("no such sign-in attempt".to_string()))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct ProviderAuthCode {
|
||||
code: String,
|
||||
}
|
||||
|
||||
async fn submit_provider_auth_code(
|
||||
State(state): State<ProviderAuthState>,
|
||||
UrlPath((machine_id, provider_name, attempt)): UrlPath<(String, String, String)>,
|
||||
axum::Json(body): axum::Json<ProviderAuthCode>,
|
||||
) -> Result<axum::Json<crate::provider_auth::LoginInfo>, ApiError> {
|
||||
let (_, _, key) = provider_auth_target(&state.manager, &machine_id, &provider_name)?;
|
||||
state
|
||||
.logins
|
||||
.submit(&machine_id, key, &attempt, &body.code)
|
||||
.map(axum::Json)
|
||||
.map_err(bad_request)
|
||||
}
|
||||
|
||||
async fn cancel_provider_auth(
|
||||
State(state): State<ProviderAuthState>,
|
||||
UrlPath((machine_id, provider_name, attempt)): UrlPath<(String, String, String)>,
|
||||
) -> Result<axum::Json<crate::provider_auth::LoginInfo>, ApiError> {
|
||||
let (_, _, key) = provider_auth_target(&state.manager, &machine_id, &provider_name)?;
|
||||
state
|
||||
.logins
|
||||
.cancel(&machine_id, key, &attempt)
|
||||
.map(axum::Json)
|
||||
.map_err(bad_request)
|
||||
}
|
||||
|
||||
async fn usage(
|
||||
State(state): State<UsageState>,
|
||||
) -> Result<axum::Json<Vec<crate::usage::UsageSnapshot>>, ApiError> {
|
||||
// Read here rather than inside the fetch, so the list of machines is the
|
||||
// one that existed when the request arrived and cannot change under a
|
||||
// fetch that takes an ssh round trip per machine.
|
||||
let setups = state.manager.setups();
|
||||
let machines = state.manager.machines();
|
||||
// The fetch is blocking by design (see `usage`); off the workers.
|
||||
let snapshots = tokio::task::spawn_blocking(move || state.monitor.snapshots(&setups))
|
||||
let snapshots = tokio::task::spawn_blocking(move || state.monitor.snapshots(&machines))
|
||||
.await
|
||||
.context("usage fetch panicked")?;
|
||||
Ok(axum::Json(snapshots))
|
||||
@@ -1348,7 +1473,7 @@ struct CwdRequest {
|
||||
/// Moves a session to a different working directory.
|
||||
///
|
||||
/// The directory is checked here rather than in the manager because checking
|
||||
/// it is an ssh round trip on a remote setup, and the manager is not async.
|
||||
/// it is an ssh round trip on a remote machine, and the manager is not async.
|
||||
///
|
||||
/// Checked rather than trusted, and refused rather than corrected: a mistyped
|
||||
/// path that was accepted would leave a session recorded somewhere its process
|
||||
@@ -1372,20 +1497,20 @@ async fn set_cwd(
|
||||
// launched from, which is not something the person typing it can see. The
|
||||
// same question the explorer asks of every path, asked in one place.
|
||||
let cwd = crate::files::check_path(&body.cwd.to_string_lossy()).map_err(bad_request)?;
|
||||
let setup = setup_by_id(&manager, &session.setup)?;
|
||||
let transport = crate::session::transport::Transport::for_setup(&setup);
|
||||
let machine = machine_by_id(&manager, &session.machine)?;
|
||||
let transport = crate::session::transport::Transport::for_machine(&machine);
|
||||
if !crate::session::import::directory_exists(&transport, &cwd).await {
|
||||
return Err(ApiError::BadRequest(format!(
|
||||
"{} has no directory {cwd}",
|
||||
setup.name
|
||||
machine.name
|
||||
)));
|
||||
}
|
||||
// Stored in the short form, so the one path kept is the one the phone will
|
||||
// draw -- rather than storing `/home/bob/…` and abbreviating it again at
|
||||
// each place it is shown, which is two representations of one directory.
|
||||
// Only where the setup runs here; see `setups::shorten_home`.
|
||||
let stored = if setup.ssh.is_none() {
|
||||
crate::setups::shorten_home(&cwd)
|
||||
// Only where the machine runs here; see `machines::shorten_home`.
|
||||
let stored = if machine.ssh.is_none() {
|
||||
crate::machines::shorten_home(&cwd)
|
||||
} else {
|
||||
cwd.clone()
|
||||
};
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
//!
|
||||
//! **The phone never names a file.** It picks an id out of what this
|
||||
//! module enumerated, and the path is looked up again on the server -- the
|
||||
//! same rule the setups model follows for providers, and for the same
|
||||
//! same rule the machines model follows for providers, and for the same
|
||||
//! reason: an enrolled token must not be able to turn into "read me this
|
||||
//! arbitrary path".
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
//! 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 remote setup
|
||||
//! **The model file is the far machine's, not this one's.** A remote machine
|
||||
//! 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
|
||||
|
||||
+117
-115
@@ -30,8 +30,8 @@ use serde::Serialize;
|
||||
use tokio::sync::{broadcast, mpsc};
|
||||
|
||||
use crate::config::{
|
||||
Config, DEFAULT_RESUME_MESSAGE, DriverKind, ProviderConfig, ScheduledResume, SessionConfig,
|
||||
SetupConfig, SshConfig, TokenEntry,
|
||||
Config, DEFAULT_RESUME_MESSAGE, DriverKind, MachineConfig, ProviderConfig, ScheduledResume,
|
||||
SessionConfig, SshConfig, TokenEntry,
|
||||
};
|
||||
use claude::ClaudeDriver;
|
||||
use codex::CodexDriver;
|
||||
@@ -100,7 +100,7 @@ pub fn now() -> f64 {
|
||||
}
|
||||
|
||||
pub struct SpawnSpec {
|
||||
pub setup: String,
|
||||
pub machine: String,
|
||||
pub provider: String,
|
||||
pub title: Option<String>,
|
||||
pub model: Option<String>,
|
||||
@@ -172,7 +172,7 @@ impl AutoResumeView {
|
||||
pub struct OwedResume {
|
||||
pub session_id: String,
|
||||
/// The machine whose account ran out, which is the one to ask.
|
||||
pub setup: String,
|
||||
pub machine: String,
|
||||
/// Which meter reports on it -- a `crate::usage::UsageProvider::name`, the
|
||||
/// same pairing `SessionInfo::usage_provider` uses.
|
||||
pub provider: &'static str,
|
||||
@@ -193,11 +193,11 @@ fn resume_message(meta: &SessionConfig) -> String {
|
||||
pub struct SessionInfo {
|
||||
pub id: String,
|
||||
pub provider: String,
|
||||
pub setup: String,
|
||||
pub machine: String,
|
||||
/// That machine's current label, resolved when this row is built, so
|
||||
/// renaming a setup renames it everywhere rather than leaving old
|
||||
/// renaming a machine renames it everywhere rather than leaving old
|
||||
/// sessions showing the old name.
|
||||
pub setup_name: String,
|
||||
pub machine_name: String,
|
||||
pub title: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
@@ -562,7 +562,7 @@ impl LiveSession {
|
||||
Ok((name, path))
|
||||
}
|
||||
|
||||
/// `setup_name` and `cwd` are passed in rather than read from the
|
||||
/// `machine_name` and `cwd` are passed in rather than read from the
|
||||
/// snapshot this session launched with: only the manager holds the
|
||||
/// config, and both can change under a running session. Passed rather
|
||||
/// than mirrored into `Shared`, so there is one answer, read where the
|
||||
@@ -574,7 +574,7 @@ impl LiveSession {
|
||||
/// cautious one.
|
||||
fn info(
|
||||
&self,
|
||||
setup_name: &str,
|
||||
machine_name: &str,
|
||||
cwd: Option<&Path>,
|
||||
effort: Option<&str>,
|
||||
imported: bool,
|
||||
@@ -584,8 +584,8 @@ impl LiveSession {
|
||||
SessionInfo {
|
||||
id: self.meta.id.clone(),
|
||||
provider: self.meta.provider.clone(),
|
||||
setup: self.meta.setup.clone(),
|
||||
setup_name: setup_name.to_string(),
|
||||
machine: self.meta.machine.clone(),
|
||||
machine_name: machine_name.to_string(),
|
||||
title: self.shared.title.lock().unwrap().clone(),
|
||||
model: self.shared.model.lock().unwrap().clone(),
|
||||
permission_mode: self.shared.permission_mode.lock().unwrap().clone(),
|
||||
@@ -646,7 +646,7 @@ pub struct SessionManager {
|
||||
/// The CLI-owned copy optionally removed with an ai-app session.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct ForeignTranscript {
|
||||
pub setup: String,
|
||||
pub machine: String,
|
||||
pub id: String,
|
||||
kind: DriverKind,
|
||||
}
|
||||
@@ -702,10 +702,10 @@ impl SessionManager {
|
||||
// One unlaunchable session -- a corrupt transcript, an
|
||||
// unreachable host, a provider edited away -- shows as exited
|
||||
// rather than taking the server down, and can still be deleted.
|
||||
match resolve(&config, meta).and_then(|(setup, provider)| {
|
||||
match resolve(&config, meta).and_then(|(machine, provider)| {
|
||||
launch(
|
||||
meta.clone(),
|
||||
&setup,
|
||||
&machine,
|
||||
&provider,
|
||||
Env {
|
||||
data_dir: &data_dir,
|
||||
@@ -777,7 +777,7 @@ impl SessionManager {
|
||||
self
|
||||
}
|
||||
|
||||
/// Writes this machine into a config that has no setups, with the
|
||||
/// Writes this machine into a config that has no machines, with the
|
||||
/// providers actually found on it.
|
||||
///
|
||||
/// Discovered rather than assumed. This used to write a `claude-cli`
|
||||
@@ -789,16 +789,16 @@ impl SessionManager {
|
||||
/// this server runs, and says so in the log. Seeding the hardcoded list
|
||||
/// would be the original bug with an extra step, and seeding nothing
|
||||
/// leaves a fresh install with nothing to prove the pipe with.
|
||||
pub async fn seed_setup(&self) -> Result<()> {
|
||||
if !self.inner.read().unwrap().config.setups.is_empty() {
|
||||
pub async fn seed_machine(&self) -> Result<()> {
|
||||
if !self.inner.read().unwrap().config.machines.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let providers = match crate::setups::discover(&transport::Transport::Here).await {
|
||||
let providers = match crate::machines::discover(&transport::Transport::Here).await {
|
||||
Ok(found) => found,
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
"couldn't ask this machine what it has ({err}); seeding {} only -- \
|
||||
re-probe the setup from the app once that is fixed",
|
||||
re-probe the machine from the app once that is fixed",
|
||||
crate::config::ECHO_PROVIDER
|
||||
);
|
||||
vec![Config::echo_provider()]
|
||||
@@ -807,16 +807,16 @@ impl SessionManager {
|
||||
let names: Vec<&str> = providers.iter().map(|p| p.name.as_str()).collect();
|
||||
|
||||
let mut inner = self.inner.write().unwrap();
|
||||
if !inner.config.setups.is_empty() {
|
||||
if !inner.config.machines.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut candidate = inner.config.clone();
|
||||
candidate.setups.push(Config::seed(providers.clone()));
|
||||
candidate.machines.push(Config::seed(providers.clone()));
|
||||
candidate.save(&self.config_path)?;
|
||||
inner.config = candidate;
|
||||
tracing::info!(
|
||||
"no setups configured -- added \"{}\" with: {}",
|
||||
crate::config::LOCAL_SETUP,
|
||||
"no machines configured -- added \"{}\" with: {}",
|
||||
crate::config::LOCAL_MACHINE,
|
||||
names.join(", ")
|
||||
);
|
||||
Ok(())
|
||||
@@ -839,80 +839,80 @@ impl SessionManager {
|
||||
///
|
||||
/// `providers` comes from probing rather than from the caller: the
|
||||
/// probe is async and this is not, so the route asks and this writes.
|
||||
pub fn add_setup(
|
||||
pub fn add_machine(
|
||||
&self,
|
||||
name: &str,
|
||||
ssh: Option<SshConfig>,
|
||||
providers: Vec<ProviderConfig>,
|
||||
) -> Result<SetupConfig> {
|
||||
) -> Result<MachineConfig> {
|
||||
let name = name.trim().to_string();
|
||||
if name.is_empty() {
|
||||
bail!("a setup needs a name");
|
||||
bail!("a machine needs a name");
|
||||
}
|
||||
self.update(|config| {
|
||||
if config.setup_named(&name).is_some() {
|
||||
bail!("there is already a setup called \"{name}\"");
|
||||
if config.machine_named(&name).is_some() {
|
||||
bail!("there is already a machine called \"{name}\"");
|
||||
}
|
||||
// Ids are derived once and then fixed, so a label can be
|
||||
// edited later without orphaning the sessions that named it.
|
||||
let mut id = crate::setups::id_from(&name);
|
||||
while config.setup(&id).is_some() {
|
||||
let mut id = crate::machines::id_from(&name);
|
||||
while config.machine(&id).is_some() {
|
||||
id = format!("{id}-{}", &random_hex()[..4]);
|
||||
}
|
||||
let setup = SetupConfig {
|
||||
let machine = MachineConfig {
|
||||
id,
|
||||
name: name.clone(),
|
||||
ssh,
|
||||
providers,
|
||||
};
|
||||
config.setups.push(setup.clone());
|
||||
Ok(setup)
|
||||
config.machines.push(machine.clone());
|
||||
Ok(machine)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn update_setup(
|
||||
pub fn update_machine(
|
||||
&self,
|
||||
id: &str,
|
||||
name: Option<&str>,
|
||||
providers: Option<Vec<ProviderConfig>>,
|
||||
) -> Result<SetupConfig> {
|
||||
) -> Result<MachineConfig> {
|
||||
self.update(|config| {
|
||||
if let Some(name) = name {
|
||||
let name = name.trim();
|
||||
if name.is_empty() {
|
||||
bail!("a setup needs a name");
|
||||
bail!("a machine needs a name");
|
||||
}
|
||||
if config.setups.iter().any(|s| s.name == name && s.id != id) {
|
||||
bail!("there is already a setup called \"{name}\"");
|
||||
if config.machines.iter().any(|s| s.name == name && s.id != id) {
|
||||
bail!("there is already a machine called \"{name}\"");
|
||||
}
|
||||
}
|
||||
let setup = config
|
||||
.setups
|
||||
let machine = config
|
||||
.machines
|
||||
.iter_mut()
|
||||
.find(|setup| setup.id == id)
|
||||
.with_context(|| format!("no setup with id \"{id}\""))?;
|
||||
.find(|machine| machine.id == id)
|
||||
.with_context(|| format!("no machine with id \"{id}\""))?;
|
||||
if let Some(name) = name {
|
||||
setup.name = name.trim().to_string();
|
||||
machine.name = name.trim().to_string();
|
||||
}
|
||||
if let Some(providers) = providers {
|
||||
setup.providers = providers;
|
||||
machine.providers = providers;
|
||||
}
|
||||
Ok(setup.clone())
|
||||
Ok(machine.clone())
|
||||
})
|
||||
}
|
||||
|
||||
/// Removes a machine, provided nothing is still running on it.
|
||||
/// Refused rather than cascaded: the person asking is better placed to
|
||||
/// decide which of those sessions they still want.
|
||||
pub fn delete_setup(&self, id: &str) -> Result<()> {
|
||||
pub fn delete_machine(&self, id: &str) -> Result<()> {
|
||||
self.update(|config| {
|
||||
if config.setup(id).is_none() {
|
||||
bail!("no setup with id \"{id}\"");
|
||||
if config.machine(id).is_none() {
|
||||
bail!("no machine with id \"{id}\"");
|
||||
}
|
||||
let using: Vec<&str> = config
|
||||
.sessions
|
||||
.iter()
|
||||
.filter(|session| session.setup == id)
|
||||
.filter(|session| session.machine == id)
|
||||
.map(|session| session.title.as_str())
|
||||
.collect();
|
||||
if !using.is_empty() {
|
||||
@@ -922,7 +922,7 @@ impl SessionManager {
|
||||
using.join(", "),
|
||||
);
|
||||
}
|
||||
config.setups.retain(|setup| setup.id != id);
|
||||
config.machines.retain(|machine| machine.id != id);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
@@ -1061,18 +1061,18 @@ impl SessionManager {
|
||||
pub fn remote_of(&self, id: &str) -> Option<(crate::config::SshConfig, Option<PathBuf>)> {
|
||||
let inner = self.inner.read().unwrap();
|
||||
let meta = inner.config.sessions.iter().find(|meta| meta.id == id)?;
|
||||
let setup = inner
|
||||
let machine = inner
|
||||
.config
|
||||
.setups
|
||||
.machines
|
||||
.iter()
|
||||
.find(|setup| setup.id == meta.setup)?;
|
||||
Some((setup.ssh.clone()?, meta.cwd.clone()))
|
||||
.find(|machine| machine.id == meta.machine)?;
|
||||
Some((machine.ssh.clone()?, meta.cwd.clone()))
|
||||
}
|
||||
|
||||
pub fn foreign_transcript(&self, id: &str) -> Option<ForeignTranscript> {
|
||||
let inner = self.inner.read().unwrap();
|
||||
let meta = inner.config.sessions.iter().find(|meta| meta.id == id)?;
|
||||
let kind = kind_of(&inner.config, &meta.setup, &meta.provider)?;
|
||||
let kind = kind_of(&inner.config, &meta.machine, &meta.provider)?;
|
||||
let session_dir = self.data_dir.join(&meta.id);
|
||||
let foreign = match kind {
|
||||
DriverKind::ClaudeCli => {
|
||||
@@ -1085,7 +1085,7 @@ impl SessionManager {
|
||||
DriverKind::Echo | DriverKind::LlamaCpp => None,
|
||||
}?;
|
||||
Some(ForeignTranscript {
|
||||
setup: meta.setup.clone(),
|
||||
machine: meta.machine.clone(),
|
||||
id: foreign,
|
||||
kind,
|
||||
})
|
||||
@@ -1101,28 +1101,28 @@ impl SessionManager {
|
||||
.iter()
|
||||
.map(|meta| match inner.live.get(&meta.id) {
|
||||
Some(session) => session.info(
|
||||
label_of(&inner.config, &meta.setup),
|
||||
label_of(&inner.config, &meta.machine),
|
||||
meta.cwd.as_deref(),
|
||||
meta.effort.as_deref(),
|
||||
import::read_cursor(&self.data_dir.join(&meta.id)).is_some(),
|
||||
kind_of(&inner.config, &meta.setup, &meta.provider),
|
||||
kind_of(&inner.config, &meta.machine, &meta.provider),
|
||||
AutoResumeView::of(meta),
|
||||
),
|
||||
None => SessionInfo {
|
||||
id: meta.id.clone(),
|
||||
setup: meta.setup.clone(),
|
||||
setup_name: label_of(&inner.config, &meta.setup).to_string(),
|
||||
machine: meta.machine.clone(),
|
||||
machine_name: label_of(&inner.config, &meta.machine).to_string(),
|
||||
provider: meta.provider.clone(),
|
||||
title: meta.title.clone(),
|
||||
model: meta.model.clone(),
|
||||
permission_mode: meta.permission_mode.clone(),
|
||||
effort: meta.effort.clone(),
|
||||
takes_effort: kind_of(&inner.config, &meta.setup, &meta.provider)
|
||||
takes_effort: kind_of(&inner.config, &meta.machine, &meta.provider)
|
||||
.is_some_and(DriverKind::takes_effort),
|
||||
context_tokens: None,
|
||||
max_image_edge: kind_of(&inner.config, &meta.setup, &meta.provider)
|
||||
max_image_edge: kind_of(&inner.config, &meta.machine, &meta.provider)
|
||||
.and_then(DriverKind::max_image_edge),
|
||||
usage_provider: kind_of(&inner.config, &meta.setup, &meta.provider)
|
||||
usage_provider: kind_of(&inner.config, &meta.machine, &meta.provider)
|
||||
.and_then(DriverKind::usage_provider),
|
||||
notify: meta.notify,
|
||||
auto_resume: meta.auto_resume,
|
||||
@@ -1131,10 +1131,10 @@ impl SessionManager {
|
||||
imported: import::read_cursor(&self.data_dir.join(&meta.id)).is_some(),
|
||||
keeps_own_transcript: keeps_own_transcript(
|
||||
&inner.config,
|
||||
&meta.setup,
|
||||
&meta.machine,
|
||||
&meta.provider,
|
||||
),
|
||||
own_transcript_name: kind_of(&inner.config, &meta.setup, &meta.provider)
|
||||
own_transcript_name: kind_of(&inner.config, &meta.machine, &meta.provider)
|
||||
.and_then(DriverKind::own_transcript_name),
|
||||
cwd: meta.cwd.clone(),
|
||||
status: status_of_unlaunched(&self.data_dir.join(&meta.id)),
|
||||
@@ -1172,8 +1172,8 @@ impl SessionManager {
|
||||
|
||||
/// Every machine this server can run something on, each with what it
|
||||
/// can run. One list rather than two, because the pair is the choice.
|
||||
pub fn setups(&self) -> Vec<SetupConfig> {
|
||||
self.inner.read().unwrap().config.setups.clone()
|
||||
pub fn machines(&self) -> Vec<MachineConfig> {
|
||||
self.inner.read().unwrap().config.machines.clone()
|
||||
}
|
||||
|
||||
pub fn spawn_session(&self, spec: SpawnSpec) -> Result<SessionInfo> {
|
||||
@@ -1191,28 +1191,28 @@ impl SessionManager {
|
||||
|
||||
fn spawn_seeded(&self, spec: SpawnSpec, seed: Option<Seed>) -> Result<SessionInfo> {
|
||||
let mut inner = self.inner.write().unwrap();
|
||||
let setup = inner
|
||||
let machine = inner
|
||||
.config
|
||||
.setup(&spec.setup)
|
||||
.machine(&spec.machine)
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"no setup with id \"{}\" -- configured: {}",
|
||||
spec.setup,
|
||||
"no machine with id \"{}\" -- configured: {}",
|
||||
spec.machine,
|
||||
// Ids, since that is what was looked up. Labels made
|
||||
// the failure read as a contradiction: "no setup named
|
||||
// the failure read as a contradiction: "no machine named
|
||||
// X -- configured: X".
|
||||
names(inner.config.setups.iter().map(|s| s.id.as_str())),
|
||||
names(inner.config.machines.iter().map(|s| s.id.as_str())),
|
||||
)
|
||||
})?
|
||||
.clone();
|
||||
let provider = setup
|
||||
let provider = machine
|
||||
.provider(&spec.provider)
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"setup \"{}\" has no provider named \"{}\" -- it offers: {}",
|
||||
spec.setup,
|
||||
"machine \"{}\" has no provider named \"{}\" -- it offers: {}",
|
||||
spec.machine,
|
||||
spec.provider,
|
||||
names(setup.providers.iter().map(|p| p.name.as_str())),
|
||||
names(machine.providers.iter().map(|p| p.name.as_str())),
|
||||
)
|
||||
})?
|
||||
.clone();
|
||||
@@ -1223,7 +1223,7 @@ impl SessionManager {
|
||||
.unwrap_or_else(|| format!("{} session", provider.name));
|
||||
let meta = SessionConfig {
|
||||
id: id.clone(),
|
||||
setup: setup.id.clone(),
|
||||
machine: machine.id.clone(),
|
||||
provider: provider.name.clone(),
|
||||
title,
|
||||
// No model unless one was chosen. This used to fall back to the
|
||||
@@ -1267,7 +1267,7 @@ impl SessionManager {
|
||||
|
||||
let session = launch(
|
||||
meta.clone(),
|
||||
&setup,
|
||||
&machine,
|
||||
&provider,
|
||||
self.env(),
|
||||
self.announce.clone(),
|
||||
@@ -1286,7 +1286,7 @@ impl SessionManager {
|
||||
// Whether this one was seeded, the same question the listing asks
|
||||
// of the directory a moment later.
|
||||
let info = session.info(
|
||||
&setup.name,
|
||||
&machine.name,
|
||||
session.meta.cwd.as_deref(),
|
||||
session.meta.effort.as_deref(),
|
||||
import::read_cursor(&self.data_dir.join(&id)).is_some(),
|
||||
@@ -1442,8 +1442,8 @@ impl SessionManager {
|
||||
let scheduled = meta.resume?;
|
||||
Some(OwedResume {
|
||||
session_id: meta.id.clone(),
|
||||
setup: meta.setup.clone(),
|
||||
provider: kind_of(&inner.config, &meta.setup, &meta.provider)?
|
||||
machine: meta.machine.clone(),
|
||||
provider: kind_of(&inner.config, &meta.machine, &meta.provider)?
|
||||
.usage_provider()?,
|
||||
scheduled,
|
||||
})
|
||||
@@ -1628,7 +1628,7 @@ impl SessionManager {
|
||||
/// it entirely.
|
||||
///
|
||||
/// Whether the directory exists is the caller's question, because
|
||||
/// asking it is an ssh round trip on a remote setup; see the route.
|
||||
/// asking it is an ssh round trip on a remote machine; see the route.
|
||||
pub fn set_session_cwd(&self, id: &str, cwd: PathBuf) -> Result<()> {
|
||||
{
|
||||
let mut inner = self.inner.write().unwrap();
|
||||
@@ -1889,7 +1889,7 @@ impl SessionManager {
|
||||
// Fresh from the config, like every other launch: a model or a
|
||||
// permission mode changed while the session was stopped is what it
|
||||
// starts with.
|
||||
let (setup, provider) = resolve(&inner.config, &meta)?;
|
||||
let (machine, provider) = resolve(&inner.config, &meta)?;
|
||||
match existing {
|
||||
Some(session) => {
|
||||
// Replacing the value a driver lives in does not end the
|
||||
@@ -1901,7 +1901,7 @@ impl SessionManager {
|
||||
}
|
||||
*session.driver.lock().unwrap() = Some(make_driver(
|
||||
&meta,
|
||||
&setup,
|
||||
&machine,
|
||||
&provider,
|
||||
self.env(),
|
||||
session.dir(),
|
||||
@@ -1915,7 +1915,7 @@ impl SessionManager {
|
||||
None => {
|
||||
let session = launch(
|
||||
meta,
|
||||
&setup,
|
||||
&machine,
|
||||
&provider,
|
||||
self.env(),
|
||||
self.announce.clone(),
|
||||
@@ -2050,24 +2050,26 @@ fn adoptable(session_dir: &Path) -> bool {
|
||||
/// The provider and host a session's config names, or a message saying
|
||||
/// which one is missing. Both are looked up fresh at every launch, so
|
||||
/// editing either takes effect on the next respawn.
|
||||
fn resolve(config: &Config, meta: &SessionConfig) -> Result<(SetupConfig, ProviderConfig)> {
|
||||
let setup = config
|
||||
.setup(&meta.setup)
|
||||
.with_context(|| format!("no setup named \"{}\"", meta.setup))?;
|
||||
let provider = setup.provider(&meta.provider).with_context(|| {
|
||||
fn resolve(config: &Config, meta: &SessionConfig) -> Result<(MachineConfig, ProviderConfig)> {
|
||||
let machine = config
|
||||
.machine(&meta.machine)
|
||||
.with_context(|| format!("no machine named \"{}\"", meta.machine))?;
|
||||
let provider = machine.provider(&meta.provider).with_context(|| {
|
||||
format!(
|
||||
"setup \"{}\" has no provider named \"{}\"",
|
||||
meta.setup, meta.provider
|
||||
"machine \"{}\" has no provider named \"{}\"",
|
||||
meta.machine, meta.provider
|
||||
)
|
||||
})?;
|
||||
Ok((setup.clone(), provider.clone()))
|
||||
Ok((machine.clone(), provider.clone()))
|
||||
}
|
||||
|
||||
/// A setup's current label, or its id when the setup has been deleted --
|
||||
/// A machine's current label, or its id when the machine has been deleted --
|
||||
/// which is what a session left behind by a removed machine shows, and is
|
||||
/// better than an empty column or a guess at what it used to be called.
|
||||
fn label_of<'a>(config: &'a Config, id: &'a str) -> &'a str {
|
||||
config.setup(id).map_or(id, |setup| setup.name.as_str())
|
||||
config
|
||||
.machine(id)
|
||||
.map_or(id, |machine| machine.name.as_str())
|
||||
}
|
||||
|
||||
/// The two ways a session directory can name a Claude Code conversation:
|
||||
@@ -2094,21 +2096,21 @@ fn foreign_ids(dir: &Path) -> (Option<String>, Option<String>) {
|
||||
/// app's delete cannot reach.
|
||||
///
|
||||
/// False when the provider can't be found, which is the safe way round: a
|
||||
/// setup or provider removed from the config leaves sessions naming one
|
||||
/// machine or provider removed from the config leaves sessions naming one
|
||||
/// that is gone, and the warning that then shows is the strong one. Saying
|
||||
/// "this can be brought back" on no evidence is the answer that loses
|
||||
/// somebody's conversation.
|
||||
fn keeps_own_transcript(config: &Config, setup: &str, provider: &str) -> bool {
|
||||
kind_of(config, setup, provider).is_some_and(DriverKind::keeps_own_transcript)
|
||||
fn keeps_own_transcript(config: &Config, machine: &str, provider: &str) -> bool {
|
||||
kind_of(config, machine, provider).is_some_and(DriverKind::keeps_own_transcript)
|
||||
}
|
||||
|
||||
/// What a session's provider is, for the questions answered by its *kind*
|
||||
/// rather than by its name. `None` for a provider that has been edited away,
|
||||
/// which is a session that cannot run at all.
|
||||
fn kind_of(config: &Config, setup: &str, provider: &str) -> Option<DriverKind> {
|
||||
fn kind_of(config: &Config, machine: &str, provider: &str) -> Option<DriverKind> {
|
||||
config
|
||||
.setup(setup)
|
||||
.and_then(|setup| setup.providers.iter().find(|it| it.name == provider))
|
||||
.machine(machine)
|
||||
.and_then(|machine| machine.providers.iter().find(|it| it.name == provider))
|
||||
.map(|provider| provider.kind)
|
||||
}
|
||||
|
||||
@@ -2310,7 +2312,7 @@ struct Env<'a> {
|
||||
/// process for it to speak to. See [`Launching`] for when that is.
|
||||
fn launch(
|
||||
meta: SessionConfig,
|
||||
setup: &SetupConfig,
|
||||
machine: &MachineConfig,
|
||||
provider: &ProviderConfig,
|
||||
env: Env<'_>,
|
||||
announce: Announcements,
|
||||
@@ -2405,7 +2407,7 @@ fn launch(
|
||||
&& shared.context_tokens.lock().unwrap().is_none()
|
||||
&& let Some(session_id) = claude::read_resume_token(&dir)
|
||||
{
|
||||
let transport = Transport::for_setup(setup);
|
||||
let transport = Transport::for_machine(machine);
|
||||
let shared = Arc::clone(&shared);
|
||||
tokio::spawn(async move {
|
||||
if let Some(context) = import::context_of(&transport, &session_id).await {
|
||||
@@ -2423,7 +2425,7 @@ fn launch(
|
||||
&& shared.context_tokens.lock().unwrap().is_none()
|
||||
&& let Some(thread_id) = codex::read_thread(&dir)
|
||||
{
|
||||
let transport = Transport::for_setup(setup);
|
||||
let transport = Transport::for_machine(machine);
|
||||
let shared = Arc::clone(&shared);
|
||||
tokio::spawn(async move {
|
||||
if let Some(context) = codex::context_of(&transport, &thread_id).await {
|
||||
@@ -2441,7 +2443,7 @@ fn launch(
|
||||
// anybody pressing anything.
|
||||
if let Some(cursor) = import::read_cursor(&dir) {
|
||||
spawn_import_sync(
|
||||
Transport::for_setup(setup),
|
||||
Transport::for_machine(machine),
|
||||
dir.clone(),
|
||||
cursor,
|
||||
sink.clone(),
|
||||
@@ -2454,7 +2456,7 @@ fn launch(
|
||||
.then(|| {
|
||||
make_driver(
|
||||
&meta,
|
||||
setup,
|
||||
machine,
|
||||
provider,
|
||||
env,
|
||||
&dir,
|
||||
@@ -2505,7 +2507,7 @@ fn launch(
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn make_driver(
|
||||
meta: &SessionConfig,
|
||||
setup: &SetupConfig,
|
||||
machine: &MachineConfig,
|
||||
provider: &ProviderConfig,
|
||||
env: Env<'_>,
|
||||
dir: &Path,
|
||||
@@ -2525,7 +2527,7 @@ fn make_driver(
|
||||
DriverKind::LlamaCpp => Arc::new(LlamaDriver::launch(
|
||||
meta,
|
||||
provider,
|
||||
&Transport::for_setup(setup),
|
||||
&Transport::for_machine(machine),
|
||||
env.models_dir,
|
||||
transcript_path,
|
||||
dir,
|
||||
@@ -2535,7 +2537,7 @@ fn make_driver(
|
||||
DriverKind::ClaudeCli => Arc::new(ClaudeDriver::launch(
|
||||
meta,
|
||||
provider,
|
||||
&Transport::for_setup(setup),
|
||||
&Transport::for_machine(machine),
|
||||
dir,
|
||||
sink.clone(),
|
||||
Arc::clone(subagents),
|
||||
@@ -2543,7 +2545,7 @@ fn make_driver(
|
||||
DriverKind::CodexCli => Arc::new(CodexDriver::launch(
|
||||
meta,
|
||||
provider,
|
||||
Transport::for_setup(setup),
|
||||
Transport::for_machine(machine),
|
||||
dir,
|
||||
sink.clone(),
|
||||
)?),
|
||||
@@ -2799,7 +2801,7 @@ mod tests {
|
||||
fn echo_spec() -> SpawnSpec {
|
||||
SpawnSpec {
|
||||
params: Default::default(),
|
||||
setup: crate::config::LOCAL_SETUP_ID.to_string(),
|
||||
machine: crate::config::LOCAL_MACHINE_ID.to_string(),
|
||||
provider: crate::config::ECHO_PROVIDER.to_string(),
|
||||
title: None,
|
||||
model: None,
|
||||
@@ -2857,7 +2859,7 @@ mod tests {
|
||||
/// depending on whether `claude` happens to be installed.
|
||||
fn seed_echo_only(config_path: &std::path::Path) {
|
||||
Config {
|
||||
setups: vec![Config::seed(vec![Config::echo_provider()])],
|
||||
machines: vec![Config::seed(vec![Config::echo_provider()])],
|
||||
..Config::default()
|
||||
}
|
||||
.save(config_path)
|
||||
@@ -3356,7 +3358,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
manager.foreign_transcript(&info.id),
|
||||
Some(ForeignTranscript {
|
||||
setup: info.setup.clone(),
|
||||
machine: info.machine.clone(),
|
||||
id: "5ecf21da-d53f".to_string(),
|
||||
kind: DriverKind::ClaudeCli,
|
||||
})
|
||||
@@ -3979,7 +3981,7 @@ mod tests {
|
||||
std::fs::write(&command, "#!/bin/sh\ncat > /dev/null\n").expect("write stand-in");
|
||||
std::fs::set_permissions(&command, std::fs::Permissions::from_mode(0o755)).expect("chmod");
|
||||
Config {
|
||||
setups: vec![Config::seed(vec![
|
||||
machines: vec![Config::seed(vec![
|
||||
Config::echo_provider(),
|
||||
ProviderConfig {
|
||||
name: "stand-in".to_string(),
|
||||
|
||||
@@ -48,16 +48,16 @@ impl Operation {
|
||||
#[serde(rename_all = "camelCase", tag = "state")]
|
||||
pub enum Change {
|
||||
Started {
|
||||
setup: String,
|
||||
machine: String,
|
||||
session: String,
|
||||
operation: Operation,
|
||||
},
|
||||
Finished {
|
||||
setup: String,
|
||||
machine: String,
|
||||
session: String,
|
||||
},
|
||||
Failed {
|
||||
setup: String,
|
||||
machine: String,
|
||||
session: String,
|
||||
message: String,
|
||||
},
|
||||
@@ -66,11 +66,11 @@ 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 keeps that fact in one place.
|
||||
pub fn setup(&self) -> &str {
|
||||
pub fn machine(&self) -> &str {
|
||||
match self {
|
||||
Self::Started { setup, .. }
|
||||
| Self::Finished { setup, .. }
|
||||
| Self::Failed { setup, .. } => setup,
|
||||
Self::Started { machine, .. }
|
||||
| Self::Finished { machine, .. }
|
||||
| Self::Failed { machine, .. } => machine,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -106,12 +106,12 @@ impl Registry {
|
||||
/// [`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());
|
||||
pub fn begin(self: &Arc<Self>, machine: &str, session: &str, operation: Operation) -> InFlight {
|
||||
let key = (machine.to_string(), session.to_string());
|
||||
self.running.lock().unwrap().insert(key.clone(), operation);
|
||||
self.failures.lock().unwrap().remove(&key);
|
||||
let _ = self.changes.send(Change::Started {
|
||||
setup: key.0.clone(),
|
||||
machine: key.0.clone(),
|
||||
session: key.1.clone(),
|
||||
operation,
|
||||
});
|
||||
@@ -123,25 +123,25 @@ impl Registry {
|
||||
}
|
||||
|
||||
/// What is happening to this session, if anything is.
|
||||
pub fn running(&self, setup: &str, session: &str) -> Option<Operation> {
|
||||
let key = (setup.to_string(), session.to_string());
|
||||
pub fn running(&self, machine: &str, session: &str) -> Option<Operation> {
|
||||
let key = (machine.to_string(), session.to_string());
|
||||
self.running.lock().unwrap().get(&key).copied()
|
||||
}
|
||||
|
||||
/// How the last operation on this session failed, if it did.
|
||||
pub fn failure(&self, setup: &str, session: &str) -> Option<String> {
|
||||
let key = (setup.to_string(), session.to_string());
|
||||
pub fn failure(&self, machine: &str, session: &str) -> Option<String> {
|
||||
let key = (machine.to_string(), session.to_string());
|
||||
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.
|
||||
pub fn prune(&self, setup: &str, present: &[String]) {
|
||||
pub fn prune(&self, machine: &str, present: &[String]) {
|
||||
self.failures
|
||||
.lock()
|
||||
.unwrap()
|
||||
.retain(|(kept_setup, session), _| {
|
||||
kept_setup != setup || present.iter().any(|id| id == session)
|
||||
.retain(|(kept_machine, session), _| {
|
||||
kept_machine != machine || present.iter().any(|id| id == session)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -174,7 +174,7 @@ impl InFlight {
|
||||
}
|
||||
self.settled = true;
|
||||
self.registry.running.lock().unwrap().remove(&self.key);
|
||||
let (setup, session) = (self.key.0.clone(), self.key.1.clone());
|
||||
let (machine, session) = (self.key.0.clone(), self.key.1.clone());
|
||||
let change = match failure {
|
||||
Some(message) => {
|
||||
self.registry
|
||||
@@ -183,12 +183,12 @@ impl InFlight {
|
||||
.unwrap()
|
||||
.insert(self.key.clone(), message.clone());
|
||||
Change::Failed {
|
||||
setup,
|
||||
machine,
|
||||
session,
|
||||
message,
|
||||
}
|
||||
}
|
||||
None => Change::Finished { setup, session },
|
||||
None => Change::Finished { machine, session },
|
||||
};
|
||||
let _ = self.registry.changes.send(change);
|
||||
}
|
||||
|
||||
@@ -97,7 +97,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.
|
||||
/// spawned with even if the machine is edited afterwards.
|
||||
Ssh { name: String, ssh: SshConfig },
|
||||
}
|
||||
|
||||
@@ -179,11 +179,11 @@ impl Transport {
|
||||
}
|
||||
}
|
||||
|
||||
/// The transport a setup describes; a setup with no `ssh` is here.
|
||||
pub fn for_setup(setup: &crate::config::SetupConfig) -> Self {
|
||||
match &setup.ssh {
|
||||
/// The transport a machine describes; a machine with no `ssh` is here.
|
||||
pub fn for_machine(machine: &crate::config::MachineConfig) -> Self {
|
||||
match &machine.ssh {
|
||||
Some(ssh) => Self::Ssh {
|
||||
name: setup.name.clone(),
|
||||
name: machine.name.clone(),
|
||||
ssh: ssh.clone(),
|
||||
},
|
||||
None => Self::Here,
|
||||
|
||||
+196
-74
@@ -16,11 +16,11 @@
|
||||
//! behind the same snapshot shape.
|
||||
//!
|
||||
//! **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.
|
||||
//! runs wherever its machine 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
|
||||
//! read through the session `Transport`, one snapshot per machine that offers
|
||||
//! that provider.
|
||||
//!
|
||||
//! The token is read *to* the backend and the HTTP call is made from here, so
|
||||
@@ -36,7 +36,7 @@ use std::time::{Duration, Instant};
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::config::SetupConfig;
|
||||
use crate::config::MachineConfig;
|
||||
use crate::session::transport::{Launch, Transport};
|
||||
|
||||
const USAGE_URL: &str = "https://api.anthropic.com/api/oauth/usage";
|
||||
@@ -72,12 +72,12 @@ 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
|
||||
/// Named answers rather than a flag and a message, because the screen has to
|
||||
/// 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.
|
||||
/// last, so a perfectly healthy machine read as broken.
|
||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||
#[serde(tag = "state", rename_all = "camelCase")]
|
||||
pub enum UsageState {
|
||||
@@ -88,6 +88,13 @@ pub enum UsageState {
|
||||
NotLoggedIn,
|
||||
/// The machine could not be asked at all.
|
||||
Unreachable { detail: String },
|
||||
/// An explicit Claude login is waiting for its browser code. Kept apart
|
||||
/// from a failure because nothing is broken while somebody is completing
|
||||
/// the operation on the phone.
|
||||
Authenticating,
|
||||
/// Credentials exist, but the CLI could not renew them. Unlike a generic
|
||||
/// provider failure, this has a direct action the phone can offer.
|
||||
LoginRequired { detail: String },
|
||||
/// The machine is logged in, but the usage endpoint did not answer.
|
||||
Failed { detail: String },
|
||||
}
|
||||
@@ -98,10 +105,10 @@ 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.
|
||||
pub setup: String,
|
||||
pub machine: String,
|
||||
/// That machine's current label, resolved when the snapshot is built, so
|
||||
/// renaming a setup renames it here too.
|
||||
pub setup_name: String,
|
||||
/// renaming a machine renames it here too.
|
||||
pub machine_name: String,
|
||||
/// The provider's billing pool, when it exposes more than one. Codex uses
|
||||
/// this to keep regular usage separate from its Luna reserve allowance.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -149,8 +156,8 @@ pub trait UsageProvider: Send + Sync {
|
||||
/// 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,
|
||||
pub machine: String,
|
||||
pub machine_name: String,
|
||||
/// How to reach that machine. `Here` for the backend's own.
|
||||
pub transport: Transport,
|
||||
/// The CLI to run there, for the one thing this asks of it: refreshing its
|
||||
@@ -168,8 +175,8 @@ impl ClaudeUsage {
|
||||
fn snapshot(&self, state: UsageState, windows: Vec<UsageWindow>) -> UsageSnapshot {
|
||||
UsageSnapshot {
|
||||
provider: self.name().to_string(),
|
||||
setup: self.setup.clone(),
|
||||
setup_name: self.setup_name.clone(),
|
||||
machine: self.machine.clone(),
|
||||
machine_name: self.machine_name.clone(),
|
||||
limit_id: None,
|
||||
limit_name: None,
|
||||
state,
|
||||
@@ -234,8 +241,8 @@ impl UsageProvider for ClaudeUsage {
|
||||
/// app-server protocol. The CLI owns authentication and token refresh; this
|
||||
/// process never opens or copies its credentials.
|
||||
pub struct CodexUsage {
|
||||
pub setup: String,
|
||||
pub setup_name: String,
|
||||
pub machine: String,
|
||||
pub machine_name: String,
|
||||
pub transport: Transport,
|
||||
pub program: String,
|
||||
}
|
||||
@@ -244,8 +251,8 @@ impl CodexUsage {
|
||||
fn snapshot(&self, state: UsageState, windows: Vec<UsageWindow>) -> UsageSnapshot {
|
||||
UsageSnapshot {
|
||||
provider: CODEX.to_string(),
|
||||
setup: self.setup.clone(),
|
||||
setup_name: self.setup_name.clone(),
|
||||
machine: self.machine.clone(),
|
||||
machine_name: self.machine_name.clone(),
|
||||
limit_id: None,
|
||||
limit_name: None,
|
||||
state,
|
||||
@@ -283,7 +290,7 @@ impl UsageProvider for CodexUsage {
|
||||
Err(err) => {
|
||||
return vec![self.snapshot(
|
||||
UsageState::Unreachable {
|
||||
detail: format!("couldn't ask Codex on {}: {err:#}", self.setup_name),
|
||||
detail: format!("couldn't ask Codex on {}: {err:#}", self.machine_name),
|
||||
},
|
||||
Vec::new(),
|
||||
)];
|
||||
@@ -446,7 +453,7 @@ impl ClaudeUsage {
|
||||
return Err(UsageState::Failed {
|
||||
detail: format!(
|
||||
"the Claude login on {} has expired, and `{} doctor` couldn't be run there to refresh it: {err:#}",
|
||||
self.setup_name, self.program
|
||||
self.machine_name, self.program
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -463,10 +470,10 @@ impl ClaudeUsage {
|
||||
/// A login the CLI could not renew: the one state here somebody has to act
|
||||
/// on, so it says where and what to run.
|
||||
fn still_expired(&self) -> UsageState {
|
||||
UsageState::Failed {
|
||||
UsageState::LoginRequired {
|
||||
detail: format!(
|
||||
"the Claude login on {} has expired and could not be refreshed; run `{} /login` there",
|
||||
self.setup_name, self.program
|
||||
"The Claude login on {} has expired and could not be refreshed.",
|
||||
self.machine_name
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -727,6 +734,8 @@ fn describe(state: &UsageState, windows: &[UsageWindow]) -> String {
|
||||
},
|
||||
UsageState::NotLoggedIn => "nobody is logged in on this machine".to_string(),
|
||||
UsageState::Unreachable { detail } => format!("machine unreachable ({detail})"),
|
||||
UsageState::Authenticating => "sign-in is in progress".to_string(),
|
||||
UsageState::LoginRequired { detail } => format!("sign-in required ({detail})"),
|
||||
UsageState::Failed { detail } => format!("the meter failed ({detail})"),
|
||||
}
|
||||
}
|
||||
@@ -734,8 +743,8 @@ fn describe(state: &UsageState, windows: &[UsageWindow]) -> String {
|
||||
/// The fixture, as a provider, so it travels the same route and the same
|
||||
/// cache as a real meter rather than being spliced in at the screen.
|
||||
struct EchoUsage {
|
||||
setup: String,
|
||||
setup_name: String,
|
||||
machine: String,
|
||||
machine_name: String,
|
||||
fixture: Fixture,
|
||||
}
|
||||
|
||||
@@ -759,8 +768,8 @@ impl UsageProvider for EchoUsage {
|
||||
));
|
||||
vec![UsageSnapshot {
|
||||
provider: self.name().to_string(),
|
||||
setup: self.setup.clone(),
|
||||
setup_name: self.setup_name.clone(),
|
||||
machine: self.machine.clone(),
|
||||
machine_name: self.machine_name.clone(),
|
||||
limit_id: None,
|
||||
limit_name: None,
|
||||
state,
|
||||
@@ -778,7 +787,7 @@ 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
|
||||
/// Derived from what the machine 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.
|
||||
///
|
||||
@@ -787,9 +796,9 @@ impl UsageProvider for EchoUsage {
|
||||
/// 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>> {
|
||||
fn providers_for(machine: &MachineConfig, fixture: &Fixture) -> Vec<Box<dyn UsageProvider>> {
|
||||
let mut found: Vec<Box<dyn UsageProvider>> = Vec::new();
|
||||
for provider in &setup.providers {
|
||||
for provider in &machine.providers {
|
||||
let Some(name) = provider.kind.usage_provider() else {
|
||||
continue;
|
||||
};
|
||||
@@ -801,23 +810,23 @@ fn providers_for(setup: &SetupConfig, fixture: &Fixture) -> Vec<Box<dyn UsagePro
|
||||
}
|
||||
match name {
|
||||
CLAUDE => found.push(Box::new(ClaudeUsage {
|
||||
setup: setup.id.clone(),
|
||||
setup_name: setup.name.clone(),
|
||||
transport: Transport::for_setup(setup),
|
||||
machine: machine.id.clone(),
|
||||
machine_name: machine.name.clone(),
|
||||
transport: Transport::for_machine(machine),
|
||||
program: provider.program().to_string(),
|
||||
})),
|
||||
CODEX => found.push(Box::new(CodexUsage {
|
||||
setup: setup.id.clone(),
|
||||
setup_name: setup.name.clone(),
|
||||
transport: Transport::for_setup(setup),
|
||||
machine: machine.id.clone(),
|
||||
machine_name: machine.name.clone(),
|
||||
transport: Transport::for_machine(machine),
|
||||
program: provider.program().to_string(),
|
||||
})),
|
||||
// Nothing at all until a test has asked for something: an
|
||||
// echo session costs nothing, so the honest answer is no row
|
||||
// rather than a row saying zero.
|
||||
ECHO if fixture.is_set() => found.push(Box::new(EchoUsage {
|
||||
setup: setup.id.clone(),
|
||||
setup_name: setup.name.clone(),
|
||||
machine: machine.id.clone(),
|
||||
machine_name: machine.name.clone(),
|
||||
fixture: fixture.clone(),
|
||||
})),
|
||||
_ => {}
|
||||
@@ -829,16 +838,22 @@ fn providers_for(setup: &SetupConfig, fixture: &Fixture) -> Vec<Box<dyn UsagePro
|
||||
/// 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 not
|
||||
/// fixed at startup -- setups are added, renamed and removed from the phone --
|
||||
/// fixed at startup -- machines 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, Vec<UsageSnapshot>)>;
|
||||
type ProviderGate = Arc<Mutex<()>>;
|
||||
|
||||
#[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>,
|
||||
/// One gate per machine and metered provider. The cache lock cannot cover
|
||||
/// a network call without making an unreachable machine stall every other
|
||||
/// one, but releasing it used to let concurrent `/usage` requests launch
|
||||
/// two token refreshers against the same rotating credential.
|
||||
gates: Mutex<HashMap<(String, &'static str), ProviderGate>>,
|
||||
/// The invented meter an echo session can put up; empty unless one
|
||||
/// has. Shared with the session layer, which is where the command
|
||||
/// that sets it is typed -- see [`Fixture`].
|
||||
@@ -849,37 +864,116 @@ impl UsageMonitor {
|
||||
pub fn new(fixture: Fixture) -> Self {
|
||||
Self {
|
||||
cache: Mutex::new(Cached::new()),
|
||||
gates: Mutex::new(HashMap::new()),
|
||||
fixture,
|
||||
}
|
||||
}
|
||||
|
||||
fn gate_for(&self, key: &(String, &'static str)) -> ProviderGate {
|
||||
self.gates
|
||||
.lock()
|
||||
.unwrap()
|
||||
.entry(key.clone())
|
||||
.or_default()
|
||||
.clone()
|
||||
}
|
||||
|
||||
fn cached(
|
||||
&self,
|
||||
key: &(String, &'static str),
|
||||
machine_name: &str,
|
||||
max_age: Option<Duration>,
|
||||
) -> Option<Vec<UsageSnapshot>> {
|
||||
let cache = self.cache.lock().unwrap();
|
||||
let (fetched, snapshots) = cache.get(key)?;
|
||||
if max_age.is_some_and(|age| fetched.elapsed() >= age) {
|
||||
return None;
|
||||
}
|
||||
let mut snapshots = snapshots.clone();
|
||||
for snapshot in &mut snapshots {
|
||||
snapshot.machine_name = machine_name.to_string();
|
||||
}
|
||||
Some(snapshots)
|
||||
}
|
||||
|
||||
/// Prevents automatic refresh from overlapping an explicit login. The
|
||||
/// fresh cache entry closes the small gap before the login worker acquires
|
||||
/// the same gate, while an attempt lasting beyond the normal cache window
|
||||
/// is protected by the worker holding it.
|
||||
pub(crate) fn claude_authentication_started(&self, machine: &MachineConfig) -> ProviderGate {
|
||||
let key = (machine.id.clone(), CLAUDE);
|
||||
let gate = self.gate_for(&key);
|
||||
let guard = gate.lock().unwrap();
|
||||
self.cache.lock().unwrap().insert(
|
||||
key,
|
||||
(
|
||||
Instant::now(),
|
||||
vec![UsageSnapshot {
|
||||
provider: CLAUDE.to_string(),
|
||||
machine: machine.id.clone(),
|
||||
machine_name: machine.name.clone(),
|
||||
limit_id: None,
|
||||
limit_name: None,
|
||||
state: UsageState::Authenticating,
|
||||
windows: Vec::new(),
|
||||
fetched_at: crate::session::now(),
|
||||
}],
|
||||
),
|
||||
);
|
||||
drop(guard);
|
||||
gate
|
||||
}
|
||||
|
||||
/// Makes the first read after a login ask the provider instead of keeping
|
||||
/// the pre-login state for the ordinary cache interval.
|
||||
pub(crate) fn claude_authentication_finished(&self, machine: &str) {
|
||||
self.cache
|
||||
.lock()
|
||||
.unwrap()
|
||||
.remove(&(machine.to_string(), CLAUDE));
|
||||
}
|
||||
|
||||
/// One or more snapshots per machine that offers a paid service, in the
|
||||
/// order the machines are configured and the provider reports them.
|
||||
///
|
||||
/// Blocking -- call via `spawn_blocking`. Takes the setups rather than
|
||||
/// Blocking -- call via `spawn_blocking`. Takes the machines rather than
|
||||
/// 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> {
|
||||
pub fn snapshots(&self, machines: &[MachineConfig]) -> Vec<UsageSnapshot> {
|
||||
let mut fresh = Vec::new();
|
||||
for setup in setups {
|
||||
for provider in providers_for(setup, &self.fixture) {
|
||||
let key = (setup.id.clone(), provider.name());
|
||||
if let Some((fetched, snapshot)) = self.cache.lock().unwrap().get(&key)
|
||||
&& fetched.elapsed() < provider.poll_interval()
|
||||
for machine in machines {
|
||||
for provider in providers_for(machine, &self.fixture) {
|
||||
let key = (machine.id.clone(), provider.name());
|
||||
if let Some(snapshots) =
|
||||
self.cached(&key, &machine.name, Some(provider.poll_interval()))
|
||||
{
|
||||
fresh.extend(snapshots);
|
||||
continue;
|
||||
}
|
||||
|
||||
let gate = self.gate_for(&key);
|
||||
let _guard = match gate.try_lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(std::sync::TryLockError::WouldBlock) => {
|
||||
// A refresh already in flight can keep serving the last
|
||||
// measured answer. An explicit login seeds its own
|
||||
// authenticating answer before taking the gate.
|
||||
if let Some(snapshots) = self.cached(&key, &machine.name, None) {
|
||||
fresh.extend(snapshots);
|
||||
continue;
|
||||
}
|
||||
// The first-ever fetch has no honest stale answer. Wait
|
||||
// for its one producer, then recheck below.
|
||||
gate.lock().unwrap()
|
||||
}
|
||||
Err(std::sync::TryLockError::Poisoned(err)) => err.into_inner(),
|
||||
};
|
||||
if let Some(snapshots) =
|
||||
self.cached(&key, &machine.name, Some(provider.poll_interval()))
|
||||
{
|
||||
// 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 snapshots = snapshot.clone();
|
||||
for snapshot in &mut snapshots {
|
||||
snapshot.setup_name = setup.name.clone();
|
||||
}
|
||||
fresh.extend(snapshots);
|
||||
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.
|
||||
let snapshots = provider.fetch();
|
||||
self.cache
|
||||
.lock()
|
||||
@@ -890,11 +984,14 @@ impl UsageMonitor {
|
||||
}
|
||||
// Machines that have gone away should not keep their numbers alive.
|
||||
let live: std::collections::HashSet<&str> =
|
||||
setups.iter().map(|setup| setup.id.as_str()).collect();
|
||||
machines.iter().map(|machine| machine.id.as_str()).collect();
|
||||
self.cache
|
||||
.lock()
|
||||
.unwrap()
|
||||
.retain(|(setup, _), _| live.contains(setup.as_str()));
|
||||
.retain(|(machine, _), _| live.contains(machine.as_str()));
|
||||
self.gates.lock().unwrap().retain(|(machine, _), gate| {
|
||||
live.contains(machine.as_str()) || Arc::strong_count(gate) > 1
|
||||
});
|
||||
fresh
|
||||
}
|
||||
}
|
||||
@@ -904,6 +1001,31 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::config::DriverKind;
|
||||
|
||||
#[test]
|
||||
fn refresh_gates_are_shared_per_machine_and_provider_only() {
|
||||
let monitor = UsageMonitor::new(Fixture::default());
|
||||
let claude_here = monitor.gate_for(&("here".to_string(), CLAUDE));
|
||||
let same = monitor.gate_for(&("here".to_string(), CLAUDE));
|
||||
let claude_there = monitor.gate_for(&("there".to_string(), CLAUDE));
|
||||
let codex_here = monitor.gate_for(&("here".to_string(), CODEX));
|
||||
|
||||
let held = claude_here.lock().unwrap();
|
||||
assert!(
|
||||
matches!(same.try_lock(), Err(std::sync::TryLockError::WouldBlock)),
|
||||
"the same machine/provider pair must have one credential writer"
|
||||
);
|
||||
assert!(
|
||||
claude_there.try_lock().is_ok(),
|
||||
"another machine must not wait"
|
||||
);
|
||||
assert!(
|
||||
codex_here.try_lock().is_ok(),
|
||||
"another provider must not wait"
|
||||
);
|
||||
drop(held);
|
||||
assert!(same.try_lock().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refusal_the_endpoint_answered_is_not_reported_as_an_unreachable_one() {
|
||||
assert!(why(&ureq::Error::StatusCode(500)).contains("HTTP 500"));
|
||||
@@ -913,9 +1035,9 @@ mod tests {
|
||||
#[test]
|
||||
fn an_expired_login_says_where_to_log_in_rather_than_naming_the_network() {
|
||||
let provider = ClaudeUsage {
|
||||
setup: "far".to_string(),
|
||||
setup_name: "somewhere else".to_string(),
|
||||
transport: Transport::for_setup(&unreachable_setup()),
|
||||
machine: "far".to_string(),
|
||||
machine_name: "somewhere else".to_string(),
|
||||
transport: Transport::for_machine(&unreachable_machine()),
|
||||
program: "/opt/claude".to_string(),
|
||||
};
|
||||
// The machine cannot be reached, so the refresh attempt fails there
|
||||
@@ -934,10 +1056,10 @@ mod tests {
|
||||
"the token must never be quoted back"
|
||||
);
|
||||
|
||||
let UsageState::Failed { detail } = provider.still_expired() else {
|
||||
panic!("still expired is a fault");
|
||||
let UsageState::LoginRequired { detail } = provider.still_expired() else {
|
||||
panic!("still expired must offer a new login");
|
||||
};
|
||||
assert!(detail.contains("/opt/claude /login"), "{detail}");
|
||||
assert!(detail.contains("somewhere else"), "{detail}");
|
||||
assert!(!detail.contains("unreachable"), "{detail}");
|
||||
}
|
||||
|
||||
@@ -967,10 +1089,10 @@ mod tests {
|
||||
assert_eq!(windows[3].resets_at, None);
|
||||
}
|
||||
|
||||
/// A setup naming a machine that cannot be dialled, so nothing here touches
|
||||
/// A machine naming a machine that cannot be dialled, so nothing here touches
|
||||
/// the network beyond ssh failing to resolve it.
|
||||
fn unreachable_setup() -> SetupConfig {
|
||||
SetupConfig {
|
||||
fn unreachable_machine() -> MachineConfig {
|
||||
MachineConfig {
|
||||
id: "far".to_string(),
|
||||
name: "somewhere else".to_string(),
|
||||
ssh: Some(crate::config::SshConfig {
|
||||
@@ -993,9 +1115,9 @@ mod tests {
|
||||
#[test]
|
||||
fn a_machine_that_cannot_be_asked_says_so_rather_than_looking_logged_out() {
|
||||
let provider = ClaudeUsage {
|
||||
setup: "far".to_string(),
|
||||
setup_name: "somewhere else".to_string(),
|
||||
transport: Transport::for_setup(&unreachable_setup()),
|
||||
machine: "far".to_string(),
|
||||
machine_name: "somewhere else".to_string(),
|
||||
transport: Transport::for_machine(&unreachable_machine()),
|
||||
program: "claude".to_string(),
|
||||
};
|
||||
let snapshot = provider.fetch().remove(0);
|
||||
@@ -1007,8 +1129,8 @@ mod tests {
|
||||
"{:?}",
|
||||
snapshot.state
|
||||
);
|
||||
assert_eq!(snapshot.setup, "far");
|
||||
assert_eq!(snapshot.setup_name, "somewhere else");
|
||||
assert_eq!(snapshot.machine, "far");
|
||||
assert_eq!(snapshot.machine_name, "somewhere else");
|
||||
assert!(snapshot.windows.is_empty());
|
||||
}
|
||||
|
||||
@@ -1043,7 +1165,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn only_machines_that_can_run_claude_are_asked_about_it() {
|
||||
let mut echo_only = unreachable_setup();
|
||||
let mut echo_only = unreachable_machine();
|
||||
echo_only.providers = vec![crate::config::ProviderConfig {
|
||||
name: "echo".to_string(),
|
||||
kind: DriverKind::Echo,
|
||||
@@ -1056,7 +1178,7 @@ mod tests {
|
||||
// otherwise there is no meter to report.
|
||||
let unset = Fixture::new();
|
||||
assert!(providers_for(&echo_only, &unset).is_empty());
|
||||
assert_eq!(providers_for(&unreachable_setup(), &unset).len(), 1);
|
||||
assert_eq!(providers_for(&unreachable_machine(), &unset).len(), 1);
|
||||
|
||||
// And with one set, that machine has exactly the invented meter
|
||||
// -- under the name the session's `usageProvider` will name.
|
||||
@@ -1142,8 +1264,8 @@ mod tests {
|
||||
#[test]
|
||||
fn keeps_codex_reserve_as_a_named_pool() {
|
||||
let provider = CodexUsage {
|
||||
setup: "local".to_string(),
|
||||
setup_name: "this machine".to_string(),
|
||||
machine: "local".to_string(),
|
||||
machine_name: "this machine".to_string(),
|
||||
transport: Transport::Here,
|
||||
program: "codex".to_string(),
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user