pub(crate) mod compare;
mod complete;
mod config_check;
pub(crate) mod data;
mod export;
pub(crate) mod front;
mod history;
pub(crate) mod init;
mod logs;
pub(crate) mod migrate;
pub(crate) mod pod;
mod progress;
pub(crate) mod push;
mod record;
mod reload;
mod runpod_train;
mod skill;
pub(crate) mod train;
use std::io::IsTerminal as _;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, SystemTime};
use clap::builder::PossibleValuesParser;
use clap::{Args, Parser, Subcommand, ValueHint};
use clap_complete::engine::ArgValueCandidates;
use secrecy::SecretString;
use tokio::sync::OnceCell;
use tokio::task::JoinHandle;
use self::front::Frontend;
use self::reload::Reloader;
use crate::config::{DotenvKeys, EnvSource, QUANTIZE_TYPES, Source};
use crate::events::Observer;
use crate::logging::{LOG_LINES, LogBuffer, LogMode};
use crate::metrics::{Metrics, MetricsServer};
use crate::project_lock::{LockError, ProjectLock};
use crate::secrets::{Resolver, SecretError, SecretSource, VaultRef, VaultSettings, VaultSource};
use crate::update::{self, CheckEnv, Newer};
const CHECK_GRACE: Duration = Duration::from_millis(500);
#[derive(Debug, Parser)]
#[command(name = "overbrainer", version, about)]
pub struct Cli {
#[arg(short = 'C', long, global = true, default_value = ".", value_hint = ValueHint::DirPath)]
pub project_dir: PathBuf,
#[command(subcommand)]
pub command: Command,
#[arg(skip)]
pub auto: bool,
}
#[derive(Debug, Subcommand)]
pub enum Command {
Init {
#[arg(value_hint = ValueHint::DirPath)]
dir: Option<PathBuf>,
},
Config {
#[command(subcommand)]
command: ConfigCommand,
},
Subtopics(StageArgs),
Questions(StageArgs),
Answers(StageArgs),
Split(SplitArgs),
Run,
Train(TrainArgs),
Export(ExportArgs),
Compare(CompareArgs),
Push(PushArgs),
Runs {
#[command(subcommand)]
command: RunsCommand,
},
History(HistoryArgs),
Migrate(MigrateArgs),
Pod {
#[command(subcommand)]
command: PodCommand,
},
Skill {
#[command(subcommand)]
command: SkillCommand,
},
Tui,
}
impl Command {
#[must_use]
pub fn log_mode(&self) -> LogMode {
match self {
Self::Tui => LogMode::Tui(LogBuffer::new(LOG_LINES)),
_ => LogMode::Stderr,
}
}
#[must_use]
pub fn serves_metrics(&self) -> bool {
self.writes_project() && !matches!(self, Self::Migrate(_))
}
#[must_use]
pub fn stopped_run(&self) -> Option<&str> {
match self {
Self::Train(TrainArgs {
command: Some(TrainCommand::Stop { run_id }),
..
}) => Some(run_id),
_ => None,
}
}
#[must_use]
pub fn writes_project(&self) -> bool {
match self {
Self::Tui
| Self::Subtopics(_)
| Self::Questions(_)
| Self::Answers(_)
| Self::Split(_)
| Self::Run
| Self::Migrate(_)
| Self::Train(_)
| Self::Export(_)
| Self::Compare(_)
| Self::Push(_) => true,
Self::Pod { command } => matches!(command, PodCommand::Rm { .. }),
Self::Init { .. }
| Self::Config { .. }
| Self::Runs { .. }
| Self::History(_)
| Self::Skill { .. } => false,
}
}
}
#[derive(Debug, Subcommand)]
pub enum PodCommand {
Ls,
Rm {
#[arg(add = ArgValueCandidates::new(complete::run_ids))]
run_id: String,
#[arg(long)]
force: bool,
},
Gpus(GpuArgs),
Datacenters,
Volumes,
Templates,
}
#[derive(Debug, Default, Args)]
pub struct GpuArgs {
#[arg(long)]
pub min_vram: Option<u32>,
#[arg(long)]
pub max_price: Option<f64>,
#[arg(long)]
pub data_center: Option<String>,
#[arg(long)]
pub in_stock: bool,
}
#[derive(Debug, Subcommand)]
pub enum SkillCommand {
Install(SkillInstallArgs),
}
#[derive(Debug, Args)]
pub struct SkillInstallArgs {
#[arg(long, conflicts_with = "dir")]
pub global: bool,
#[arg(long, value_hint = ValueHint::DirPath)]
pub dir: Option<PathBuf>,
#[arg(long)]
pub force: bool,
}
#[derive(Debug, Default, Args)]
#[command(args_conflicts_with_subcommands = true)]
pub struct TrainArgs {
#[arg(long, add = ArgValueCandidates::new(complete::targets))]
pub target: Option<String>,
#[arg(long)]
pub keep_pod: bool,
#[arg(long, value_name = "RUN_ID", add = ArgValueCandidates::new(complete::run_ids))]
pub resume_from: Option<String>,
#[command(subcommand)]
pub command: Option<TrainCommand>,
#[arg(skip)]
pub vram_floor: crate::train::sizing::VramFloor,
}
#[derive(Debug, Args)]
pub struct ExportArgs {
#[arg(add = ArgValueCandidates::new(complete::run_ids))]
pub run_id: String,
#[arg(long, value_name = "TYPE", value_parser = PossibleValuesParser::new(QUANTIZE_TYPES))]
pub quantize: Option<String>,
#[arg(long, value_name = "NAME", value_parser = ollama_name)]
pub ollama: Option<String>,
#[arg(long)]
pub keep_pod: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Args)]
pub struct CompareArgs {
#[arg(long, add = ArgValueCandidates::new(complete::run_ids))]
pub run: Option<String>,
#[arg(long, value_name = "N", value_parser = clap::value_parser!(u32).range(1..))]
pub limit: Option<u32>,
#[arg(long, conflicts_with = "rejudge")]
pub keep_pod: bool,
#[arg(long, value_name = "COMPARE_ID", conflicts_with = "limit")]
pub rejudge: Option<String>,
}
#[derive(Debug, Args)]
pub struct PushArgs {
#[arg(add = ArgValueCandidates::new(complete::run_ids))]
pub run_id: String,
#[arg(long)]
pub repo: Option<String>,
#[arg(long)]
pub public: bool,
#[arg(long)]
pub overwrite_card: bool,
#[arg(long)]
pub dry_run: bool,
}
fn ollama_name(text: &str) -> Result<String, String> {
if crate::config::is_ollama_name(text) {
Ok(text.to_string())
} else {
Err(
"not an Ollama model name ([host/][namespace/]model[:tag], each part of letters, \
digits, '_', '-' and '.')"
.to_string(),
)
}
}
#[derive(Debug, Subcommand)]
pub enum TrainCommand {
Attach {
#[arg(add = ArgValueCandidates::new(complete::run_ids))]
run_id: String,
},
Stop {
#[arg(add = ArgValueCandidates::new(complete::run_ids))]
run_id: String,
},
Cancel {
#[arg(add = ArgValueCandidates::new(complete::run_ids))]
run_id: String,
},
}
#[derive(Debug, Subcommand)]
pub enum RunsCommand {
Ls,
Logs(LogsArgs),
}
#[derive(Debug, Args)]
pub struct LogsArgs {
#[arg(add = ArgValueCandidates::new(complete::run_ids))]
pub run_id: String,
#[arg(long)]
pub pod: bool,
#[arg(long, value_enum, requires = "pod")]
pub source: Option<crate::runpod::LogSource>,
#[arg(long, requires = "pod")]
pub follow: bool,
#[arg(long, value_name = "N")]
pub tail: Option<u32>,
}
#[derive(Debug, Default, Args)]
pub struct HistoryArgs {
#[arg(long)]
pub all: bool,
}
#[derive(Debug, Default, Args)]
pub struct MigrateArgs {
#[arg(long)]
pub dry_run: bool,
}
#[derive(Debug, Default, Args)]
pub struct StageArgs {
#[arg(long, add = ArgValueCandidates::new(complete::topics))]
pub topic: Option<String>,
#[arg(long)]
pub force: bool,
}
#[derive(Debug, Default, Args)]
pub struct SplitArgs {
#[arg(long, add = ArgValueCandidates::new(complete::topics))]
pub topic: Option<String>,
}
#[derive(Debug, Subcommand)]
pub enum ConfigCommand {
Check {
#[arg(long)]
resolve: bool,
},
}
pub fn wizard(cli: &mut Cli) -> anyhow::Result<bool> {
if !wants_wizard(cli, std::io::stdout().is_terminal()) {
return Ok(true);
}
if let Some(refusal) = crate::tui::wizard_refusal(&cli.project_dir) {
anyhow::bail!(refusal);
}
match crate::tui::wizard(&cli.project_dir)? {
crate::tui::WizardEnded::Open { auto } => {
cli.auto = auto;
Ok(true)
},
crate::tui::WizardEnded::Quit { written: false } => {
eprintln!("init wizard quit: nothing was written");
Ok(false)
},
crate::tui::WizardEnded::Quit { written: true } => {
eprintln!("the project is written: `overbrainer tui` opens it");
Ok(false)
},
}
}
fn wants_wizard(cli: &Cli, stdout_is_terminal: bool) -> bool {
matches!(cli.command, Command::Tui)
&& stdout_is_terminal
&& cli.project_dir.is_dir()
&& cli
.project_dir
.join(crate::config::CONFIG_FILE)
.symlink_metadata()
.is_err()
}
pub async fn run(cli: Cli, logs: LogMode, dotenv: DotenvKeys) -> anyhow::Result<()> {
let stderr = std::io::stderr().is_terminal();
let stdout = std::io::stdout().is_terminal();
let env = (!matches!(cli.command, Command::Skill { .. })).then(CheckEnv::from_process);
let mut check = env
.filter(|env| should_check(&cli.command, stderr, stdout, env))
.map(|env| {
tokio::spawn(async move {
update::check(&env, update::CRATES_IO_URL, SystemTime::now()).await
})
});
let hint = migrate::hints(&cli.command, &cli.project_dir);
let result = dispatch(cli, logs, &mut check, dotenv).await;
if hint {
eprintln!("{}", migrate::HINT);
}
if let Some(check) = check
&& let Some(newer) = settle(check).await
{
eprintln!("{newer}");
}
result
}
fn should_check(
command: &Command,
stderr_is_terminal: bool,
stdout_is_terminal: bool,
env: &CheckEnv,
) -> bool {
!env.disabled
&& match command {
Command::Skill { .. } => false,
Command::Tui => stdout_is_terminal,
_ => stderr_is_terminal,
}
}
async fn settle(mut check: JoinHandle<Option<Newer>>) -> Option<Newer> {
let answer = tokio::time::timeout(CHECK_GRACE, &mut check).await;
check.abort();
answer.ok()?.ok()?
}
enum Access {
Run(Option<ProjectLock>),
StopRequested,
}
async fn access(cli: &Cli) -> anyhow::Result<Access> {
let dir = &cli.project_dir;
if !cli.command.writes_project() || !dir.join(crate::config::CONFIG_FILE).is_file() {
return Ok(Access::Run(None));
}
match ProjectLock::acquire(dir) {
Ok(lock) => Ok(Access::Run(Some(lock))),
Err(LockError::Held { pid }) => match cli.command.stopped_run() {
Some(run_id) => {
let source = Source::from(EnvSource::Process);
let front = Frontend::Cli(None);
train::request_stop(dir, run_id, pid, &front, &source).await?;
Ok(Access::StopRequested)
},
None => Err(LockError::Held { pid }.into()),
},
Err(error) => Err(error.into()),
}
}
async fn dispatch(
cli: Cli,
logs: LogMode,
check: &mut Option<JoinHandle<Option<Newer>>>,
dotenv: DotenvKeys,
) -> anyhow::Result<()> {
let Access::Run(lock) = access(&cli).await? else {
return Ok(());
};
let dir = &cli.project_dir;
let auto = cli.auto;
let served = served_metrics(&cli.command, dir, lock.is_some()).await;
let observer = served
.as_ref()
.map(|(_, metrics)| Arc::clone(metrics) as Arc<dyn Observer>);
let front = Frontend::Cli(observer.clone());
match cli.command {
Command::Init { dir: target } => init::run(target.as_deref().unwrap_or(dir)),
Command::Config {
command: ConfigCommand::Check { resolve },
} => config_check::run(dir, resolve).await,
Command::Subtopics(args) => stage(dir, &front, data::Command::Subtopics, &args).await,
Command::Questions(args) => stage(dir, &front, data::Command::Questions, &args).await,
Command::Answers(args) => stage(dir, &front, data::Command::Answers, &args).await,
Command::Split(SplitArgs { topic }) => {
let args = StageArgs {
topic,
force: false,
};
stage(dir, &front, data::Command::Split, &args).await
},
Command::Run => run_all(dir, &front, dotenv).await,
command @ (Command::Train(_)
| Command::Export(_)
| Command::Compare(_)
| Command::Push(_)) => Box::pin(model_job(dir, command, &front)).await,
Command::Runs {
command: RunsCommand::Ls,
} => train::list(dir),
Command::Runs {
command: RunsCommand::Logs(args),
} => logs::run(dir, &args).await,
Command::History(args) => history::run(dir, &args),
Command::Migrate(args) => migrate::run(dir, args.dry_run),
Command::Pod { command } => pod::run(dir, &command).await,
Command::Skill { command } => skill::run(dir, &command),
Command::Tui => match logs {
LogMode::Tui(buffer) => {
let start = crate::tui::Start { dotenv, auto };
crate::tui::run(dir, buffer, check.take(), start, observer).await
},
LogMode::Stderr => anyhow::bail!("overbrainer tui needs the TUI log mode"),
},
}
}
async fn model_job(dir: &Path, command: Command, front: &Frontend) -> anyhow::Result<()> {
let source = Source::from(EnvSource::Process);
match command {
Command::Train(args) => Box::pin(train::run(dir, &args, front, &source)).await,
Command::Export(args) => Box::pin(export::run(dir, &args, front)).await,
Command::Compare(args) => Box::pin(compare::run(dir, &args, front, &source)).await,
Command::Push(args) => Box::pin(push::run(dir, &args, front)).await,
other => anyhow::bail!("{other:?} is not a command on the model of a run"),
}
}
async fn run_all(dir: &Path, front: &Frontend, dotenv: DotenvKeys) -> anyhow::Result<()> {
let mut reloader = Reloader::new(dir, dotenv);
let args = StageArgs::default();
let load = data::Load::Reload(&mut reloader);
data::run(dir, data::Command::Run, &args, front, load).await?;
Box::pin(train::after_run(dir, front, &mut reloader)).await
}
async fn stage(
dir: &Path,
front: &Frontend,
command: data::Command,
args: &StageArgs,
) -> anyhow::Result<()> {
let load = data::Load::Source(&Source::from(EnvSource::Process));
data::run(dir, command, args, front, load).await
}
async fn served_metrics(
command: &Command,
dir: &Path,
locked: bool,
) -> Option<(MetricsServer, Arc<Metrics>)> {
if locked && command.serves_metrics() {
serve_metrics(dir).await
} else {
None
}
}
async fn serve_metrics(dir: &Path) -> Option<(MetricsServer, Arc<Metrics>)> {
let settings = crate::config::load(dir, EnvSource::Process).ok()?;
let address = settings.metrics.listen?;
if !address.ip().is_loopback() {
tracing::warn!("metrics on {address} have no authentication");
}
let metrics = Arc::new(project_metrics(dir));
crate::metrics::serve(address, Arc::clone(&metrics))
.await
.inspect(|server| tracing::info!("metrics at http://{}/metrics", server.address()))
.inspect_err(|error| tracing::warn!("cannot serve the metrics on {address}: {error}"))
.ok()
.map(|server| (server, metrics))
}
fn project_metrics(dir: &Path) -> Metrics {
let history = crate::history::read(dir).unwrap_or_else(|error| {
tracing::warn!("cannot read the stage history, the metrics start from zero: {error}");
Vec::new()
});
Metrics::new(&history).with_runs(crate::runs::Runs::new(dir))
}
fn resolver() -> Resolver<LazyVault> {
Resolver::new(Some(LazyVault::default()))
}
#[derive(Default)]
struct LazyVault {
source: OnceCell<Option<VaultSource>>,
}
impl LazyVault {
async fn source(&self) -> Result<Option<&VaultSource>, SecretError> {
let source = self.source.get_or_try_init(|| async { vault() }).await?;
Ok(source.as_ref())
}
}
impl SecretSource for LazyVault {
async fn fetch(&self, reference: &VaultRef) -> Result<SecretString, SecretError> {
match self.source().await? {
Some(source) => source.fetch(reference).await,
None => Err(SecretError::VaultNotConfigured),
}
}
}
fn vault() -> Result<Option<VaultSource>, SecretError> {
let home = std::env::var_os("HOME").map(PathBuf::from);
VaultSettings::from_env(|key| std::env::var(key).ok(), home.as_deref())?
.map(|settings| VaultSource::new(&settings))
.transpose()
}
#[cfg(test)]
mod tests {
use super::*;
fn command(args: &[&str]) -> Result<Command, clap::Error> {
let cli = Cli::try_parse_from(std::iter::once("overbrainer").chain(args.iter().copied()))?;
Ok(cli.command)
}
#[test]
fn the_update_check_runs_on_a_terminal_unless_turned_off() -> Result<(), clap::Error> {
let on = CheckEnv::default();
let off = CheckEnv {
disabled: true,
cache_dir: None,
};
for (args, stderr, stdout, env, expected) in [
(&["run"][..], true, true, &on, true),
(&["history"], true, false, &on, true),
(&["run"], false, true, &on, false),
(&["run"], true, true, &off, false),
(&["skill", "install"], true, true, &on, false),
(&["tui"], true, true, &on, true),
(&["tui"], false, true, &on, true),
(&["tui"], true, false, &on, false),
(&["tui"], true, true, &off, false),
] {
assert_eq!(
should_check(&command(args)?, stderr, stdout, env),
expected,
"{args:?}, stderr a terminal: {stderr}, stdout: {stdout}, disabled: {}",
env.disabled
);
}
Ok(())
}
#[tokio::test(start_paused = true)]
async fn the_notice_waits_at_most_the_grace_period() {
let check = tokio::spawn(std::future::pending::<Option<Newer>>());
let start = tokio::time::Instant::now();
assert_eq!(settle(check).await, None);
assert_eq!(start.elapsed(), CHECK_GRACE);
}
#[tokio::test(start_paused = true)]
async fn a_finished_check_gives_its_answer() {
let newer = Newer {
latest: "9.0.0".into(),
current: "0.4.0",
};
let answer = newer.clone();
let check = tokio::spawn(async move { Some(answer) });
assert_eq!(settle(check).await, Some(newer));
}
#[test]
fn the_wizard_opens_only_for_tui_in_an_existing_directory_without_a_config()
-> Result<(), Box<dyn std::error::Error>> {
let dir = tempfile::tempdir()?;
let path = dir.path().to_string_lossy().into_owned();
let missing = dir.path().join("missing").to_string_lossy().into_owned();
let cli = |args: &[&str]| Cli::try_parse_from(args);
assert!(wants_wizard(
&cli(&["overbrainer", "-C", &path, "tui"])?,
true
));
assert!(!wants_wizard(
&cli(&["overbrainer", "-C", &path, "tui"])?,
false
));
assert!(!wants_wizard(
&cli(&["overbrainer", "-C", &path, "run"])?,
true
));
assert!(!wants_wizard(
&cli(&["overbrainer", "-C", &missing, "tui"])?,
true
));
assert!(!dir.path().join("missing").exists());
std::fs::write(dir.path().join(crate::config::CONFIG_FILE), "")?;
assert!(!wants_wizard(
&cli(&["overbrainer", "-C", &path, "tui"])?,
true
));
Ok(())
}
#[test]
fn export_takes_a_known_type_and_an_ollama_name() -> Result<(), clap::Error> {
let Command::Export(args) = command(&[
"export",
"r1",
"--quantize",
"Q8_0",
"--ollama",
"me/mentor:q8",
"--keep-pod",
])?
else {
return Err(clap::Error::new(clap::error::ErrorKind::InvalidSubcommand));
};
assert_eq!(args.run_id, "r1");
assert_eq!(args.quantize.as_deref(), Some("Q8_0"));
assert_eq!(args.ollama.as_deref(), Some("me/mentor:q8"));
assert!(args.keep_pod);
let Command::Export(defaults) = command(&["export", "r1"])? else {
return Err(clap::Error::new(clap::error::ErrorKind::InvalidSubcommand));
};
assert_eq!((defaults.quantize, defaults.ollama), (None, None));
assert!(command(&["export", "r1", "--quantize", "Q4"]).is_err());
assert!(command(&["export", "r1", "--ollama", "bad name"]).is_err());
assert!(command(&["export", "r1", "--ollama", "a//b"]).is_err());
assert!(command(&["export"]).is_err());
Ok(())
}
#[test]
fn compare_options_parse() -> Result<(), clap::Error> {
let Command::Compare(args) =
command(&["compare", "--run", "r1", "--limit", "20", "--keep-pod"])?
else {
return Err(clap::Error::new(clap::error::ErrorKind::InvalidSubcommand));
};
assert_eq!(args.run.as_deref(), Some("r1"));
assert_eq!(args.limit, Some(20));
assert!(args.keep_pod);
let Command::Compare(args) = command(&["compare", "--rejudge", "compare_20261006-120000"])?
else {
return Err(clap::Error::new(clap::error::ErrorKind::InvalidSubcommand));
};
assert_eq!(args.rejudge.as_deref(), Some("compare_20261006-120000"));
assert!(command(&["compare", "--limit", "0"]).is_err());
assert!(command(&["compare", "--rejudge", "c1", "--limit", "3"]).is_err());
assert!(command(&["compare", "--rejudge", "c1", "--keep-pod"]).is_err());
Ok(())
}
#[test]
fn push_takes_a_repo_and_its_flags() -> Result<(), clap::Error> {
let Command::Push(args) = command(&[
"push",
"r1",
"--repo",
"me/model",
"--public",
"--overwrite-card",
"--dry-run",
])?
else {
return Err(clap::Error::new(clap::error::ErrorKind::InvalidSubcommand));
};
assert_eq!(args.run_id, "r1");
assert_eq!(args.repo.as_deref(), Some("me/model"));
assert!(args.public && args.overwrite_card && args.dry_run);
let Command::Push(defaults) = command(&["push", "r1"])? else {
return Err(clap::Error::new(clap::error::ErrorKind::InvalidSubcommand));
};
assert_eq!(defaults.repo, None);
assert!(!defaults.public && !defaults.overwrite_card && !defaults.dry_run);
assert!(command(&["push"]).is_err());
Ok(())
}
#[test]
fn only_train_stop_names_a_stopped_run() -> Result<(), clap::Error> {
let stopped = |args: &[&str]| -> Result<Option<String>, clap::Error> {
let cli =
Cli::try_parse_from(std::iter::once("overbrainer").chain(args.iter().copied()))?;
Ok(cli.command.stopped_run().map(str::to_string))
};
assert_eq!(stopped(&["train", "stop", "x"])?.as_deref(), Some("x"));
for args in [
&["train"][..],
&["train", "attach", "x"],
&["train", "cancel", "x"],
] {
assert_eq!(stopped(args)?, None, "{args:?}");
}
Ok(())
}
#[test]
fn only_commands_that_write_take_the_lock() -> Result<(), clap::Error> {
let writes = |args: &[&str]| -> Result<bool, clap::Error> {
let cli =
Cli::try_parse_from(std::iter::once("overbrainer").chain(args.iter().copied()))?;
Ok(cli.command.writes_project())
};
for args in [
&["tui"][..],
&["subtopics"],
&["questions"],
&["answers"],
&["split"],
&["run"],
&["train"],
&["train", "attach", "x"],
&["train", "cancel", "x"],
&["train", "stop", "x"],
&["export", "x"],
&["push", "x"],
&["push", "x", "--dry-run"],
&["pod", "rm", "x"],
&["migrate"],
&["migrate", "--dry-run"],
] {
assert!(writes(args)?, "{args:?} should lock");
}
let serves = |args: &[&str]| -> Result<bool, clap::Error> {
let cli =
Cli::try_parse_from(std::iter::once("overbrainer").chain(args.iter().copied()))?;
Ok(cli.command.serves_metrics())
};
assert!(serves(&["run"])? && serves(&["tui"])?);
assert!(!serves(&["migrate"])? && !serves(&["history"])?);
for args in [
&["init"][..],
&["config", "check"],
&["runs", "ls"],
&["history"],
&["pod", "ls"],
&["skill", "install"],
] {
assert!(!writes(args)?, "{args:?} should not lock");
}
Ok(())
}
}