diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt index c16bd21..49630e7 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt @@ -106,13 +106,13 @@ private fun JSONArray.strings(): List = (0 until length()).map { getStri /** Percent-encodes a value going into a query string. */ private fun String.urlEncoded(): String = java.net.URLEncoder.encode(this, Charsets.UTF_8.name()) -// One row of GET /sessions. `provider` is what runs it, `host` where -- -// the two are independent, so a session names both. +// One row of GET /sessions. A session names the machine it runs on and +// which of that machine's providers it runs. data class SessionSummary( val id: String, + val setup: String, val provider: String, val title: String, - val host: String?, val model: String?, val status: String, val lastActivity: Double, @@ -121,9 +121,9 @@ data class SessionSummary( private fun parseSession(session: JSONObject) = SessionSummary( id = session.getString("id"), + setup = session.getString("setup"), provider = session.getString("provider"), title = session.getString("title"), - host = session.optString("host").ifEmpty { null }, model = session.optString("model").ifEmpty { null }, status = session.getString("status"), lastActivity = session.getDouble("lastActivity"), @@ -133,43 +133,48 @@ fun fetchSessions(settings: ServerSettings): List = requestFromServer(settings, "/sessions") { it.jsonObjects(::parseSession) } // What the server offers, so the spawn screen has no hardcoded lists: a -// provider or host added to the server's config.ron appears here with no -// app rebuild. +// setup added to the server's config.ron appears here with no app rebuild. +// +// One list rather than two. A provider only exists on a machine that has +// it installed, so offering machines and providers as independent choices +// would offer pairs that cannot work. data class Provider(val name: String, val kind: String, val models: List) -data class RemoteHost(val name: String, val address: String) +/** A machine, and what it can run. [address] is absent for the backend itself. */ +data class Setup(val name: String, val address: String?, val providers: List) -fun fetchProviders(settings: ServerSettings): List = - requestFromServer(settings, "/providers") { connection -> - connection.jsonObjects { provider -> - Provider( - name = provider.getString("name"), - kind = provider.getString("kind"), - // Omitted entirely when the provider offers none. - models = provider.optJSONArray("models")?.strings().orEmpty(), +fun fetchSetups(settings: ServerSettings): List = + requestFromServer(settings, "/setups") { connection -> + connection.jsonObjects { setup -> + Setup( + name = setup.getString("name"), + address = setup.optString("address").ifEmpty { null }, + providers = + setup.getJSONArray("providers").mapObjects { provider -> + Provider( + name = provider.getString("name"), + kind = provider.getString("kind"), + // Omitted entirely when the provider offers none. + models = provider.optJSONArray("models")?.strings().orEmpty(), + ) + }, ) } } -fun fetchHosts(settings: ServerSettings): List = - requestFromServer(settings, "/hosts") { connection -> - connection.jsonObjects { host -> - RemoteHost(name = host.getString("name"), address = host.getString("address")) - } - } - /** - * Spawns a session and returns it as the list would show it. [host] is the name of a configured - * host, or null to run on the backend machine itself. + * Spawns a session and returns it as the list would show it. [setup] names the machine and + * [provider] one of the things that machine offers. */ fun spawnSession( settings: ServerSettings, + setup: String, provider: String, title: String, - host: String? = null, model: String? = null, cwd: String? = null, permissionMode: String? = null, + params: Map = emptyMap(), ): SessionSummary = requestFromServer( settings, @@ -177,13 +182,16 @@ fun spawnSession( method = "POST", jsonBody = JSONObject() + .put("setup", setup) .put("provider", provider) .put("title", title) .apply { - if (!host.isNullOrBlank()) put("host", host) if (!model.isNullOrBlank()) put("model", model) if (!cwd.isNullOrBlank()) put("cwd", cwd) if (!permissionMode.isNullOrBlank()) put("permissionMode", permissionMode) + if (params.isNotEmpty()) { + put("params", JSONObject(params.toMap())) + } } .toString(), readTimeoutMs = 30000, diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt index 56473dd..341a6ed 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt @@ -216,7 +216,7 @@ private fun SessionCard( // than a bare name, so a host isn't mistaken for a model. listOfNotNull( session.provider, - session.host?.let { "on $it" }, + "on ${session.setup}", session.model, ) .joinToString(" · "), diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt index d522be7..2f495e7 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -242,7 +242,7 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: () Text( listOfNotNull( summary.provider, - summary.host?.let { "on $it" }, + "on ${summary.setup}", summary.model, if (totalTokens > 0) "$totalTokens tok" else null, ) diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt index 0b4461a..de0d0b9 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt @@ -39,7 +39,6 @@ import kotlinx.coroutines.withContext private val PERMISSION_MODES = listOf("manual", "acceptEdits", "auto", "bypassPermissions", "plan") /** Runs on the backend machine itself -- the "no host" case. */ -private const val LOCAL_HOST_LABEL = "backend" /** * The spawn screen: what to run, where to run it, and the per-kind fields. @@ -57,10 +56,14 @@ fun SpawnScreen( // What the form is made of, and whether we have it yet. A failure here // is not the same as a server with nothing to offer, so it must not // reach the pickers as empty lists -- see LoadState. - var options by remember { mutableStateOf>(LoadState.Loading) } + var options by remember { mutableStateOf>>(LoadState.Loading) } - var provider by remember { mutableStateOf(null) } - var host by remember { mutableStateOf(null) } + // Setup first, then one of its providers. Choosing a setup can + // invalidate the provider, so the provider is stored by name and + // resolved against the current setup rather than held as an object + // that could outlive the list it came from. + var setupName by remember { mutableStateOf(null) } + var providerName by remember { mutableStateOf(null) } var title by remember { mutableStateOf("") } var model by remember { mutableStateOf("") } var cwd by remember { mutableStateOf("") } @@ -74,23 +77,16 @@ fun SpawnScreen( LaunchedEffect(Unit) { options = try { - val fetched = - withContext(Dispatchers.IO) { - SpawnOptions(fetchProviders(settings), fetchHosts(settings)) - } - provider = fetched.providers.firstOrNull() + val fetched = withContext(Dispatchers.IO) { fetchSetups(settings) } + val first = fetched.firstOrNull() + setupName = first?.name + providerName = first?.providers?.firstOrNull()?.name LoadState.Loaded(fetched) } catch (e: ApiException) { LoadState.failed(e) } } - val current = provider - // Only the Claude CLI has models, a working directory, and permission - // modes; keying the extra fields on the kind rather than the provider - // name keeps a second Claude provider from needing anything here. - val isClaude = current?.kind == "claude_cli" - Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { Text( @@ -106,7 +102,7 @@ fun SpawnScreen( // failure to fetch them leaves no form worth showing -- so this // reports and stops, rather than offering empty pickers under an // error message. - val (providers, hosts) = + val setups = when (val state = options) { is LoadState.Loading -> { CircularProgressIndicator() @@ -118,33 +114,56 @@ fun SpawnScreen( } is LoadState.Loaded -> state.value } + val setup = setups.firstOrNull { it.name == setupName } + val current = setup?.providers?.firstOrNull { it.name == providerName } + // Only the Claude CLI has models, a working directory and + // permission modes; keying the extra fields on the kind rather + // than the provider name keeps a second Claude provider from + // needing anything here. + val isClaude = current?.kind == "claude_cli" + // The machine first, because it decides what can be run at all. ChipGroup( - label = "Provider", - options = providers.map { it.name }, - selected = current?.name, - onSelect = { name -> provider = providers.first { it.name == name } }, + label = "Setup", + options = setups.map { it.name }, + selected = setupName, + onSelect = { name -> + setupName = name + // The provider list changes with the machine, so a name + // carried over from the previous one would be a selection + // that isn't in the picker. Take that machine's first. + providerName = + setups.firstOrNull { it.name == name }?.providers?.firstOrNull()?.name + }, ) - - // Always offered, whatever the provider: where a session runs is - // independent of what runs it. - ChipGroup( - label = "Run on", - options = listOf(LOCAL_HOST_LABEL) + hosts.map { it.name }, - selected = host ?: LOCAL_HOST_LABEL, - onSelect = { name -> host = name.takeIf { it != LOCAL_HOST_LABEL } }, - ) - host?.let { chosen -> - hosts - .firstOrNull { it.name == chosen } - ?.let { - Text( - it.address, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + setup?.address?.let { + Text( + it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + // The address belongs to the setup above it, not to the + // provider label below; without this they read as one block. + Spacer(Modifier.height(8.dp)) } + + // Only what this machine actually has. A setup with none says so + // rather than showing an empty row that reads as a failure. + if (setup != null && setup.providers.isEmpty()) { + Text( + "\"${setup.name}\" has no providers configured.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + ChipGroup( + label = "Provider", + options = setup?.providers?.map { it.name }.orEmpty(), + selected = providerName, + onSelect = { providerName = it }, + ) + } + Spacer(Modifier.height(16.dp)) OutlinedTextField( @@ -210,9 +229,9 @@ fun SpawnScreen( withContext(Dispatchers.IO) { spawnSession( settings, + setup = setup?.name.orEmpty(), provider = chosen.name, title = title.trim(), - host = host, model = model.trim().takeIf { isClaude }, cwd = cwd.trim().takeIf { isClaude }, permissionMode = permissionMode.takeIf { isClaude }, @@ -232,9 +251,6 @@ fun SpawnScreen( } } -/** What the spawn form is built from, fetched as one thing. */ -private data class SpawnOptions(val providers: List, val hosts: List) - /** * A labeled row of choices that wraps onto as many lines as it needs. * diff --git a/server/src/config.rs b/server/src/config.rs index fdc4be1..d809f5e 100644 --- a/server/src/config.rs +++ b/server/src/config.rs @@ -18,7 +18,7 @@ use std::collections::BTreeMap; use std::path::{Path, PathBuf}; -use anyhow::{Context, Result}; +use anyhow::{Context, Result, bail}; use serde::{Deserialize, Serialize}; use crate::private; @@ -87,26 +87,48 @@ pub struct Config { /// the credential. A list (of one, today) so per-device tokens with /// individual revocation are a config entry later, not a migration. pub tokens: Vec, - /// What can be spawned. See [`ProviderConfig`]. - pub providers: Vec, - /// Machines a session can be told to run on. See [`HostConfig`]. - pub hosts: Vec, + /// Every machine this server can run something on, and what each of + /// them can run. See [`SetupConfig`]. + pub setups: Vec, pub sessions: Vec, } -/// One thing that can be spawned: which driver, and how to invoke it. +/// A machine, and the things it can run. /// -/// Deliberately says nothing about *where* it runs -- that is the -/// session's [`SessionConfig::host`], because the two are independent. -/// The same provider may run locally for one session and over SSH for the -/// next, and pinning a machine here would make "the Claude CLI" and "the -/// Claude CLI on that box" two different things to configure and pick -/// between. +/// This is the unit a session is spawned against: pick a setup, then one +/// of its providers. Grouping them this way is what stops the spawn +/// screen offering combinations that cannot work -- a provider only +/// exists on a machine where that program is installed, and the previous +/// model, which let any provider be paired with any host, offered the +/// whole cross-product including the impossible parts of it. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SetupConfig { + /// What the spawn screen shows and sessions store. Unique; renaming + /// one orphans the sessions that reference it. + pub name: String, + /// How to reach it, absent for this machine. A setup with no `ssh` is + /// where the server itself runs. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ssh: Option, + /// What can be spawned here. Names are unique within a setup, and only + /// within it: two machines may each have a `claude-cli`, which is the + /// point. + #[serde(default)] + pub providers: Vec, +} + +impl SetupConfig { + 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. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ProviderConfig { /// Shown on the spawn screen and stored by sessions that use it. - /// Unique; renaming one orphans the sessions that reference it. pub name: String, pub kind: DriverKind, /// Override for the executable, for an install that isn't on PATH. @@ -118,18 +140,15 @@ pub struct ProviderConfig { pub models: Vec, } -/// A machine sessions can be run on, reached with the system `ssh` client -/// -- so `~/.ssh/config`, agents, and jump hosts all keep working, and -/// there is one place to configure connections (PLAN.md, rule 23). +/// How to reach a setup that isn't this machine, with the system `ssh` +/// client -- so `~/.ssh/config`, agents, and jump hosts all keep working, +/// and there is one place to configure connections (PLAN.md, rule 23). /// -/// Applies to any session of any provider: a remote session is the -/// identical command with `ssh host …` in front, and nothing downstream of -/// the spawn knows the difference. +/// A remote session is the identical command with `ssh host …` in front, +/// and nothing downstream of the spawn knows the difference. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct HostConfig { - /// What the spawn screen shows and the session stores. - pub name: String, +pub struct SshConfig { /// `user@host`, or a `Host` alias from `~/.ssh/config`. pub address: String, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -183,15 +202,13 @@ pub struct TokenEntry { pub struct SessionConfig { /// Stable identifier; names the session's directory and its routes. pub id: String, - /// Name of the [`ProviderConfig`] this session runs. Stored rather - /// than the resolved driver so an edited provider (a new command path, - /// another model) takes effect on the next relaunch; a session whose + /// Name of the [`SetupConfig`] this session runs on. + pub setup: String, + /// Name of the provider within that setup. Both stored by name rather + /// than resolved, so an edited setup (a new command path, another + /// model) takes effect on the next relaunch; a session whose setup or /// provider is gone reports as exited and can still be deleted. pub provider: String, - /// Name of the [`HostConfig`] to run it on. Absent means the backend - /// machine itself. Independent of the provider by design. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub host: Option, pub title: String, #[serde(skip_serializing_if = "Option::is_none")] pub model: Option, @@ -220,41 +237,71 @@ pub struct SessionConfig { pub created: f64, } -/// The name of the built-in echo provider. Always present, never written -/// to the config file: it needs no configuration and gives every install a -/// working session type to test the pipe with. +/// The name of the echo provider, and of the setup this machine gets on +/// first run. +/// +/// Echo is seeded into the config rather than conjured at read time the +/// way it used to be. An implicit provider is one a person cannot see in +/// the file or edit from the phone, and the point of this app is that +/// configuration is visible and editable; if somebody deletes it, that was +/// a choice. pub const ECHO_PROVIDER: &str = "echo"; +pub const LOCAL_SETUP: &str = "this machine"; impl Config { - /// Every provider, built-in first. A configured provider named `echo` - /// wins, so the built-in can be redefined but never silently - /// duplicated. - pub fn providers(&self) -> Vec { - let mut providers = Vec::new(); - if !self.providers.iter().any(|p| p.name == ECHO_PROVIDER) { - providers.push(ProviderConfig { - name: ECHO_PROVIDER.to_string(), - kind: DriverKind::Echo, - command: None, - models: Vec::new(), - }); + pub fn setup(&self, name: &str) -> Option<&SetupConfig> { + self.setups.iter().find(|setup| setup.name == name) + } + + /// What a fresh install starts with: this machine, offering the echo + /// driver to prove the pipe and the Claude CLI to be useful. + /// + /// Echo runs in-process, so it belongs to the setup with no ssh -- + /// there is nothing for a transport to wrap, and offering it on a + /// remote machine would be a choice that changes nothing. + pub fn seed() -> SetupConfig { + SetupConfig { + name: LOCAL_SETUP.to_string(), + ssh: None, + providers: vec![ + ProviderConfig { + name: ECHO_PROVIDER.to_string(), + kind: DriverKind::Echo, + command: None, + models: Vec::new(), + }, + ProviderConfig { + name: "claude-cli".to_string(), + kind: DriverKind::ClaudeCli, + command: None, + models: ["fable", "opus", "sonnet", "haiku"] + .iter() + .map(|m| (*m).to_string()) + .collect(), + }, + ], } - providers.extend(self.providers.iter().cloned()); - providers - } - - pub fn provider(&self, name: &str) -> Option { - self.providers().into_iter().find(|p| p.name == name) - } - - pub fn host(&self, name: &str) -> Option { - self.hosts.iter().find(|host| host.name == name).cloned() } pub fn load(path: &Path) -> Result { match std::fs::read_to_string(path) { - Ok(text) => format::parse(&text) - .with_context(|| format!("{} is not valid config RON", path.display())), + Ok(text) => { + // `Config` defaults unknown fields away, so a file from + // before setups existed would load as "no setups at all" + // and be re-seeded over -- losing every configured + // provider and host without a word. Say so instead. + if text.contains("\nproviders:") || text.contains("\nhosts:") { + bail!( + "{} is in the old shape: `providers` and `hosts` were separate lists, \ + and are now `setups`, each carrying the providers that machine has. \ + Rewrite it as `setups: [(name: \"...\", providers: [...])]` -- a \ + setup with no `ssh` is this machine.", + path.display(), + ); + } + format::parse(&text) + .with_context(|| format!("{} is not valid config RON", path.display())) + } // A first run has no config -- the normal starting state; a // token is generated and saved on that first start. Err(err) if err.kind() == std::io::ErrorKind::NotFound => { @@ -317,39 +364,41 @@ mod tests { let dir = tempfile::tempdir().expect("tempdir"); let path = dir.path().join("config.ron"); - // A missing file is the ordinary first-run state, not an error -- - // and echo is offered even then, with nothing configured. + // 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 + // 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.sessions.is_empty()); - assert_eq!(first_run.providers().len(), 1); - assert_eq!( - first_run.provider(ECHO_PROVIDER).expect("built-in").kind, - DriverKind::Echo - ); let config = Config { tokens: vec![TokenEntry { name: "phone".to_string(), sha256: "ab".repeat(32), }], - providers: vec![ProviderConfig { - name: "claude-cli".to_string(), - kind: DriverKind::ClaudeCli, - command: None, - models: vec!["haiku".to_string()], - }], - hosts: vec![HostConfig { - name: "vm".to_string(), - address: "bob@10.0.2.15".to_string(), - port: Some(2222), - identity_file: None, - options: Vec::new(), - }], + setups: vec![ + Config::seed(), + SetupConfig { + name: "vm".to_string(), + ssh: Some(SshConfig { + address: "bob@10.0.2.15".to_string(), + port: Some(2222), + identity_file: None, + options: Vec::new(), + }), + providers: vec![ProviderConfig { + name: "claude-cli".to_string(), + kind: DriverKind::ClaudeCli, + command: None, + models: vec!["haiku".to_string()], + }], + }, + ], sessions: vec![SessionConfig { id: "abc123".to_string(), + setup: "vm".to_string(), provider: "claude-cli".to_string(), - host: Some("vm".to_string()), title: "test".to_string(), model: None, cwd: None, @@ -362,20 +411,28 @@ mod tests { let loaded = Config::load(&path).expect("reload"); assert_eq!(loaded.tokens[0].name, "phone"); - assert_eq!(loaded.sessions[0].id, "abc123"); + assert_eq!(loaded.sessions[0].setup, "vm"); assert_eq!(loaded.sessions[0].provider, "claude-cli"); - assert_eq!(loaded.sessions[0].host.as_deref(), Some("vm")); - assert_eq!(loaded.host("vm").expect("host").port, Some(2222)); - // Built-in echo plus the configured one; any provider can run on - // any host, so they are listed independently. assert_eq!( loaded - .providers() - .iter() - .map(|p| p.name.clone()) - .collect::>(), - ["echo", "claude-cli"], + .setup("vm") + .expect("setup") + .ssh + .as_ref() + .expect("ssh") + .port, + 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. + assert!( + loaded + .setup(LOCAL_SETUP) + .expect("local") + .provider("claude-cli") + .is_some() + ); + assert!(loaded.setup(LOCAL_SETUP).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 @@ -398,21 +455,40 @@ mod tests { } #[test] - fn a_configured_echo_provider_replaces_the_built_in_one() { - let config = Config { - providers: vec![ProviderConfig { - name: ECHO_PROVIDER.to_string(), - kind: DriverKind::ClaudeCli, - command: Some("/opt/claude".to_string()), - models: Vec::new(), - }], - ..Config::default() - }; - // One entry, not two: the built-in is skipped rather than shadowed. - assert_eq!(config.providers().len(), 1); + /// A config from before setups existed must not load as an empty one. + /// `Config` defaults unknown fields away, so without this check the + /// old `providers` and `hosts` would vanish and be silently re-seeded + /// over -- the worst kind of migration, the sort nobody notices. + fn a_config_in_the_old_shape_is_refused_rather_than_emptied() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("config.ron"); + std::fs::write( + &path, + "tokens: [],\nproviders: [\n (name: \"claude-cli\", kind: claude_cli),\n],\nhosts: [],\nsessions: [],\n", + ) + .expect("write"); + let err = Config::load(&path).expect_err("should refuse"); + let message = format!("{err:#}"); + assert!(message.contains("old shape"), "{message}"); + assert!(message.contains("setups"), "{message}"); + } + + #[test] + /// What a fresh install can do before anybody configures anything: + /// echo to prove the pipe, and the Claude CLI to be useful. Both are + /// on the machine the server runs on, and echo belongs there because + /// it runs in-process -- there is no transport for it to cross. + fn the_seed_setup_is_this_machine_and_can_spawn_something() { + let seed = Config::seed(); + assert_eq!(seed.name, LOCAL_SETUP); + assert!(seed.ssh.is_none()); assert_eq!( - config.provider(ECHO_PROVIDER).expect("provider").kind, - DriverKind::ClaudeCli + seed.provider(ECHO_PROVIDER).expect("echo").kind, + DriverKind::Echo + ); + assert_eq!( + seed.provider("claude-cli").expect("claude").kind, + DriverKind::ClaudeCli, ); } } diff --git a/server/src/main.rs b/server/src/main.rs index 2ae73ab..f88bb93 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -199,11 +199,17 @@ async fn main() -> Result<()> { ); tracing::info!("config: {}", config_path.display()); tracing::info!("models: {}", models_dir.display()); - for provider in manager.providers() { - tracing::info!(" provider {} ({:?})", provider.name, provider.kind); - } - for host in manager.hosts() { - tracing::info!(" host {} -> {}", host.name, host.address); + 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 provider in &setup.providers { + tracing::info!(" provider {} ({:?})", provider.name, provider.kind); + } } for info in manager.sessions() { tracing::info!( diff --git a/server/src/routes.rs b/server/src/routes.rs index 55ca9f8..1a8310c 100644 --- a/server/src/routes.rs +++ b/server/src/routes.rs @@ -3,10 +3,9 @@ //! wraps the whole router in. //! //! ```text -//! GET /providers what can be spawned -//! GET /hosts machines a session can be run on +//! GET /setups machines, each with what it can run //! GET /sessions list (id, provider, title, model, status, last activity) -//! POST /sessions spawn {provider, title?, model?, cwd?, permissionMode?} +//! POST /sessions spawn {setup, provider, title?, model?, cwd?, params?} //! GET /sessions/{id}/events?after=N SSE: transcript replay from N, then live //! POST /sessions/{id}/message {text, attachmentIds?} //! POST /sessions/{id}/answer {questionId, answer} (questions and permissions) @@ -46,8 +45,7 @@ use crate::session::{LiveSession, SessionInfo, SessionManager, SpawnSpec}; pub fn router(manager: Arc) -> Router { Router::new() - .route("/providers", get(list_providers)) - .route("/hosts", get(list_hosts)) + .route("/setups", get(list_setups)) .route("/sessions", get(list_sessions).post(spawn_session)) .route("/sessions/{id}", delete(delete_session)) .route("/sessions/{id}/events", get(events)) @@ -113,9 +111,25 @@ async fn list_sessions(State(manager): State>) -> axum::Json } /// What the spawn screen needs to render itself, so the phone holds no -/// hardcoded list: an entry added to `config.ron` shows up with no app -/// rebuild. Providers and hosts are listed separately because they are -/// independent choices -- any provider can be run on any host. +/// hardcoded list: a setup added to `config.ron` shows up with no app +/// rebuild. +/// +/// One list rather than two, because the choice is a pair and the halves +/// are not independent. A provider only exists on a machine that has it +/// installed, so listing providers and machines separately offered their +/// whole cross-product -- including "the Claude CLI on the box that hasn't +/// got it". +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct SetupInfo { + name: String, + /// Where it runs, for telling two setups apart. Absent for the one + /// that is this machine. + #[serde(skip_serializing_if = "Option::is_none")] + address: Option, + providers: Vec, +} + #[derive(serde::Serialize)] #[serde(rename_all = "camelCase")] struct ProviderInfo { @@ -124,41 +138,23 @@ struct ProviderInfo { models: Vec, } -async fn list_providers( - State(manager): State>, -) -> axum::Json> { +async fn list_setups(State(manager): State>) -> axum::Json> { axum::Json( manager - .providers() + .setups() .into_iter() - .map(|provider| ProviderInfo { - name: provider.name, - kind: provider.kind, - models: provider.models, - }) - .collect(), - ) -} - -#[derive(serde::Serialize)] -#[serde(rename_all = "camelCase")] -struct HostInfo { - name: String, - /// Shown under the name so a host can be told apart from its label. - address: String, -} - -/// Configured remote machines. Running on the backend itself is always -/// available and deliberately absent here -- it is the "no host" case, not -/// an entry that could be edited away. -async fn list_hosts(State(manager): State>) -> axum::Json> { - axum::Json( - manager - .hosts() - .into_iter() - .map(|host| HostInfo { - name: host.name, - address: host.address, + .map(|setup| SetupInfo { + name: setup.name, + address: setup.ssh.map(|ssh| ssh.address), + providers: setup + .providers + .into_iter() + .map(|provider| ProviderInfo { + name: provider.name, + kind: provider.kind, + models: provider.models, + }) + .collect(), }) .collect(), ) @@ -167,10 +163,9 @@ async fn list_hosts(State(manager): State>) -> axum::Json, #[serde(default)] title: Option, #[serde(default)] @@ -192,8 +187,8 @@ async fn spawn_session( ) -> Result, ApiError> { let info = manager .spawn_session(SpawnSpec { + setup: body.setup, provider: body.provider, - host: body.host, title: body.title, model: body.model, cwd: body.cwd, diff --git a/server/src/session/mod.rs b/server/src/session/mod.rs index 5774e1e..75dd026 100644 --- a/server/src/session/mod.rs +++ b/server/src/session/mod.rs @@ -25,7 +25,7 @@ use anyhow::{Context, Result, bail}; use serde::Serialize; use tokio::sync::{broadcast, mpsc}; -use crate::config::{Config, DriverKind, HostConfig, ProviderConfig, SessionConfig, TokenEntry}; +use crate::config::{Config, DriverKind, ProviderConfig, SessionConfig, SetupConfig, TokenEntry}; use claude::ClaudeDriver; use driver::{Driver, Event, ImageRef, SessionStatus}; use echo::EchoDriver; @@ -47,9 +47,9 @@ pub fn now() -> f64 { /// What the phone needs to spawn a session -- the spawn screen's fields. pub struct SpawnSpec { + /// Which machine, and which of its providers. + pub setup: String, pub provider: String, - /// Name of a configured host to run on; absent runs on this machine. - pub host: Option, pub title: Option, pub model: Option, pub cwd: Option, @@ -64,9 +64,8 @@ pub struct SpawnSpec { pub struct SessionInfo { pub id: String, pub provider: String, - /// Name of the host it runs on; absent means the backend machine. - #[serde(skip_serializing_if = "Option::is_none")] - pub host: Option, + /// The machine it runs on. + pub setup: String, pub title: String, #[serde(skip_serializing_if = "Option::is_none")] pub model: Option, @@ -172,7 +171,7 @@ impl LiveSession { SessionInfo { id: self.meta.id.clone(), provider: self.meta.provider.clone(), - host: self.meta.host.clone(), + setup: self.meta.setup.clone(), title: self.meta.title.clone(), model: self.shared.model.lock().unwrap().clone(), cwd: self.meta.cwd.clone(), @@ -216,14 +215,8 @@ impl SessionManager { // unreachable ssh host, a provider that was edited away -- // shows as exited rather than taking the whole server down // with it, and can still be deleted from the phone. - match resolve(&config, meta).and_then(|(provider, host)| { - launch( - meta.clone(), - &provider, - host.as_ref(), - &data_dir, - &models_dir, - ) + match resolve(&config, meta).and_then(|(setup, provider)| { + launch(meta.clone(), &setup, &provider, &data_dir, &models_dir) }) { Ok(session) => { live.insert(meta.id.clone(), session); @@ -239,7 +232,7 @@ impl SessionManager { models_dir, inner: RwLock::new(Inner { config, live }), }; - manager.seed_providers()?; + manager.seed_setup()?; Ok(manager) } @@ -247,24 +240,22 @@ impl SessionManager { /// so a fresh install has something to spawn and a worked example of /// the schema to edit. Runs local by default -- a session is given a /// host when the CLI lives elsewhere, which is a per-session choice. - fn seed_providers(&self) -> Result<()> { + /// Gives a fresh install something to spawn. Only ever fires when + /// there are no setups at all -- deleting the last one is a choice, + /// not a state to be repaired. + fn seed_setup(&self) -> Result<()> { let mut inner = self.inner.write().unwrap(); - if !inner.config.providers.is_empty() { + if !inner.config.setups.is_empty() { return Ok(()); } let mut candidate = inner.config.clone(); - candidate.providers.push(ProviderConfig { - name: "claude-cli".to_string(), - kind: DriverKind::ClaudeCli, - command: None, - models: ["fable", "opus", "sonnet", "haiku"] - .iter() - .map(|model| model.to_string()) - .collect(), - }); + candidate.setups.push(Config::seed()); candidate.save(&self.config_path)?; inner.config = candidate; - tracing::info!("no providers configured -- added a default \"claude-cli\" provider"); + tracing::info!( + "no setups configured -- added \"{}\"", + crate::config::LOCAL_SETUP + ); Ok(()) } @@ -311,8 +302,8 @@ impl SessionManager { Some(session) => session.info(), None => SessionInfo { id: meta.id.clone(), + setup: meta.setup.clone(), provider: meta.provider.clone(), - host: meta.host.clone(), title: meta.title.clone(), model: meta.model.clone(), cwd: meta.cwd.clone(), @@ -329,55 +320,45 @@ impl SessionManager { } /// Every provider this server offers, built-in echo included. - pub fn providers(&self) -> Vec { - self.inner.read().unwrap().config.providers() - } - - /// Every configured host a session can be run on. Running on the - /// backend itself is always available and is not in this list. - pub fn hosts(&self) -> Vec { - self.inner.read().unwrap().config.hosts.clone() + /// 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 { + self.inner.read().unwrap().config.setups.clone() } pub fn spawn_session(&self, spec: SpawnSpec) -> Result { let mut inner = self.inner.write().unwrap(); - let provider = inner.config.provider(&spec.provider).with_context(|| { - format!( - "no provider named \"{}\" -- configured: {}", - spec.provider, - inner - .config - .providers() - .iter() - .map(|p| p.name.clone()) - .collect::>() - .join(", "), - ) - })?; + let setup = inner + .config + .setup(&spec.setup) + .with_context(|| { + format!( + "no setup named \"{}\" -- configured: {}", + spec.setup, + names(inner.config.setups.iter().map(|s| s.name.as_str())), + ) + })? + .clone(); + let provider = setup + .provider(&spec.provider) + .with_context(|| { + format!( + "setup \"{}\" has no provider named \"{}\" -- it offers: {}", + spec.setup, + spec.provider, + names(setup.providers.iter().map(|p| p.name.as_str())), + ) + })? + .clone(); let id = unique_id(&inner.config); let title = spec .title .filter(|title| !title.trim().is_empty()) .unwrap_or_else(|| format!("{} session", provider.name)); - let host = match &spec.host { - Some(name) => Some(inner.config.host(name).with_context(|| { - format!( - "no host named \"{name}\" -- configured: {}", - inner - .config - .hosts - .iter() - .map(|host| host.name.clone()) - .collect::>() - .join(", "), - ) - })?), - None => None, - }; let meta = SessionConfig { id: id.clone(), + setup: setup.name.clone(), provider: provider.name.clone(), - host: spec.host, title, model: spec.model.or_else(|| provider.models.first().cloned()), cwd: spec.cwd, @@ -388,8 +369,8 @@ impl SessionManager { let session = launch( meta.clone(), + &setup, &provider, - host.as_ref(), &self.data_dir, &self.models_dir, )?; @@ -456,19 +437,28 @@ impl SessionManager { /// 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<(ProviderConfig, Option)> { - let provider = config - .provider(&meta.provider) - .with_context(|| format!("no provider named \"{}\"", meta.provider))?; - let host = match &meta.host { - Some(name) => Some( - config - .host(name) - .with_context(|| format!("no host named \"{name}\""))?, - ), - None => None, - }; - Ok((provider, host)) +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(|| { + format!( + "setup \"{}\" has no provider named \"{}\"", + meta.setup, meta.provider + ) + })?; + Ok((setup.clone(), provider.clone())) +} + +/// Names for a failure message: what there is, so the reader can see what +/// they meant instead of only that they were wrong. +fn names<'a>(all: impl Iterator) -> String { + let all: Vec<_> = all.collect(); + if all.is_empty() { + "none".to_string() + } else { + all.join(", ") + } } /// 8 random bytes, hex -- short enough for a URL, unique enough forever at @@ -495,8 +485,8 @@ fn unique_id(config: &Config) -> String { /// event pump connecting them. fn launch( meta: SessionConfig, + setup: &SetupConfig, provider: &ProviderConfig, - host: Option<&HostConfig>, data_dir: &Path, models_dir: &Path, ) -> Result> { @@ -518,7 +508,7 @@ fn launch( DriverKind::LlamaCpp => Box::new(LlamaDriver::spawn( &meta, provider, - &Transport::for_host(host), + &Transport::for_setup(setup), models_dir, &transcript_path, sink.clone(), @@ -526,7 +516,7 @@ fn launch( DriverKind::ClaudeCli => Box::new(ClaudeDriver::spawn( &meta, provider, - &Transport::for_host(host), + &Transport::for_setup(setup), &dir, sink.clone(), )?), @@ -587,8 +577,8 @@ mod tests { fn echo_spec() -> SpawnSpec { SpawnSpec { params: Default::default(), + setup: crate::config::LOCAL_SETUP.to_string(), provider: crate::config::ECHO_PROVIDER.to_string(), - host: None, title: None, model: None, cwd: None, diff --git a/server/src/session/transport.rs b/server/src/session/transport.rs index 79db4a6..ab6633c 100644 --- a/server/src/session/transport.rs +++ b/server/src/session/transport.rs @@ -24,7 +24,7 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use tokio::process::Child; -use crate::config::HostConfig; +use crate::config::SshConfig; /// What a driver needs run in order to exist as a process. /// @@ -51,17 +51,21 @@ impl Launch { pub enum Transport { /// The machine this server is running on. Here, - /// Reached with the system `ssh` client. Owns its host entry rather - /// than borrowing it, so a session keeps working against the config it - /// was spawned with even if that entry is edited afterwards. - Ssh(HostConfig), + /// Reached with the system `ssh` client. Owns its entry rather than + /// borrowing it, so a session keeps working against the config it was + /// spawned with even if the setup is edited afterwards. Carries the + /// setup's name only to say where things are running. + Ssh { name: String, ssh: SshConfig }, } impl Transport { - /// The transport a session's configured host names; absent is [`Self::Here`]. - pub fn for_host(host: Option<&HostConfig>) -> Self { - match host { - Some(host) => Self::Ssh(host.clone()), + /// The transport a setup describes; a setup with no `ssh` is here. + pub fn for_setup(setup: &crate::config::SetupConfig) -> Self { + match &setup.ssh { + Some(ssh) => Self::Ssh { + name: setup.name.clone(), + ssh: ssh.clone(), + }, None => Self::Here, } } @@ -75,14 +79,15 @@ impl Transport { pub fn spawn(&self, launch: &Launch) -> Result { let host = match self { Self::Here => None, - Self::Ssh(host) => Some(host), + Self::Ssh { ssh, .. } => Some(ssh), }; crate::ssh::command(host, &launch.program, &launch.args, launch.cwd.as_deref()) .spawn() .with_context(|| match self { - Self::Ssh(host) => format!( - "couldn't start ssh to run \"{}\" on {} -- is the ssh client installed here?", - launch.program, host.name, + Self::Ssh { name, .. } => format!( + "couldn't start ssh to run \"{}\" on {name} -- is the ssh client installed \ + here?", + launch.program, ), Self::Here => format!( "couldn't run \"{}\" on this machine -- is it installed and on PATH? If it \ @@ -96,7 +101,7 @@ impl Transport { pub fn describe(&self) -> String { match self { Self::Here => "on this machine".to_string(), - Self::Ssh(host) => format!("on {} ({})", host.name, host.address), + Self::Ssh { name, ssh } => format!("on {name} ({})", ssh.address), } } } diff --git a/server/src/ssh.rs b/server/src/ssh.rs index 7551475..d196277 100644 --- a/server/src/ssh.rs +++ b/server/src/ssh.rs @@ -15,7 +15,7 @@ use std::process::Stdio; use tokio::process::Command; -use crate::config::HostConfig; +use crate::config::SshConfig; /// Options forced onto every connection. `BatchMode` makes a missing key /// fail immediately with a readable message instead of hanging on a @@ -29,14 +29,14 @@ const SSH_OPTIONS: [&str; 3] = [ ]; /// Builds the child process for `program args…`, run in `cwd`, either on -/// this machine (`host` absent) or on `host`. +/// this machine (`ssh` absent) or on the machine it describes. pub fn command( - host: Option<&HostConfig>, + remote: Option<&SshConfig>, program: &str, args: &[String], cwd: Option<&Path>, ) -> Command { - let Some(ssh) = host else { + let Some(ssh) = remote else { let mut command = Command::new(program); command.args(args); if let Some(cwd) = cwd { @@ -132,9 +132,8 @@ mod tests { /// A host with nothing configured but a name to dial, so `~/.ssh/config` /// decides everything else -- the case that proves this adds no flags of /// its own when it was not told to. - fn bare_host() -> HostConfig { - HostConfig { - name: "vm".to_string(), + fn bare_host() -> SshConfig { + SshConfig { address: "vm".to_string(), port: None, identity_file: None, @@ -159,8 +158,7 @@ mod tests { #[test] fn a_session_with_a_host_wraps_the_same_command_in_ssh() { - let ssh = HostConfig { - name: "vm".to_string(), + let ssh = SshConfig { address: "bob@10.0.2.15".to_string(), port: Some(2222), identity_file: Some("/home/me/.ssh/id_ai".into()),