Rename setups and add provider reauthentication

This commit is contained in:
iris committed 2026-09-12 22:56:43 -04:00
1 parent e9a0f1b9da
commit 7d9df5d572
36 files changed
+1866 -684

No files matched your search

+117 -115
View File
@@ -30,8 +30,8 @@ use serde::Serialize;
use tokio::sync::{broadcast, mpsc};
use crate::config::{
Config, DEFAULT_RESUME_MESSAGE, DriverKind, ProviderConfig, ScheduledResume, SessionConfig,
SetupConfig, SshConfig, TokenEntry,
Config, DEFAULT_RESUME_MESSAGE, DriverKind, MachineConfig, ProviderConfig, ScheduledResume,
SessionConfig, SshConfig, TokenEntry,
};
use claude::ClaudeDriver;
use codex::CodexDriver;
@@ -100,7 +100,7 @@ pub fn now() -> f64 {
}
pub struct SpawnSpec {
pub setup: String,
pub machine: String,
pub provider: String,
pub title: Option<String>,
pub model: Option<String>,
@@ -172,7 +172,7 @@ impl AutoResumeView {
pub struct OwedResume {
pub session_id: String,
/// The machine whose account ran out, which is the one to ask.
pub setup: String,
pub machine: String,
/// Which meter reports on it -- a `crate::usage::UsageProvider::name`, the
/// same pairing `SessionInfo::usage_provider` uses.
pub provider: &'static str,
@@ -193,11 +193,11 @@ fn resume_message(meta: &SessionConfig) -> String {
pub struct SessionInfo {
pub id: String,
pub provider: String,
pub setup: String,
pub machine: String,
/// That machine's current label, resolved when this row is built, so
/// renaming a setup renames it everywhere rather than leaving old
/// renaming a machine renames it everywhere rather than leaving old
/// sessions showing the old name.
pub setup_name: String,
pub machine_name: String,
pub title: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
@@ -562,7 +562,7 @@ impl LiveSession {
Ok((name, path))
}
/// `setup_name` and `cwd` are passed in rather than read from the
/// `machine_name` and `cwd` are passed in rather than read from the
/// snapshot this session launched with: only the manager holds the
/// config, and both can change under a running session. Passed rather
/// than mirrored into `Shared`, so there is one answer, read where the
@@ -574,7 +574,7 @@ impl LiveSession {
/// cautious one.
fn info(
&self,
setup_name: &str,
machine_name: &str,
cwd: Option<&Path>,
effort: Option<&str>,
imported: bool,
@@ -584,8 +584,8 @@ impl LiveSession {
SessionInfo {
id: self.meta.id.clone(),
provider: self.meta.provider.clone(),
setup: self.meta.setup.clone(),
setup_name: setup_name.to_string(),
machine: self.meta.machine.clone(),
machine_name: machine_name.to_string(),
title: self.shared.title.lock().unwrap().clone(),
model: self.shared.model.lock().unwrap().clone(),
permission_mode: self.shared.permission_mode.lock().unwrap().clone(),
@@ -646,7 +646,7 @@ pub struct SessionManager {
/// The CLI-owned copy optionally removed with an ai-app session.
#[derive(Debug, PartialEq, Eq)]
pub struct ForeignTranscript {
pub setup: String,
pub machine: String,
pub id: String,
kind: DriverKind,
}
@@ -702,10 +702,10 @@ impl SessionManager {
// One unlaunchable session -- a corrupt transcript, an
// unreachable host, a provider edited away -- shows as exited
// rather than taking the server down, and can still be deleted.
match resolve(&config, meta).and_then(|(setup, provider)| {
match resolve(&config, meta).and_then(|(machine, provider)| {
launch(
meta.clone(),
&setup,
&machine,
&provider,
Env {
data_dir: &data_dir,
@@ -777,7 +777,7 @@ impl SessionManager {
self
}
/// Writes this machine into a config that has no setups, with the
/// Writes this machine into a config that has no machines, with the
/// providers actually found on it.
///
/// Discovered rather than assumed. This used to write a `claude-cli`
@@ -789,16 +789,16 @@ impl SessionManager {
/// this server runs, and says so in the log. Seeding the hardcoded list
/// would be the original bug with an extra step, and seeding nothing
/// leaves a fresh install with nothing to prove the pipe with.
pub async fn seed_setup(&self) -> Result<()> {
if !self.inner.read().unwrap().config.setups.is_empty() {
pub async fn seed_machine(&self) -> Result<()> {
if !self.inner.read().unwrap().config.machines.is_empty() {
return Ok(());
}
let providers = match crate::setups::discover(&transport::Transport::Here).await {
let providers = match crate::machines::discover(&transport::Transport::Here).await {
Ok(found) => found,
Err(err) => {
tracing::warn!(
"couldn't ask this machine what it has ({err}); seeding {} only -- \
re-probe the setup from the app once that is fixed",
re-probe the machine from the app once that is fixed",
crate::config::ECHO_PROVIDER
);
vec![Config::echo_provider()]
@@ -807,16 +807,16 @@ impl SessionManager {
let names: Vec<&str> = providers.iter().map(|p| p.name.as_str()).collect();
let mut inner = self.inner.write().unwrap();
if !inner.config.setups.is_empty() {
if !inner.config.machines.is_empty() {
return Ok(());
}
let mut candidate = inner.config.clone();
candidate.setups.push(Config::seed(providers.clone()));
candidate.machines.push(Config::seed(providers.clone()));
candidate.save(&self.config_path)?;
inner.config = candidate;
tracing::info!(
"no setups configured -- added \"{}\" with: {}",
crate::config::LOCAL_SETUP,
"no machines configured -- added \"{}\" with: {}",
crate::config::LOCAL_MACHINE,
names.join(", ")
);
Ok(())
@@ -839,80 +839,80 @@ impl SessionManager {
///
/// `providers` comes from probing rather than from the caller: the
/// probe is async and this is not, so the route asks and this writes.
pub fn add_setup(
pub fn add_machine(
&self,
name: &str,
ssh: Option<SshConfig>,
providers: Vec<ProviderConfig>,
) -> Result<SetupConfig> {
) -> Result<MachineConfig> {
let name = name.trim().to_string();
if name.is_empty() {
bail!("a setup needs a name");
bail!("a machine needs a name");
}
self.update(|config| {
if config.setup_named(&name).is_some() {
bail!("there is already a setup called \"{name}\"");
if config.machine_named(&name).is_some() {
bail!("there is already a machine called \"{name}\"");
}
// Ids are derived once and then fixed, so a label can be
// edited later without orphaning the sessions that named it.
let mut id = crate::setups::id_from(&name);
while config.setup(&id).is_some() {
let mut id = crate::machines::id_from(&name);
while config.machine(&id).is_some() {
id = format!("{id}-{}", &random_hex()[..4]);
}
let setup = SetupConfig {
let machine = MachineConfig {
id,
name: name.clone(),
ssh,
providers,
};
config.setups.push(setup.clone());
Ok(setup)
config.machines.push(machine.clone());
Ok(machine)
})
}
pub fn update_setup(
pub fn update_machine(
&self,
id: &str,
name: Option<&str>,
providers: Option<Vec<ProviderConfig>>,
) -> Result<SetupConfig> {
) -> Result<MachineConfig> {
self.update(|config| {
if let Some(name) = name {
let name = name.trim();
if name.is_empty() {
bail!("a setup needs a name");
bail!("a machine needs a name");
}
if config.setups.iter().any(|s| s.name == name && s.id != id) {
bail!("there is already a setup called \"{name}\"");
if config.machines.iter().any(|s| s.name == name && s.id != id) {
bail!("there is already a machine called \"{name}\"");
}
}
let setup = config
.setups
let machine = config
.machines
.iter_mut()
.find(|setup| setup.id == id)
.with_context(|| format!("no setup with id \"{id}\""))?;
.find(|machine| machine.id == id)
.with_context(|| format!("no machine with id \"{id}\""))?;
if let Some(name) = name {
setup.name = name.trim().to_string();
machine.name = name.trim().to_string();
}
if let Some(providers) = providers {
setup.providers = providers;
machine.providers = providers;
}
Ok(setup.clone())
Ok(machine.clone())
})
}
/// Removes a machine, provided nothing is still running on it.
/// Refused rather than cascaded: the person asking is better placed to
/// decide which of those sessions they still want.
pub fn delete_setup(&self, id: &str) -> Result<()> {
pub fn delete_machine(&self, id: &str) -> Result<()> {
self.update(|config| {
if config.setup(id).is_none() {
bail!("no setup with id \"{id}\"");
if config.machine(id).is_none() {
bail!("no machine with id \"{id}\"");
}
let using: Vec<&str> = config
.sessions
.iter()
.filter(|session| session.setup == id)
.filter(|session| session.machine == id)
.map(|session| session.title.as_str())
.collect();
if !using.is_empty() {
@@ -922,7 +922,7 @@ impl SessionManager {
using.join(", "),
);
}
config.setups.retain(|setup| setup.id != id);
config.machines.retain(|machine| machine.id != id);
Ok(())
})
}
@@ -1061,18 +1061,18 @@ impl SessionManager {
pub fn remote_of(&self, id: &str) -> Option<(crate::config::SshConfig, Option<PathBuf>)> {
let inner = self.inner.read().unwrap();
let meta = inner.config.sessions.iter().find(|meta| meta.id == id)?;
let setup = inner
let machine = inner
.config
.setups
.machines
.iter()
.find(|setup| setup.id == meta.setup)?;
Some((setup.ssh.clone()?, meta.cwd.clone()))
.find(|machine| machine.id == meta.machine)?;
Some((machine.ssh.clone()?, meta.cwd.clone()))
}
pub fn foreign_transcript(&self, id: &str) -> Option<ForeignTranscript> {
let inner = self.inner.read().unwrap();
let meta = inner.config.sessions.iter().find(|meta| meta.id == id)?;
let kind = kind_of(&inner.config, &meta.setup, &meta.provider)?;
let kind = kind_of(&inner.config, &meta.machine, &meta.provider)?;
let session_dir = self.data_dir.join(&meta.id);
let foreign = match kind {
DriverKind::ClaudeCli => {
@@ -1085,7 +1085,7 @@ impl SessionManager {
DriverKind::Echo | DriverKind::LlamaCpp => None,
}?;
Some(ForeignTranscript {
setup: meta.setup.clone(),
machine: meta.machine.clone(),
id: foreign,
kind,
})
@@ -1101,28 +1101,28 @@ impl SessionManager {
.iter()
.map(|meta| match inner.live.get(&meta.id) {
Some(session) => session.info(
label_of(&inner.config, &meta.setup),
label_of(&inner.config, &meta.machine),
meta.cwd.as_deref(),
meta.effort.as_deref(),
import::read_cursor(&self.data_dir.join(&meta.id)).is_some(),
kind_of(&inner.config, &meta.setup, &meta.provider),
kind_of(&inner.config, &meta.machine, &meta.provider),
AutoResumeView::of(meta),
),
None => SessionInfo {
id: meta.id.clone(),
setup: meta.setup.clone(),
setup_name: label_of(&inner.config, &meta.setup).to_string(),
machine: meta.machine.clone(),
machine_name: label_of(&inner.config, &meta.machine).to_string(),
provider: meta.provider.clone(),
title: meta.title.clone(),
model: meta.model.clone(),
permission_mode: meta.permission_mode.clone(),
effort: meta.effort.clone(),
takes_effort: kind_of(&inner.config, &meta.setup, &meta.provider)
takes_effort: kind_of(&inner.config, &meta.machine, &meta.provider)
.is_some_and(DriverKind::takes_effort),
context_tokens: None,
max_image_edge: kind_of(&inner.config, &meta.setup, &meta.provider)
max_image_edge: kind_of(&inner.config, &meta.machine, &meta.provider)
.and_then(DriverKind::max_image_edge),
usage_provider: kind_of(&inner.config, &meta.setup, &meta.provider)
usage_provider: kind_of(&inner.config, &meta.machine, &meta.provider)
.and_then(DriverKind::usage_provider),
notify: meta.notify,
auto_resume: meta.auto_resume,
@@ -1131,10 +1131,10 @@ impl SessionManager {
imported: import::read_cursor(&self.data_dir.join(&meta.id)).is_some(),
keeps_own_transcript: keeps_own_transcript(
&inner.config,
&meta.setup,
&meta.machine,
&meta.provider,
),
own_transcript_name: kind_of(&inner.config, &meta.setup, &meta.provider)
own_transcript_name: kind_of(&inner.config, &meta.machine, &meta.provider)
.and_then(DriverKind::own_transcript_name),
cwd: meta.cwd.clone(),
status: status_of_unlaunched(&self.data_dir.join(&meta.id)),
@@ -1172,8 +1172,8 @@ impl SessionManager {
/// Every machine this server can run something on, each with what it
/// can run. One list rather than two, because the pair is the choice.
pub fn setups(&self) -> Vec<SetupConfig> {
self.inner.read().unwrap().config.setups.clone()
pub fn machines(&self) -> Vec<MachineConfig> {
self.inner.read().unwrap().config.machines.clone()
}
pub fn spawn_session(&self, spec: SpawnSpec) -> Result<SessionInfo> {
@@ -1191,28 +1191,28 @@ impl SessionManager {
fn spawn_seeded(&self, spec: SpawnSpec, seed: Option<Seed>) -> Result<SessionInfo> {
let mut inner = self.inner.write().unwrap();
let setup = inner
let machine = inner
.config
.setup(&spec.setup)
.machine(&spec.machine)
.with_context(|| {
format!(
"no setup with id \"{}\" -- configured: {}",
spec.setup,
"no machine with id \"{}\" -- configured: {}",
spec.machine,
// Ids, since that is what was looked up. Labels made
// the failure read as a contradiction: "no setup named
// the failure read as a contradiction: "no machine named
// X -- configured: X".
names(inner.config.setups.iter().map(|s| s.id.as_str())),
names(inner.config.machines.iter().map(|s| s.id.as_str())),
)
})?
.clone();
let provider = setup
let provider = machine
.provider(&spec.provider)
.with_context(|| {
format!(
"setup \"{}\" has no provider named \"{}\" -- it offers: {}",
spec.setup,
"machine \"{}\" has no provider named \"{}\" -- it offers: {}",
spec.machine,
spec.provider,
names(setup.providers.iter().map(|p| p.name.as_str())),
names(machine.providers.iter().map(|p| p.name.as_str())),
)
})?
.clone();
@@ -1223,7 +1223,7 @@ impl SessionManager {
.unwrap_or_else(|| format!("{} session", provider.name));
let meta = SessionConfig {
id: id.clone(),
setup: setup.id.clone(),
machine: machine.id.clone(),
provider: provider.name.clone(),
title,
// No model unless one was chosen. This used to fall back to the
@@ -1267,7 +1267,7 @@ impl SessionManager {
let session = launch(
meta.clone(),
&setup,
&machine,
&provider,
self.env(),
self.announce.clone(),
@@ -1286,7 +1286,7 @@ impl SessionManager {
// Whether this one was seeded, the same question the listing asks
// of the directory a moment later.
let info = session.info(
&setup.name,
&machine.name,
session.meta.cwd.as_deref(),
session.meta.effort.as_deref(),
import::read_cursor(&self.data_dir.join(&id)).is_some(),
@@ -1442,8 +1442,8 @@ impl SessionManager {
let scheduled = meta.resume?;
Some(OwedResume {
session_id: meta.id.clone(),
setup: meta.setup.clone(),
provider: kind_of(&inner.config, &meta.setup, &meta.provider)?
machine: meta.machine.clone(),
provider: kind_of(&inner.config, &meta.machine, &meta.provider)?
.usage_provider()?,
scheduled,
})
@@ -1628,7 +1628,7 @@ impl SessionManager {
/// it entirely.
///
/// Whether the directory exists is the caller's question, because
/// asking it is an ssh round trip on a remote setup; see the route.
/// asking it is an ssh round trip on a remote machine; see the route.
pub fn set_session_cwd(&self, id: &str, cwd: PathBuf) -> Result<()> {
{
let mut inner = self.inner.write().unwrap();
@@ -1889,7 +1889,7 @@ impl SessionManager {
// Fresh from the config, like every other launch: a model or a
// permission mode changed while the session was stopped is what it
// starts with.
let (setup, provider) = resolve(&inner.config, &meta)?;
let (machine, provider) = resolve(&inner.config, &meta)?;
match existing {
Some(session) => {
// Replacing the value a driver lives in does not end the
@@ -1901,7 +1901,7 @@ impl SessionManager {
}
*session.driver.lock().unwrap() = Some(make_driver(
&meta,
&setup,
&machine,
&provider,
self.env(),
session.dir(),
@@ -1915,7 +1915,7 @@ impl SessionManager {
None => {
let session = launch(
meta,
&setup,
&machine,
&provider,
self.env(),
self.announce.clone(),
@@ -2050,24 +2050,26 @@ fn adoptable(session_dir: &Path) -> bool {
/// The provider and host a session's config names, or a message saying
/// which one is missing. Both are looked up fresh at every launch, so
/// editing either takes effect on the next respawn.
fn resolve(config: &Config, meta: &SessionConfig) -> Result<(SetupConfig, ProviderConfig)> {
let setup = config
.setup(&meta.setup)
.with_context(|| format!("no setup named \"{}\"", meta.setup))?;
let provider = setup.provider(&meta.provider).with_context(|| {
fn resolve(config: &Config, meta: &SessionConfig) -> Result<(MachineConfig, ProviderConfig)> {
let machine = config
.machine(&meta.machine)
.with_context(|| format!("no machine named \"{}\"", meta.machine))?;
let provider = machine.provider(&meta.provider).with_context(|| {
format!(
"setup \"{}\" has no provider named \"{}\"",
meta.setup, meta.provider
"machine \"{}\" has no provider named \"{}\"",
meta.machine, meta.provider
)
})?;
Ok((setup.clone(), provider.clone()))
Ok((machine.clone(), provider.clone()))
}
/// A setup's current label, or its id when the setup has been deleted --
/// A machine's current label, or its id when the machine has been deleted --
/// which is what a session left behind by a removed machine shows, and is
/// better than an empty column or a guess at what it used to be called.
fn label_of<'a>(config: &'a Config, id: &'a str) -> &'a str {
config.setup(id).map_or(id, |setup| setup.name.as_str())
config
.machine(id)
.map_or(id, |machine| machine.name.as_str())
}
/// The two ways a session directory can name a Claude Code conversation:
@@ -2094,21 +2096,21 @@ fn foreign_ids(dir: &Path) -> (Option<String>, Option<String>) {
/// app's delete cannot reach.
///
/// False when the provider can't be found, which is the safe way round: a
/// setup or provider removed from the config leaves sessions naming one
/// machine or provider removed from the config leaves sessions naming one
/// that is gone, and the warning that then shows is the strong one. Saying
/// "this can be brought back" on no evidence is the answer that loses
/// somebody's conversation.
fn keeps_own_transcript(config: &Config, setup: &str, provider: &str) -> bool {
kind_of(config, setup, provider).is_some_and(DriverKind::keeps_own_transcript)
fn keeps_own_transcript(config: &Config, machine: &str, provider: &str) -> bool {
kind_of(config, machine, provider).is_some_and(DriverKind::keeps_own_transcript)
}
/// What a session's provider is, for the questions answered by its *kind*
/// rather than by its name. `None` for a provider that has been edited away,
/// which is a session that cannot run at all.
fn kind_of(config: &Config, setup: &str, provider: &str) -> Option<DriverKind> {
fn kind_of(config: &Config, machine: &str, provider: &str) -> Option<DriverKind> {
config
.setup(setup)
.and_then(|setup| setup.providers.iter().find(|it| it.name == provider))
.machine(machine)
.and_then(|machine| machine.providers.iter().find(|it| it.name == provider))
.map(|provider| provider.kind)
}
@@ -2310,7 +2312,7 @@ struct Env<'a> {
/// process for it to speak to. See [`Launching`] for when that is.
fn launch(
meta: SessionConfig,
setup: &SetupConfig,
machine: &MachineConfig,
provider: &ProviderConfig,
env: Env<'_>,
announce: Announcements,
@@ -2405,7 +2407,7 @@ fn launch(
&& shared.context_tokens.lock().unwrap().is_none()
&& let Some(session_id) = claude::read_resume_token(&dir)
{
let transport = Transport::for_setup(setup);
let transport = Transport::for_machine(machine);
let shared = Arc::clone(&shared);
tokio::spawn(async move {
if let Some(context) = import::context_of(&transport, &session_id).await {
@@ -2423,7 +2425,7 @@ fn launch(
&& shared.context_tokens.lock().unwrap().is_none()
&& let Some(thread_id) = codex::read_thread(&dir)
{
let transport = Transport::for_setup(setup);
let transport = Transport::for_machine(machine);
let shared = Arc::clone(&shared);
tokio::spawn(async move {
if let Some(context) = codex::context_of(&transport, &thread_id).await {
@@ -2441,7 +2443,7 @@ fn launch(
// anybody pressing anything.
if let Some(cursor) = import::read_cursor(&dir) {
spawn_import_sync(
Transport::for_setup(setup),
Transport::for_machine(machine),
dir.clone(),
cursor,
sink.clone(),
@@ -2454,7 +2456,7 @@ fn launch(
.then(|| {
make_driver(
&meta,
setup,
machine,
provider,
env,
&dir,
@@ -2505,7 +2507,7 @@ fn launch(
#[allow(clippy::too_many_arguments)]
fn make_driver(
meta: &SessionConfig,
setup: &SetupConfig,
machine: &MachineConfig,
provider: &ProviderConfig,
env: Env<'_>,
dir: &Path,
@@ -2525,7 +2527,7 @@ fn make_driver(
DriverKind::LlamaCpp => Arc::new(LlamaDriver::launch(
meta,
provider,
&Transport::for_setup(setup),
&Transport::for_machine(machine),
env.models_dir,
transcript_path,
dir,
@@ -2535,7 +2537,7 @@ fn make_driver(
DriverKind::ClaudeCli => Arc::new(ClaudeDriver::launch(
meta,
provider,
&Transport::for_setup(setup),
&Transport::for_machine(machine),
dir,
sink.clone(),
Arc::clone(subagents),
@@ -2543,7 +2545,7 @@ fn make_driver(
DriverKind::CodexCli => Arc::new(CodexDriver::launch(
meta,
provider,
Transport::for_setup(setup),
Transport::for_machine(machine),
dir,
sink.clone(),
)?),
@@ -2799,7 +2801,7 @@ mod tests {
fn echo_spec() -> SpawnSpec {
SpawnSpec {
params: Default::default(),
setup: crate::config::LOCAL_SETUP_ID.to_string(),
machine: crate::config::LOCAL_MACHINE_ID.to_string(),
provider: crate::config::ECHO_PROVIDER.to_string(),
title: None,
model: None,
@@ -2857,7 +2859,7 @@ mod tests {
/// depending on whether `claude` happens to be installed.
fn seed_echo_only(config_path: &std::path::Path) {
Config {
setups: vec![Config::seed(vec![Config::echo_provider()])],
machines: vec![Config::seed(vec![Config::echo_provider()])],
..Config::default()
}
.save(config_path)
@@ -3356,7 +3358,7 @@ mod tests {
assert_eq!(
manager.foreign_transcript(&info.id),
Some(ForeignTranscript {
setup: info.setup.clone(),
machine: info.machine.clone(),
id: "5ecf21da-d53f".to_string(),
kind: DriverKind::ClaudeCli,
})
@@ -3979,7 +3981,7 @@ mod tests {
std::fs::write(&command, "#!/bin/sh\ncat > /dev/null\n").expect("write stand-in");
std::fs::set_permissions(&command, std::fs::Permissions::from_mode(0o755)).expect("chmod");
Config {
setups: vec![Config::seed(vec![
machines: vec![Config::seed(vec![
Config::echo_provider(),
ProviderConfig {
name: "stand-in".to_string(),