A setup is a machine, and it carries what that machine can run
Providers and hosts were two independent lists, and a session named one of each. They were never independent: a provider only exists on a machine where that program is installed, so the spawn screen offered the whole cross-product, including "the Claude CLI on the box that hasn't got it". The picker could not know, because nothing in the model said. Now a setup is a machine -- optional ssh, plus the providers it has -- and spawning is two choices in order: pick a setup, then one of its providers. The impossible pairs stop being expressible rather than being validated against. Provider names are unique within a setup and only within one, so two machines can each have a `claude-cli`, which was previously either a name collision or two entries called things like "claude" and "claude on the vm". It also settles the "Run on" problem properly. That control was offered for every provider but honoured only by the Claude driver -- an echo session sent to a host ran locally and said otherwise. There is no such control now: the machine is chosen first, and echo is a provider of the setup with no ssh, where it belongs, since it runs in-process and has no transport to cross. The built-in echo provider is gone as a concept. It used to be conjured at read time and never written to the file, which meant a provider nobody could see or edit; it is now seeded into the config on first run alongside claude-cli. What the file says is what there is, and deleting it is a choice rather than a state to be repaired. A config in the old shape is refused with instructions rather than loaded. `Config` defaults unknown fields away, so `providers:` and `hosts:` would otherwise have vanished into an empty config that was then seeded over -- a migration nobody would notice until their setups were gone. Verified against a running server and on the emulator: a fresh install seeds "this machine" with echo and claude-cli and the file reads cleanly; a two-setup config lists both with their own providers; spawning on a setup works and the session row names it; asking for a provider a setup lacks says which it offers, and an unknown setup says which exist. On the phone, selecting "dev vm" narrows the provider chips to that machine's one and shows its address. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
This commit is contained in:
1 parent
7cf36005ae
commit
ecac404fd4
10 files changed
+392
-298
No files matched your search
@@ -106,13 +106,13 @@ private fun JSONArray.strings(): List<String> = (0 until length()).map { getStri
|
|||||||
/** Percent-encodes a value going into a query string. */
|
/** Percent-encodes a value going into a query string. */
|
||||||
private fun String.urlEncoded(): String = java.net.URLEncoder.encode(this, Charsets.UTF_8.name())
|
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 --
|
// One row of GET /sessions. A session names the machine it runs on and
|
||||||
// the two are independent, so a session names both.
|
// which of that machine's providers it runs.
|
||||||
data class SessionSummary(
|
data class SessionSummary(
|
||||||
val id: String,
|
val id: String,
|
||||||
|
val setup: String,
|
||||||
val provider: String,
|
val provider: String,
|
||||||
val title: String,
|
val title: String,
|
||||||
val host: String?,
|
|
||||||
val model: String?,
|
val model: String?,
|
||||||
val status: String,
|
val status: String,
|
||||||
val lastActivity: Double,
|
val lastActivity: Double,
|
||||||
@@ -121,9 +121,9 @@ data class SessionSummary(
|
|||||||
private fun parseSession(session: JSONObject) =
|
private fun parseSession(session: JSONObject) =
|
||||||
SessionSummary(
|
SessionSummary(
|
||||||
id = session.getString("id"),
|
id = session.getString("id"),
|
||||||
|
setup = session.getString("setup"),
|
||||||
provider = session.getString("provider"),
|
provider = session.getString("provider"),
|
||||||
title = session.getString("title"),
|
title = session.getString("title"),
|
||||||
host = session.optString("host").ifEmpty { null },
|
|
||||||
model = session.optString("model").ifEmpty { null },
|
model = session.optString("model").ifEmpty { null },
|
||||||
status = session.getString("status"),
|
status = session.getString("status"),
|
||||||
lastActivity = session.getDouble("lastActivity"),
|
lastActivity = session.getDouble("lastActivity"),
|
||||||
@@ -133,43 +133,48 @@ fun fetchSessions(settings: ServerSettings): List<SessionSummary> =
|
|||||||
requestFromServer(settings, "/sessions") { it.jsonObjects(::parseSession) }
|
requestFromServer(settings, "/sessions") { it.jsonObjects(::parseSession) }
|
||||||
|
|
||||||
// What the server offers, so the spawn screen has no hardcoded lists: a
|
// 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
|
// setup added to the server's config.ron appears here with no app rebuild.
|
||||||
// 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<String>)
|
data class Provider(val name: String, val kind: String, val models: List<String>)
|
||||||
|
|
||||||
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<Provider>)
|
||||||
|
|
||||||
fun fetchProviders(settings: ServerSettings): List<Provider> =
|
fun fetchSetups(settings: ServerSettings): List<Setup> =
|
||||||
requestFromServer(settings, "/providers") { connection ->
|
requestFromServer(settings, "/setups") { connection ->
|
||||||
connection.jsonObjects { provider ->
|
connection.jsonObjects { setup ->
|
||||||
|
Setup(
|
||||||
|
name = setup.getString("name"),
|
||||||
|
address = setup.optString("address").ifEmpty { null },
|
||||||
|
providers =
|
||||||
|
setup.getJSONArray("providers").mapObjects { provider ->
|
||||||
Provider(
|
Provider(
|
||||||
name = provider.getString("name"),
|
name = provider.getString("name"),
|
||||||
kind = provider.getString("kind"),
|
kind = provider.getString("kind"),
|
||||||
// Omitted entirely when the provider offers none.
|
// Omitted entirely when the provider offers none.
|
||||||
models = provider.optJSONArray("models")?.strings().orEmpty(),
|
models = provider.optJSONArray("models")?.strings().orEmpty(),
|
||||||
)
|
)
|
||||||
}
|
},
|
||||||
}
|
)
|
||||||
|
|
||||||
fun fetchHosts(settings: ServerSettings): List<RemoteHost> =
|
|
||||||
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
|
* Spawns a session and returns it as the list would show it. [setup] names the machine and
|
||||||
* host, or null to run on the backend machine itself.
|
* [provider] one of the things that machine offers.
|
||||||
*/
|
*/
|
||||||
fun spawnSession(
|
fun spawnSession(
|
||||||
settings: ServerSettings,
|
settings: ServerSettings,
|
||||||
|
setup: String,
|
||||||
provider: String,
|
provider: String,
|
||||||
title: String,
|
title: String,
|
||||||
host: String? = null,
|
|
||||||
model: String? = null,
|
model: String? = null,
|
||||||
cwd: String? = null,
|
cwd: String? = null,
|
||||||
permissionMode: String? = null,
|
permissionMode: String? = null,
|
||||||
|
params: Map<String, String> = emptyMap(),
|
||||||
): SessionSummary =
|
): SessionSummary =
|
||||||
requestFromServer(
|
requestFromServer(
|
||||||
settings,
|
settings,
|
||||||
@@ -177,13 +182,16 @@ fun spawnSession(
|
|||||||
method = "POST",
|
method = "POST",
|
||||||
jsonBody =
|
jsonBody =
|
||||||
JSONObject()
|
JSONObject()
|
||||||
|
.put("setup", setup)
|
||||||
.put("provider", provider)
|
.put("provider", provider)
|
||||||
.put("title", title)
|
.put("title", title)
|
||||||
.apply {
|
.apply {
|
||||||
if (!host.isNullOrBlank()) put("host", host)
|
|
||||||
if (!model.isNullOrBlank()) put("model", model)
|
if (!model.isNullOrBlank()) put("model", model)
|
||||||
if (!cwd.isNullOrBlank()) put("cwd", cwd)
|
if (!cwd.isNullOrBlank()) put("cwd", cwd)
|
||||||
if (!permissionMode.isNullOrBlank()) put("permissionMode", permissionMode)
|
if (!permissionMode.isNullOrBlank()) put("permissionMode", permissionMode)
|
||||||
|
if (params.isNotEmpty()) {
|
||||||
|
put("params", JSONObject(params.toMap<String, Any>()))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.toString(),
|
.toString(),
|
||||||
readTimeoutMs = 30000,
|
readTimeoutMs = 30000,
|
||||||
|
|||||||
@@ -216,7 +216,7 @@ private fun SessionCard(
|
|||||||
// than a bare name, so a host isn't mistaken for a model.
|
// than a bare name, so a host isn't mistaken for a model.
|
||||||
listOfNotNull(
|
listOfNotNull(
|
||||||
session.provider,
|
session.provider,
|
||||||
session.host?.let { "on $it" },
|
"on ${session.setup}",
|
||||||
session.model,
|
session.model,
|
||||||
)
|
)
|
||||||
.joinToString(" · "),
|
.joinToString(" · "),
|
||||||
|
|||||||
@@ -242,7 +242,7 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
|||||||
Text(
|
Text(
|
||||||
listOfNotNull(
|
listOfNotNull(
|
||||||
summary.provider,
|
summary.provider,
|
||||||
summary.host?.let { "on $it" },
|
"on ${summary.setup}",
|
||||||
summary.model,
|
summary.model,
|
||||||
if (totalTokens > 0) "$totalTokens tok" else null,
|
if (totalTokens > 0) "$totalTokens tok" else null,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ import kotlinx.coroutines.withContext
|
|||||||
private val PERMISSION_MODES = listOf("manual", "acceptEdits", "auto", "bypassPermissions", "plan")
|
private val PERMISSION_MODES = listOf("manual", "acceptEdits", "auto", "bypassPermissions", "plan")
|
||||||
|
|
||||||
/** Runs on the backend machine itself -- the "no host" case. */
|
/** 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.
|
* 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
|
// 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
|
// is not the same as a server with nothing to offer, so it must not
|
||||||
// reach the pickers as empty lists -- see LoadState.
|
// reach the pickers as empty lists -- see LoadState.
|
||||||
var options by remember { mutableStateOf<LoadState<SpawnOptions>>(LoadState.Loading) }
|
var options by remember { mutableStateOf<LoadState<List<Setup>>>(LoadState.Loading) }
|
||||||
|
|
||||||
var provider by remember { mutableStateOf<Provider?>(null) }
|
// Setup first, then one of its providers. Choosing a setup can
|
||||||
var host by remember { mutableStateOf<String?>(null) }
|
// 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<String?>(null) }
|
||||||
|
var providerName by remember { mutableStateOf<String?>(null) }
|
||||||
var title by remember { mutableStateOf("") }
|
var title by remember { mutableStateOf("") }
|
||||||
var model by remember { mutableStateOf("") }
|
var model by remember { mutableStateOf("") }
|
||||||
var cwd by remember { mutableStateOf("") }
|
var cwd by remember { mutableStateOf("") }
|
||||||
@@ -74,23 +77,16 @@ fun SpawnScreen(
|
|||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
options =
|
options =
|
||||||
try {
|
try {
|
||||||
val fetched =
|
val fetched = withContext(Dispatchers.IO) { fetchSetups(settings) }
|
||||||
withContext(Dispatchers.IO) {
|
val first = fetched.firstOrNull()
|
||||||
SpawnOptions(fetchProviders(settings), fetchHosts(settings))
|
setupName = first?.name
|
||||||
}
|
providerName = first?.providers?.firstOrNull()?.name
|
||||||
provider = fetched.providers.firstOrNull()
|
|
||||||
LoadState.Loaded(fetched)
|
LoadState.Loaded(fetched)
|
||||||
} catch (e: ApiException) {
|
} catch (e: ApiException) {
|
||||||
LoadState.failed(e)
|
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)) {
|
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||||
Text(
|
Text(
|
||||||
@@ -106,7 +102,7 @@ fun SpawnScreen(
|
|||||||
// failure to fetch them leaves no form worth showing -- so this
|
// failure to fetch them leaves no form worth showing -- so this
|
||||||
// reports and stops, rather than offering empty pickers under an
|
// reports and stops, rather than offering empty pickers under an
|
||||||
// error message.
|
// error message.
|
||||||
val (providers, hosts) =
|
val setups =
|
||||||
when (val state = options) {
|
when (val state = options) {
|
||||||
is LoadState.Loading -> {
|
is LoadState.Loading -> {
|
||||||
CircularProgressIndicator()
|
CircularProgressIndicator()
|
||||||
@@ -118,33 +114,56 @@ fun SpawnScreen(
|
|||||||
}
|
}
|
||||||
is LoadState.Loaded -> state.value
|
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(
|
ChipGroup(
|
||||||
label = "Provider",
|
label = "Setup",
|
||||||
options = providers.map { it.name },
|
options = setups.map { it.name },
|
||||||
selected = current?.name,
|
selected = setupName,
|
||||||
onSelect = { name -> provider = providers.first { it.name == name } },
|
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
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
setup?.address?.let {
|
||||||
// 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(
|
Text(
|
||||||
it.address,
|
it,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
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))
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
@@ -210,9 +229,9 @@ fun SpawnScreen(
|
|||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
spawnSession(
|
spawnSession(
|
||||||
settings,
|
settings,
|
||||||
|
setup = setup?.name.orEmpty(),
|
||||||
provider = chosen.name,
|
provider = chosen.name,
|
||||||
title = title.trim(),
|
title = title.trim(),
|
||||||
host = host,
|
|
||||||
model = model.trim().takeIf { isClaude },
|
model = model.trim().takeIf { isClaude },
|
||||||
cwd = cwd.trim().takeIf { isClaude },
|
cwd = cwd.trim().takeIf { isClaude },
|
||||||
permissionMode = permissionMode.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<Provider>, val hosts: List<RemoteHost>)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A labeled row of choices that wraps onto as many lines as it needs.
|
* A labeled row of choices that wraps onto as many lines as it needs.
|
||||||
*
|
*
|
||||||
|
|||||||
+167
-91
@@ -18,7 +18,7 @@
|
|||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result, bail};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::private;
|
use crate::private;
|
||||||
@@ -87,26 +87,48 @@ pub struct Config {
|
|||||||
/// the credential. A list (of one, today) so per-device tokens with
|
/// the credential. A list (of one, today) so per-device tokens with
|
||||||
/// individual revocation are a config entry later, not a migration.
|
/// individual revocation are a config entry later, not a migration.
|
||||||
pub tokens: Vec<TokenEntry>,
|
pub tokens: Vec<TokenEntry>,
|
||||||
/// What can be spawned. See [`ProviderConfig`].
|
/// Every machine this server can run something on, and what each of
|
||||||
pub providers: Vec<ProviderConfig>,
|
/// them can run. See [`SetupConfig`].
|
||||||
/// Machines a session can be told to run on. See [`HostConfig`].
|
pub setups: Vec<SetupConfig>,
|
||||||
pub hosts: Vec<HostConfig>,
|
|
||||||
pub sessions: Vec<SessionConfig>,
|
pub sessions: Vec<SessionConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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
|
/// This is the unit a session is spawned against: pick a setup, then one
|
||||||
/// session's [`SessionConfig::host`], because the two are independent.
|
/// of its providers. Grouping them this way is what stops the spawn
|
||||||
/// The same provider may run locally for one session and over SSH for the
|
/// screen offering combinations that cannot work -- a provider only
|
||||||
/// next, and pinning a machine here would make "the Claude CLI" and "the
|
/// exists on a machine where that program is installed, and the previous
|
||||||
/// Claude CLI on that box" two different things to configure and pick
|
/// model, which let any provider be paired with any host, offered the
|
||||||
/// between.
|
/// 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<SshConfig>,
|
||||||
|
/// What can be spawned here. Names are unique within a setup, and only
|
||||||
|
/// within it: two machines may each have a `claude-cli`, which is the
|
||||||
|
/// point.
|
||||||
|
#[serde(default)]
|
||||||
|
pub providers: Vec<ProviderConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct ProviderConfig {
|
pub struct ProviderConfig {
|
||||||
/// Shown on the spawn screen and stored by sessions that use it.
|
/// 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 name: String,
|
||||||
pub kind: DriverKind,
|
pub kind: DriverKind,
|
||||||
/// Override for the executable, for an install that isn't on PATH.
|
/// Override for the executable, for an install that isn't on PATH.
|
||||||
@@ -118,18 +140,15 @@ pub struct ProviderConfig {
|
|||||||
pub models: Vec<String>,
|
pub models: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A machine sessions can be run on, reached with the system `ssh` client
|
/// How to reach a setup that isn't this machine, with the system `ssh`
|
||||||
/// -- so `~/.ssh/config`, agents, and jump hosts all keep working, and
|
/// client -- so `~/.ssh/config`, agents, and jump hosts all keep working,
|
||||||
/// there is one place to configure connections (PLAN.md, rule 23).
|
/// and there is one place to configure connections (PLAN.md, rule 23).
|
||||||
///
|
///
|
||||||
/// Applies to any session of any provider: a remote session is the
|
/// A remote session is the identical command with `ssh host …` in front,
|
||||||
/// identical command with `ssh host …` in front, and nothing downstream of
|
/// and nothing downstream of the spawn knows the difference.
|
||||||
/// the spawn knows the difference.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct HostConfig {
|
pub struct SshConfig {
|
||||||
/// What the spawn screen shows and the session stores.
|
|
||||||
pub name: String,
|
|
||||||
/// `user@host`, or a `Host` alias from `~/.ssh/config`.
|
/// `user@host`, or a `Host` alias from `~/.ssh/config`.
|
||||||
pub address: String,
|
pub address: String,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
@@ -183,15 +202,13 @@ pub struct TokenEntry {
|
|||||||
pub struct SessionConfig {
|
pub struct SessionConfig {
|
||||||
/// Stable identifier; names the session's directory and its routes.
|
/// Stable identifier; names the session's directory and its routes.
|
||||||
pub id: String,
|
pub id: String,
|
||||||
/// Name of the [`ProviderConfig`] this session runs. Stored rather
|
/// Name of the [`SetupConfig`] this session runs on.
|
||||||
/// than the resolved driver so an edited provider (a new command path,
|
pub setup: String,
|
||||||
/// another model) takes effect on the next relaunch; a session whose
|
/// 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.
|
/// provider is gone reports as exited and can still be deleted.
|
||||||
pub provider: String,
|
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<String>,
|
|
||||||
pub title: String,
|
pub title: String,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub model: Option<String>,
|
pub model: Option<String>,
|
||||||
@@ -220,41 +237,71 @@ pub struct SessionConfig {
|
|||||||
pub created: f64,
|
pub created: f64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The name of the built-in echo provider. Always present, never written
|
/// The name of the echo provider, and of the setup this machine gets on
|
||||||
/// to the config file: it needs no configuration and gives every install a
|
/// first run.
|
||||||
/// working session type to test the pipe with.
|
///
|
||||||
|
/// 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 ECHO_PROVIDER: &str = "echo";
|
||||||
|
pub const LOCAL_SETUP: &str = "this machine";
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
/// Every provider, built-in first. A configured provider named `echo`
|
pub fn setup(&self, name: &str) -> Option<&SetupConfig> {
|
||||||
/// wins, so the built-in can be redefined but never silently
|
self.setups.iter().find(|setup| setup.name == name)
|
||||||
/// duplicated.
|
}
|
||||||
pub fn providers(&self) -> Vec<ProviderConfig> {
|
|
||||||
let mut providers = Vec::new();
|
/// What a fresh install starts with: this machine, offering the echo
|
||||||
if !self.providers.iter().any(|p| p.name == ECHO_PROVIDER) {
|
/// driver to prove the pipe and the Claude CLI to be useful.
|
||||||
providers.push(ProviderConfig {
|
///
|
||||||
|
/// 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(),
|
name: ECHO_PROVIDER.to_string(),
|
||||||
kind: DriverKind::Echo,
|
kind: DriverKind::Echo,
|
||||||
command: None,
|
command: None,
|
||||||
models: Vec::new(),
|
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<ProviderConfig> {
|
|
||||||
self.providers().into_iter().find(|p| p.name == name)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn host(&self, name: &str) -> Option<HostConfig> {
|
|
||||||
self.hosts.iter().find(|host| host.name == name).cloned()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn load(path: &Path) -> Result<Self> {
|
pub fn load(path: &Path) -> Result<Self> {
|
||||||
match std::fs::read_to_string(path) {
|
match std::fs::read_to_string(path) {
|
||||||
Ok(text) => format::parse(&text)
|
Ok(text) => {
|
||||||
.with_context(|| format!("{} is not valid config RON", path.display())),
|
// `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
|
// A first run has no config -- the normal starting state; a
|
||||||
// token is generated and saved on that first start.
|
// token is generated and saved on that first start.
|
||||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
|
||||||
@@ -317,39 +364,41 @@ mod tests {
|
|||||||
let dir = tempfile::tempdir().expect("tempdir");
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
let path = dir.path().join("config.ron");
|
let path = dir.path().join("config.ron");
|
||||||
|
|
||||||
// A missing file is the ordinary first-run state, not an error --
|
// A missing file is the ordinary first-run state, not an error.
|
||||||
// and echo is offered even then, with nothing configured.
|
// 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");
|
let first_run = Config::load(&path).expect("load");
|
||||||
assert!(first_run.tokens.is_empty());
|
assert!(first_run.tokens.is_empty());
|
||||||
|
assert!(first_run.setups.is_empty());
|
||||||
assert!(first_run.sessions.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 {
|
let config = Config {
|
||||||
tokens: vec![TokenEntry {
|
tokens: vec![TokenEntry {
|
||||||
name: "phone".to_string(),
|
name: "phone".to_string(),
|
||||||
sha256: "ab".repeat(32),
|
sha256: "ab".repeat(32),
|
||||||
}],
|
}],
|
||||||
|
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 {
|
providers: vec![ProviderConfig {
|
||||||
name: "claude-cli".to_string(),
|
name: "claude-cli".to_string(),
|
||||||
kind: DriverKind::ClaudeCli,
|
kind: DriverKind::ClaudeCli,
|
||||||
command: None,
|
command: None,
|
||||||
models: vec!["haiku".to_string()],
|
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(),
|
|
||||||
}],
|
|
||||||
sessions: vec![SessionConfig {
|
sessions: vec![SessionConfig {
|
||||||
id: "abc123".to_string(),
|
id: "abc123".to_string(),
|
||||||
|
setup: "vm".to_string(),
|
||||||
provider: "claude-cli".to_string(),
|
provider: "claude-cli".to_string(),
|
||||||
host: Some("vm".to_string()),
|
|
||||||
title: "test".to_string(),
|
title: "test".to_string(),
|
||||||
model: None,
|
model: None,
|
||||||
cwd: None,
|
cwd: None,
|
||||||
@@ -362,20 +411,28 @@ mod tests {
|
|||||||
|
|
||||||
let loaded = Config::load(&path).expect("reload");
|
let loaded = Config::load(&path).expect("reload");
|
||||||
assert_eq!(loaded.tokens[0].name, "phone");
|
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].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!(
|
assert_eq!(
|
||||||
loaded
|
loaded
|
||||||
.providers()
|
.setup("vm")
|
||||||
.iter()
|
.expect("setup")
|
||||||
.map(|p| p.name.clone())
|
.ssh
|
||||||
.collect::<Vec<_>>(),
|
.as_ref()
|
||||||
["echo", "claude-cli"],
|
.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
|
// The house rule both halves of `format` depend on: what is written
|
||||||
// is the *body* of the struct, with no outer parentheses and
|
// is the *body* of the struct, with no outer parentheses and
|
||||||
@@ -398,21 +455,40 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_configured_echo_provider_replaces_the_built_in_one() {
|
/// A config from before setups existed must not load as an empty one.
|
||||||
let config = Config {
|
/// `Config` defaults unknown fields away, so without this check the
|
||||||
providers: vec![ProviderConfig {
|
/// old `providers` and `hosts` would vanish and be silently re-seeded
|
||||||
name: ECHO_PROVIDER.to_string(),
|
/// over -- the worst kind of migration, the sort nobody notices.
|
||||||
kind: DriverKind::ClaudeCli,
|
fn a_config_in_the_old_shape_is_refused_rather_than_emptied() {
|
||||||
command: Some("/opt/claude".to_string()),
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
models: Vec::new(),
|
let path = dir.path().join("config.ron");
|
||||||
}],
|
std::fs::write(
|
||||||
..Config::default()
|
&path,
|
||||||
};
|
"tokens: [],\nproviders: [\n (name: \"claude-cli\", kind: claude_cli),\n],\nhosts: [],\nsessions: [],\n",
|
||||||
// One entry, not two: the built-in is skipped rather than shadowed.
|
)
|
||||||
assert_eq!(config.providers().len(), 1);
|
.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!(
|
assert_eq!(
|
||||||
config.provider(ECHO_PROVIDER).expect("provider").kind,
|
seed.provider(ECHO_PROVIDER).expect("echo").kind,
|
||||||
DriverKind::ClaudeCli
|
DriverKind::Echo
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
seed.provider("claude-cli").expect("claude").kind,
|
||||||
|
DriverKind::ClaudeCli,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+9
-3
@@ -199,11 +199,17 @@ async fn main() -> Result<()> {
|
|||||||
);
|
);
|
||||||
tracing::info!("config: {}", config_path.display());
|
tracing::info!("config: {}", config_path.display());
|
||||||
tracing::info!("models: {}", models_dir.display());
|
tracing::info!("models: {}", models_dir.display());
|
||||||
for provider in manager.providers() {
|
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);
|
tracing::info!(" provider {} ({:?})", provider.name, provider.kind);
|
||||||
}
|
}
|
||||||
for host in manager.hosts() {
|
|
||||||
tracing::info!(" host {} -> {}", host.name, host.address);
|
|
||||||
}
|
}
|
||||||
for info in manager.sessions() {
|
for info in manager.sessions() {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
|
|||||||
+33
-38
@@ -3,10 +3,9 @@
|
|||||||
//! wraps the whole router in.
|
//! wraps the whole router in.
|
||||||
//!
|
//!
|
||||||
//! ```text
|
//! ```text
|
||||||
//! GET /providers what can be spawned
|
//! GET /setups machines, each with what it can run
|
||||||
//! GET /hosts machines a session can be run on
|
|
||||||
//! GET /sessions list (id, provider, title, model, status, last activity)
|
//! 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
|
//! GET /sessions/{id}/events?after=N SSE: transcript replay from N, then live
|
||||||
//! POST /sessions/{id}/message {text, attachmentIds?}
|
//! POST /sessions/{id}/message {text, attachmentIds?}
|
||||||
//! POST /sessions/{id}/answer {questionId, answer} (questions and permissions)
|
//! 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<SessionManager>) -> Router {
|
pub fn router(manager: Arc<SessionManager>) -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/providers", get(list_providers))
|
.route("/setups", get(list_setups))
|
||||||
.route("/hosts", get(list_hosts))
|
|
||||||
.route("/sessions", get(list_sessions).post(spawn_session))
|
.route("/sessions", get(list_sessions).post(spawn_session))
|
||||||
.route("/sessions/{id}", delete(delete_session))
|
.route("/sessions/{id}", delete(delete_session))
|
||||||
.route("/sessions/{id}/events", get(events))
|
.route("/sessions/{id}/events", get(events))
|
||||||
@@ -113,9 +111,25 @@ async fn list_sessions(State(manager): State<Arc<SessionManager>>) -> axum::Json
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// What the spawn screen needs to render itself, so the phone holds no
|
/// 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
|
/// hardcoded list: a setup added to `config.ron` shows up with no app
|
||||||
/// rebuild. Providers and hosts are listed separately because they are
|
/// rebuild.
|
||||||
/// independent choices -- any provider can be run on any host.
|
///
|
||||||
|
/// 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<String>,
|
||||||
|
providers: Vec<ProviderInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(serde::Serialize)]
|
#[derive(serde::Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct ProviderInfo {
|
struct ProviderInfo {
|
||||||
@@ -124,12 +138,16 @@ struct ProviderInfo {
|
|||||||
models: Vec<String>,
|
models: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_providers(
|
async fn list_setups(State(manager): State<Arc<SessionManager>>) -> axum::Json<Vec<SetupInfo>> {
|
||||||
State(manager): State<Arc<SessionManager>>,
|
|
||||||
) -> axum::Json<Vec<ProviderInfo>> {
|
|
||||||
axum::Json(
|
axum::Json(
|
||||||
manager
|
manager
|
||||||
.providers()
|
.setups()
|
||||||
|
.into_iter()
|
||||||
|
.map(|setup| SetupInfo {
|
||||||
|
name: setup.name,
|
||||||
|
address: setup.ssh.map(|ssh| ssh.address),
|
||||||
|
providers: setup
|
||||||
|
.providers
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|provider| ProviderInfo {
|
.map(|provider| ProviderInfo {
|
||||||
name: provider.name,
|
name: provider.name,
|
||||||
@@ -137,28 +155,6 @@ async fn list_providers(
|
|||||||
models: provider.models,
|
models: provider.models,
|
||||||
})
|
})
|
||||||
.collect(),
|
.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<Arc<SessionManager>>) -> axum::Json<Vec<HostInfo>> {
|
|
||||||
axum::Json(
|
|
||||||
manager
|
|
||||||
.hosts()
|
|
||||||
.into_iter()
|
|
||||||
.map(|host| HostInfo {
|
|
||||||
name: host.name,
|
|
||||||
address: host.address,
|
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
)
|
)
|
||||||
@@ -167,10 +163,9 @@ async fn list_hosts(State(manager): State<Arc<SessionManager>>) -> axum::Json<Ve
|
|||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct SpawnRequest {
|
struct SpawnRequest {
|
||||||
|
/// Which machine, and which of the things it offers.
|
||||||
|
setup: String,
|
||||||
provider: String,
|
provider: String,
|
||||||
/// Name of a configured host; absent runs on the backend machine.
|
|
||||||
#[serde(default)]
|
|
||||||
host: Option<String>,
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
title: Option<String>,
|
title: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -192,8 +187,8 @@ async fn spawn_session(
|
|||||||
) -> Result<axum::Json<SessionInfo>, ApiError> {
|
) -> Result<axum::Json<SessionInfo>, ApiError> {
|
||||||
let info = manager
|
let info = manager
|
||||||
.spawn_session(SpawnSpec {
|
.spawn_session(SpawnSpec {
|
||||||
|
setup: body.setup,
|
||||||
provider: body.provider,
|
provider: body.provider,
|
||||||
host: body.host,
|
|
||||||
title: body.title,
|
title: body.title,
|
||||||
model: body.model,
|
model: body.model,
|
||||||
cwd: body.cwd,
|
cwd: body.cwd,
|
||||||
|
|||||||
+72
-82
@@ -25,7 +25,7 @@ use anyhow::{Context, Result, bail};
|
|||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use tokio::sync::{broadcast, mpsc};
|
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 claude::ClaudeDriver;
|
||||||
use driver::{Driver, Event, ImageRef, SessionStatus};
|
use driver::{Driver, Event, ImageRef, SessionStatus};
|
||||||
use echo::EchoDriver;
|
use echo::EchoDriver;
|
||||||
@@ -47,9 +47,9 @@ pub fn now() -> f64 {
|
|||||||
|
|
||||||
/// What the phone needs to spawn a session -- the spawn screen's fields.
|
/// What the phone needs to spawn a session -- the spawn screen's fields.
|
||||||
pub struct SpawnSpec {
|
pub struct SpawnSpec {
|
||||||
|
/// Which machine, and which of its providers.
|
||||||
|
pub setup: String,
|
||||||
pub provider: String,
|
pub provider: String,
|
||||||
/// Name of a configured host to run on; absent runs on this machine.
|
|
||||||
pub host: Option<String>,
|
|
||||||
pub title: Option<String>,
|
pub title: Option<String>,
|
||||||
pub model: Option<String>,
|
pub model: Option<String>,
|
||||||
pub cwd: Option<PathBuf>,
|
pub cwd: Option<PathBuf>,
|
||||||
@@ -64,9 +64,8 @@ pub struct SpawnSpec {
|
|||||||
pub struct SessionInfo {
|
pub struct SessionInfo {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub provider: String,
|
pub provider: String,
|
||||||
/// Name of the host it runs on; absent means the backend machine.
|
/// The machine it runs on.
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
pub setup: String,
|
||||||
pub host: Option<String>,
|
|
||||||
pub title: String,
|
pub title: String,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub model: Option<String>,
|
pub model: Option<String>,
|
||||||
@@ -172,7 +171,7 @@ impl LiveSession {
|
|||||||
SessionInfo {
|
SessionInfo {
|
||||||
id: self.meta.id.clone(),
|
id: self.meta.id.clone(),
|
||||||
provider: self.meta.provider.clone(),
|
provider: self.meta.provider.clone(),
|
||||||
host: self.meta.host.clone(),
|
setup: self.meta.setup.clone(),
|
||||||
title: self.meta.title.clone(),
|
title: self.meta.title.clone(),
|
||||||
model: self.shared.model.lock().unwrap().clone(),
|
model: self.shared.model.lock().unwrap().clone(),
|
||||||
cwd: self.meta.cwd.clone(),
|
cwd: self.meta.cwd.clone(),
|
||||||
@@ -216,14 +215,8 @@ impl SessionManager {
|
|||||||
// unreachable ssh host, a provider that was edited away --
|
// unreachable ssh host, a provider that was edited away --
|
||||||
// shows as exited rather than taking the whole server down
|
// shows as exited rather than taking the whole server down
|
||||||
// with it, and can still be deleted from the phone.
|
// with it, and can still be deleted from the phone.
|
||||||
match resolve(&config, meta).and_then(|(provider, host)| {
|
match resolve(&config, meta).and_then(|(setup, provider)| {
|
||||||
launch(
|
launch(meta.clone(), &setup, &provider, &data_dir, &models_dir)
|
||||||
meta.clone(),
|
|
||||||
&provider,
|
|
||||||
host.as_ref(),
|
|
||||||
&data_dir,
|
|
||||||
&models_dir,
|
|
||||||
)
|
|
||||||
}) {
|
}) {
|
||||||
Ok(session) => {
|
Ok(session) => {
|
||||||
live.insert(meta.id.clone(), session);
|
live.insert(meta.id.clone(), session);
|
||||||
@@ -239,7 +232,7 @@ impl SessionManager {
|
|||||||
models_dir,
|
models_dir,
|
||||||
inner: RwLock::new(Inner { config, live }),
|
inner: RwLock::new(Inner { config, live }),
|
||||||
};
|
};
|
||||||
manager.seed_providers()?;
|
manager.seed_setup()?;
|
||||||
Ok(manager)
|
Ok(manager)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,24 +240,22 @@ impl SessionManager {
|
|||||||
/// so a fresh install has something to spawn and a worked example of
|
/// 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
|
/// 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.
|
/// 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();
|
let mut inner = self.inner.write().unwrap();
|
||||||
if !inner.config.providers.is_empty() {
|
if !inner.config.setups.is_empty() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let mut candidate = inner.config.clone();
|
let mut candidate = inner.config.clone();
|
||||||
candidate.providers.push(ProviderConfig {
|
candidate.setups.push(Config::seed());
|
||||||
name: "claude-cli".to_string(),
|
|
||||||
kind: DriverKind::ClaudeCli,
|
|
||||||
command: None,
|
|
||||||
models: ["fable", "opus", "sonnet", "haiku"]
|
|
||||||
.iter()
|
|
||||||
.map(|model| model.to_string())
|
|
||||||
.collect(),
|
|
||||||
});
|
|
||||||
candidate.save(&self.config_path)?;
|
candidate.save(&self.config_path)?;
|
||||||
inner.config = candidate;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,8 +302,8 @@ impl SessionManager {
|
|||||||
Some(session) => session.info(),
|
Some(session) => session.info(),
|
||||||
None => SessionInfo {
|
None => SessionInfo {
|
||||||
id: meta.id.clone(),
|
id: meta.id.clone(),
|
||||||
|
setup: meta.setup.clone(),
|
||||||
provider: meta.provider.clone(),
|
provider: meta.provider.clone(),
|
||||||
host: meta.host.clone(),
|
|
||||||
title: meta.title.clone(),
|
title: meta.title.clone(),
|
||||||
model: meta.model.clone(),
|
model: meta.model.clone(),
|
||||||
cwd: meta.cwd.clone(),
|
cwd: meta.cwd.clone(),
|
||||||
@@ -329,55 +320,45 @@ impl SessionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Every provider this server offers, built-in echo included.
|
/// Every provider this server offers, built-in echo included.
|
||||||
pub fn providers(&self) -> Vec<ProviderConfig> {
|
/// Every machine this server can run something on, each with what it
|
||||||
self.inner.read().unwrap().config.providers()
|
/// 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()
|
||||||
/// 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<HostConfig> {
|
|
||||||
self.inner.read().unwrap().config.hosts.clone()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn spawn_session(&self, spec: SpawnSpec) -> Result<SessionInfo> {
|
pub fn spawn_session(&self, spec: SpawnSpec) -> Result<SessionInfo> {
|
||||||
let mut inner = self.inner.write().unwrap();
|
let mut inner = self.inner.write().unwrap();
|
||||||
let provider = inner.config.provider(&spec.provider).with_context(|| {
|
let setup = inner
|
||||||
format!(
|
|
||||||
"no provider named \"{}\" -- configured: {}",
|
|
||||||
spec.provider,
|
|
||||||
inner
|
|
||||||
.config
|
.config
|
||||||
.providers()
|
.setup(&spec.setup)
|
||||||
.iter()
|
.with_context(|| {
|
||||||
.map(|p| p.name.clone())
|
format!(
|
||||||
.collect::<Vec<_>>()
|
"no setup named \"{}\" -- configured: {}",
|
||||||
.join(", "),
|
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 id = unique_id(&inner.config);
|
||||||
let title = spec
|
let title = spec
|
||||||
.title
|
.title
|
||||||
.filter(|title| !title.trim().is_empty())
|
.filter(|title| !title.trim().is_empty())
|
||||||
.unwrap_or_else(|| format!("{} session", provider.name));
|
.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::<Vec<_>>()
|
|
||||||
.join(", "),
|
|
||||||
)
|
|
||||||
})?),
|
|
||||||
None => None,
|
|
||||||
};
|
|
||||||
let meta = SessionConfig {
|
let meta = SessionConfig {
|
||||||
id: id.clone(),
|
id: id.clone(),
|
||||||
|
setup: setup.name.clone(),
|
||||||
provider: provider.name.clone(),
|
provider: provider.name.clone(),
|
||||||
host: spec.host,
|
|
||||||
title,
|
title,
|
||||||
model: spec.model.or_else(|| provider.models.first().cloned()),
|
model: spec.model.or_else(|| provider.models.first().cloned()),
|
||||||
cwd: spec.cwd,
|
cwd: spec.cwd,
|
||||||
@@ -388,8 +369,8 @@ impl SessionManager {
|
|||||||
|
|
||||||
let session = launch(
|
let session = launch(
|
||||||
meta.clone(),
|
meta.clone(),
|
||||||
|
&setup,
|
||||||
&provider,
|
&provider,
|
||||||
host.as_ref(),
|
|
||||||
&self.data_dir,
|
&self.data_dir,
|
||||||
&self.models_dir,
|
&self.models_dir,
|
||||||
)?;
|
)?;
|
||||||
@@ -456,19 +437,28 @@ impl SessionManager {
|
|||||||
/// The provider and host a session's config names, or a message saying
|
/// 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
|
/// which one is missing. Both are looked up fresh at every launch, so
|
||||||
/// editing either takes effect on the next respawn.
|
/// editing either takes effect on the next respawn.
|
||||||
fn resolve(config: &Config, meta: &SessionConfig) -> Result<(ProviderConfig, Option<HostConfig>)> {
|
fn resolve(config: &Config, meta: &SessionConfig) -> Result<(SetupConfig, ProviderConfig)> {
|
||||||
let provider = config
|
let setup = config
|
||||||
.provider(&meta.provider)
|
.setup(&meta.setup)
|
||||||
.with_context(|| format!("no provider named \"{}\"", meta.provider))?;
|
.with_context(|| format!("no setup named \"{}\"", meta.setup))?;
|
||||||
let host = match &meta.host {
|
let provider = setup.provider(&meta.provider).with_context(|| {
|
||||||
Some(name) => Some(
|
format!(
|
||||||
config
|
"setup \"{}\" has no provider named \"{}\"",
|
||||||
.host(name)
|
meta.setup, meta.provider
|
||||||
.with_context(|| format!("no host named \"{name}\""))?,
|
)
|
||||||
),
|
})?;
|
||||||
None => None,
|
Ok((setup.clone(), provider.clone()))
|
||||||
};
|
}
|
||||||
Ok((provider, host))
|
|
||||||
|
/// 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<Item = &'a str>) -> 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
|
/// 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.
|
/// event pump connecting them.
|
||||||
fn launch(
|
fn launch(
|
||||||
meta: SessionConfig,
|
meta: SessionConfig,
|
||||||
|
setup: &SetupConfig,
|
||||||
provider: &ProviderConfig,
|
provider: &ProviderConfig,
|
||||||
host: Option<&HostConfig>,
|
|
||||||
data_dir: &Path,
|
data_dir: &Path,
|
||||||
models_dir: &Path,
|
models_dir: &Path,
|
||||||
) -> Result<Arc<LiveSession>> {
|
) -> Result<Arc<LiveSession>> {
|
||||||
@@ -518,7 +508,7 @@ fn launch(
|
|||||||
DriverKind::LlamaCpp => Box::new(LlamaDriver::spawn(
|
DriverKind::LlamaCpp => Box::new(LlamaDriver::spawn(
|
||||||
&meta,
|
&meta,
|
||||||
provider,
|
provider,
|
||||||
&Transport::for_host(host),
|
&Transport::for_setup(setup),
|
||||||
models_dir,
|
models_dir,
|
||||||
&transcript_path,
|
&transcript_path,
|
||||||
sink.clone(),
|
sink.clone(),
|
||||||
@@ -526,7 +516,7 @@ fn launch(
|
|||||||
DriverKind::ClaudeCli => Box::new(ClaudeDriver::spawn(
|
DriverKind::ClaudeCli => Box::new(ClaudeDriver::spawn(
|
||||||
&meta,
|
&meta,
|
||||||
provider,
|
provider,
|
||||||
&Transport::for_host(host),
|
&Transport::for_setup(setup),
|
||||||
&dir,
|
&dir,
|
||||||
sink.clone(),
|
sink.clone(),
|
||||||
)?),
|
)?),
|
||||||
@@ -587,8 +577,8 @@ mod tests {
|
|||||||
fn echo_spec() -> SpawnSpec {
|
fn echo_spec() -> SpawnSpec {
|
||||||
SpawnSpec {
|
SpawnSpec {
|
||||||
params: Default::default(),
|
params: Default::default(),
|
||||||
|
setup: crate::config::LOCAL_SETUP.to_string(),
|
||||||
provider: crate::config::ECHO_PROVIDER.to_string(),
|
provider: crate::config::ECHO_PROVIDER.to_string(),
|
||||||
host: None,
|
|
||||||
title: None,
|
title: None,
|
||||||
model: None,
|
model: None,
|
||||||
cwd: None,
|
cwd: None,
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ use std::path::{Path, PathBuf};
|
|||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use tokio::process::Child;
|
use tokio::process::Child;
|
||||||
|
|
||||||
use crate::config::HostConfig;
|
use crate::config::SshConfig;
|
||||||
|
|
||||||
/// What a driver needs run in order to exist as a process.
|
/// What a driver needs run in order to exist as a process.
|
||||||
///
|
///
|
||||||
@@ -51,17 +51,21 @@ impl Launch {
|
|||||||
pub enum Transport {
|
pub enum Transport {
|
||||||
/// The machine this server is running on.
|
/// The machine this server is running on.
|
||||||
Here,
|
Here,
|
||||||
/// Reached with the system `ssh` client. Owns its host entry rather
|
/// Reached with the system `ssh` client. Owns its entry rather than
|
||||||
/// than borrowing it, so a session keeps working against the config it
|
/// borrowing it, so a session keeps working against the config it was
|
||||||
/// was spawned with even if that entry is edited afterwards.
|
/// spawned with even if the setup is edited afterwards. Carries the
|
||||||
Ssh(HostConfig),
|
/// setup's name only to say where things are running.
|
||||||
|
Ssh { name: String, ssh: SshConfig },
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Transport {
|
impl Transport {
|
||||||
/// The transport a session's configured host names; absent is [`Self::Here`].
|
/// The transport a setup describes; a setup with no `ssh` is here.
|
||||||
pub fn for_host(host: Option<&HostConfig>) -> Self {
|
pub fn for_setup(setup: &crate::config::SetupConfig) -> Self {
|
||||||
match host {
|
match &setup.ssh {
|
||||||
Some(host) => Self::Ssh(host.clone()),
|
Some(ssh) => Self::Ssh {
|
||||||
|
name: setup.name.clone(),
|
||||||
|
ssh: ssh.clone(),
|
||||||
|
},
|
||||||
None => Self::Here,
|
None => Self::Here,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -75,14 +79,15 @@ impl Transport {
|
|||||||
pub fn spawn(&self, launch: &Launch) -> Result<Child> {
|
pub fn spawn(&self, launch: &Launch) -> Result<Child> {
|
||||||
let host = match self {
|
let host = match self {
|
||||||
Self::Here => None,
|
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())
|
crate::ssh::command(host, &launch.program, &launch.args, launch.cwd.as_deref())
|
||||||
.spawn()
|
.spawn()
|
||||||
.with_context(|| match self {
|
.with_context(|| match self {
|
||||||
Self::Ssh(host) => format!(
|
Self::Ssh { name, .. } => format!(
|
||||||
"couldn't start ssh to run \"{}\" on {} -- is the ssh client installed here?",
|
"couldn't start ssh to run \"{}\" on {name} -- is the ssh client installed \
|
||||||
launch.program, host.name,
|
here?",
|
||||||
|
launch.program,
|
||||||
),
|
),
|
||||||
Self::Here => format!(
|
Self::Here => format!(
|
||||||
"couldn't run \"{}\" on this machine -- is it installed and on PATH? If it \
|
"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 {
|
pub fn describe(&self) -> String {
|
||||||
match self {
|
match self {
|
||||||
Self::Here => "on this machine".to_string(),
|
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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+7
-9
@@ -15,7 +15,7 @@ use std::process::Stdio;
|
|||||||
|
|
||||||
use tokio::process::Command;
|
use tokio::process::Command;
|
||||||
|
|
||||||
use crate::config::HostConfig;
|
use crate::config::SshConfig;
|
||||||
|
|
||||||
/// Options forced onto every connection. `BatchMode` makes a missing key
|
/// Options forced onto every connection. `BatchMode` makes a missing key
|
||||||
/// fail immediately with a readable message instead of hanging on a
|
/// 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
|
/// 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(
|
pub fn command(
|
||||||
host: Option<&HostConfig>,
|
remote: Option<&SshConfig>,
|
||||||
program: &str,
|
program: &str,
|
||||||
args: &[String],
|
args: &[String],
|
||||||
cwd: Option<&Path>,
|
cwd: Option<&Path>,
|
||||||
) -> Command {
|
) -> Command {
|
||||||
let Some(ssh) = host else {
|
let Some(ssh) = remote else {
|
||||||
let mut command = Command::new(program);
|
let mut command = Command::new(program);
|
||||||
command.args(args);
|
command.args(args);
|
||||||
if let Some(cwd) = cwd {
|
if let Some(cwd) = cwd {
|
||||||
@@ -132,9 +132,8 @@ mod tests {
|
|||||||
/// A host with nothing configured but a name to dial, so `~/.ssh/config`
|
/// A host with nothing configured but a name to dial, so `~/.ssh/config`
|
||||||
/// decides everything else -- the case that proves this adds no flags of
|
/// decides everything else -- the case that proves this adds no flags of
|
||||||
/// its own when it was not told to.
|
/// its own when it was not told to.
|
||||||
fn bare_host() -> HostConfig {
|
fn bare_host() -> SshConfig {
|
||||||
HostConfig {
|
SshConfig {
|
||||||
name: "vm".to_string(),
|
|
||||||
address: "vm".to_string(),
|
address: "vm".to_string(),
|
||||||
port: None,
|
port: None,
|
||||||
identity_file: None,
|
identity_file: None,
|
||||||
@@ -159,8 +158,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_session_with_a_host_wraps_the_same_command_in_ssh() {
|
fn a_session_with_a_host_wraps_the_same_command_in_ssh() {
|
||||||
let ssh = HostConfig {
|
let ssh = SshConfig {
|
||||||
name: "vm".to_string(),
|
|
||||||
address: "bob@10.0.2.15".to_string(),
|
address: "bob@10.0.2.15".to_string(),
|
||||||
port: Some(2222),
|
port: Some(2222),
|
||||||
identity_file: Some("/home/me/.ssh/id_ai".into()),
|
identity_file: Some("/home/me/.ssh/id_ai".into()),
|
||||||
|
|||||||
Reference in new issue
Block a user