use std::time::{Duration, SystemTime};
use crossterm::event::KeyCode;
use super::app::{Action, App, Confirm, Effect, Exit, NoteOf, Overlay, Severity, View};
use super::auto;
use super::start::{self, Catalog, StartPlan};
use super::tasks::{Msg, Task, TaskId, TrainJob};
use super::training::{Detach, Ended, Follow, Job, Listing, Pushing, RunActivity};
use crate::cli::front::Report;
use crate::cli::push::{PushPlan, files_word, size_words};
use crate::events::Event;
use crate::runs::RunState;
use crate::train::sizing::{Estimate, VramFloor};
use crate::train::{Phases, TrainMetric};
pub(super) const REFRESH: Duration = Duration::from_secs(2);
const ABANDONED: &str = "If its pod is still being prepared, it is deleted and the run fails, \
as Ctrl-C does on the command line; once its job is being sent, the \
run is detached instead.";
impl App {
pub(super) fn refresh_runs(&mut self) -> Vec<Effect> {
self.refreshed = self.now;
if self.training.listing.is_some() {
self.training.list_again = true;
return Vec::new();
}
let id = self.task_id();
self.training.listing = Some(id);
vec![Effect::Spawn(id, Task::Runs)]
}
pub(super) fn refresh_when_due(&mut self) -> Vec<Effect> {
let recent = matches!(
self.now.duration_since(self.refreshed),
Ok(since) if since < REFRESH
);
if self.view == View::Training && !recent {
return self.refresh_runs();
}
Vec::new()
}
pub(super) fn listed(&mut self, id: TaskId, listing: Listing) -> Vec<Effect> {
if self.training.listing != Some(id) {
return Vec::new();
}
self.training.listing = None;
self.dirty = true;
for warning in self.training.listed(listing) {
tracing::warn!("{warning}");
}
let mut effects = self.read_selected();
if std::mem::take(&mut self.training.list_again) {
effects.extend(self.refresh_runs());
}
effects
}
fn read_selected(&mut self) -> Vec<Effect> {
match self.training.to_read() {
Some(run) => self.read_series(&run),
None => Vec::new(),
}
}
fn read_series(&mut self, run: &str) -> Vec<Effect> {
let id = self.task_id();
self.training.reading.insert(run.to_string(), id);
vec![Effect::Spawn(id, Task::Series(run.to_string()))]
}
pub(super) fn series_read(
&mut self,
id: TaskId,
run: String,
series: Option<(Vec<TrainMetric>, Phases)>,
) -> Vec<Effect> {
if self.training.reading.get(&run) != Some(&id) {
return Vec::new();
}
self.training.reading.remove(&run);
if let Some((series, phases)) = series {
if let Some(ended) = self.training.ended.get_mut(&run) {
ended.healed = true;
}
self.training.phases.insert(run.clone(), phases);
self.training.series.insert(run, series);
self.dirty = true;
}
Vec::new()
}
pub(super) fn read_failed(&mut self, id: TaskId, error: &str) -> bool {
if self.training.listing == Some(id) {
self.training.listing = None;
self.training.error = Some(format!("cannot list the runs: {error}"));
return true;
}
let before = self.training.reading.len();
self.training.reading.retain(|_, read| *read != id);
before != self.training.reading.len()
}
pub(super) fn on_training_key(&mut self, code: KeyCode) -> Vec<Effect> {
let view = &mut self.training;
match code {
KeyCode::Up | KeyCode::Char('k') => view.selected = view.selected.saturating_sub(1),
KeyCode::Down | KeyCode::Char('j') => {
view.selected = (view.selected + 1).min(view.runs.len().saturating_sub(1));
},
KeyCode::Char('a') => return self.attach_selected(),
KeyCode::Char('c') => {
self.ask_cancel();
return Vec::new();
},
KeyCode::Char('t') => return self.prepare_start(None),
KeyCode::Char('T') => return self.prepare_resume(),
KeyCode::Char('s') => {
self.ask_stop();
return Vec::new();
},
KeyCode::Char('x') => {
self.ask_clear_failed();
return Vec::new();
},
KeyCode::Char('p') => {
self.toggle_pod();
return Vec::new();
},
KeyCode::Char('h') => return self.prepare_push(),
KeyCode::Char('C') => {
let run = self
.training
.selected_run()
.map(|row| row.record.id.clone());
return self.ask_compare(run);
},
_ => return Vec::new(),
}
self.read_selected()
}
fn ask_clear_failed(&mut self) {
let failed = self.training.failed_runs();
let (runs, no) = match failed.len() {
0 => {
self.say(Severity::Info, "no failed run to clear");
return;
},
1 => ("the failed run".to_string(), "keep it"),
count => (format!("the {count} failed runs"), "keep them"),
};
self.overlay = Some(Overlay::Confirm(Confirm {
title: " Clear failed runs? ".to_string(),
text: vec![format!(
"Hide {runs} from this list until the TUI restarts? Nothing is deleted: the \
files stay in runs/, and `overbrainer runs ls` still lists every run."
)],
yes: "clear",
no,
action: Action::ClearFailed(failed),
}));
}
fn prepare_push(&mut self) -> Vec<Effect> {
let Some(run) = self
.training
.selected_run()
.map(|row| row.record.id.clone())
else {
return Vec::new();
};
if self.push_refused() {
return Vec::new();
}
if self.training.push_prepare.is_some() {
self.say(Severity::Warn, "already preparing a push");
return Vec::new();
}
let id = self.task_id();
self.training.push_prepare = Some(id);
vec![Effect::Spawn(id, Task::PreparePush(run))]
}
fn push_refused(&mut self) -> bool {
if self.refuse_new("one task at a time", "push") {
return true;
}
if let Some(pushing) = &self.training.pushing {
let said = format!("run {} is being pushed: one push at a time", pushing.run);
self.say(Severity::Warn, said);
return true;
}
false
}
pub(super) fn push_prepared(
&mut self,
id: TaskId,
plan: Result<PushPlan, String>,
) -> Vec<Effect> {
if self.training.push_prepare != Some(id) {
return Vec::new();
}
self.training.push_prepare = None;
if self.leaving.is_some() {
return Vec::new();
}
let plan = match plan {
Ok(plan) => plan,
Err(error) => {
self.say(Severity::Warn, error);
return Vec::new();
},
};
if self.overlay.is_some() || self.dataset.input.is_some() {
self.say(Severity::Info, "a push is prepared: press h again to push");
return Vec::new();
}
let visibility = if plan.private {
"private if created; an existing public repo is refused"
} else {
"public if created"
};
self.overlay = Some(Overlay::Confirm(Confirm {
title: format!(" Push run {}? ", plan.run_id),
text: vec![
format!("repo {}", plan.repo),
format!("visibility {visibility}"),
format!("{}, {}", files_word(plan.files), size_words(plan.bytes)),
],
yes: "push",
no: "cancel",
action: Action::Push(plan.run_id),
}));
Vec::new()
}
pub(super) fn confirm_push(&mut self, run: String) -> Vec<Effect> {
if self.push_refused() {
return Vec::new();
}
let task = self.task_id();
self.training.pushing = Some(Pushing {
task,
run: run.clone(),
progress: None,
});
vec![Effect::Spawn(task, Task::Push(run))]
}
pub(super) fn on_push_message(&mut self, message: Msg) -> Vec<Effect> {
match message {
Msg::Event(
_,
Event::Push {
run_id,
done,
total,
},
) => {
if let Some(pushing) = &mut self.training.pushing
&& pushing.run == run_id
{
pushing.progress = Some((done, total));
}
},
Msg::Report(_, Report::Line(line)) => tracing::info!("{line}"),
_ => {},
}
Vec::new()
}
pub(super) fn is_push(&self, id: TaskId) -> bool {
self.training
.pushing
.as_ref()
.is_some_and(|pushing| pushing.task == id)
}
pub(super) fn pushed(&mut self, id: TaskId, result: Result<String, String>) -> Vec<Effect> {
if !self.is_push(id) {
return Vec::new();
}
let run = self
.training
.pushing
.take()
.map(|pushing| pushing.run)
.unwrap_or_default();
let (severity, said) = match result {
Ok(url) => (Severity::Info, format!("pushed to {url}")),
Err(error) => (Severity::Error, format!("push failed: {error}")),
};
if self.leaving.is_some() {
self.exit_notes.push(format!("run {run}: {said}"));
}
self.say(severity, said);
self.leave_when_idle();
Vec::new()
}
pub(super) fn clear_failed(&mut self, runs: &[String]) -> Vec<Effect> {
self.training.hide(runs);
let cleared = match runs.len() {
1 => "1 failed run cleared".to_string(),
count => format!("{count} failed runs cleared"),
};
self.say(
Severity::Info,
format!("{cleared} from the list until the TUI restarts"),
);
self.read_selected()
}
fn toggle_pod(&mut self) {
let Some(row) = self.training.selected_run() else {
return;
};
let id = row.record.id.clone();
if row.pod.is_none() {
self.say(Severity::Info, format!("run {id} has no pod"));
return;
}
if self.training.dismissed.remove(&id) {
self.say(Severity::Info, format!("the pod of run {id} shows again"));
return;
}
if self.training.task_of(&id).is_some() {
self.say(
Severity::Info,
format!("run {id} is followed: its pod stays in view"),
);
return;
}
self.training.dismissed.insert(id.clone());
self.say(
Severity::Info,
format!("the pod of run {id} is dismissed; p shows it again"),
);
}
fn attach_selected(&mut self) -> Vec<Effect> {
let Some(id) = self
.training
.selected_run()
.map(|row| row.record.id.clone())
else {
return Vec::new();
};
if self.leaving.is_some() {
self.say(Severity::Warn, "refused: quitting; nothing new is followed");
return Vec::new();
}
if self.training.cancelling(&id) {
self.say(Severity::Info, format!("run {id} is being cancelled"));
return Vec::new();
}
if self.training.task_of(&id).is_some() {
self.say(Severity::Info, format!("run {id} is already followed"));
return Vec::new();
}
self.training.series.remove(&id);
self.training.phases.remove(&id);
self.training.ended.remove(&id);
self.train(Job::Attach, &id)
}
fn start_refused(&mut self) -> bool {
self.refuse_new("one task at a time", "run")
}
fn prepare_start(&mut self, resume: Option<String>) -> Vec<Effect> {
if self.start_refused() {
return Vec::new();
}
if self.prepare.is_some() {
self.say(Severity::Info, "already preparing a run");
return Vec::new();
}
let id = self.task_id();
self.prepare = Some(id);
let task = resume.map_or(Task::Prepare, Task::PrepareResume);
vec![Effect::Spawn(id, task)]
}
fn prepare_resume(&mut self) -> Vec<Effect> {
let Some(row) = self.training.selected_run() else {
return Vec::new();
};
let id = row.record.id.clone();
if row.record.state != RunState::Stopped || row.record.snapshot.is_none() {
self.say(
Severity::Info,
format!(
"run {id} is {}: T resumes a run stopped with a snapshot (s)",
row.record.state.name()
),
);
return Vec::new();
}
self.prepare_start(Some(id))
}
fn ask_stop(&mut self) {
let Some(row) = self.training.selected_run() else {
return;
};
let (id, target) = (row.record.id.clone(), row.record.target.clone());
let running = row.record.state == RunState::Running && row.record.job.is_some();
let snapshots = row.record.snapshots;
if self.training.stopping(&id) {
self.say(Severity::Info, format!("run {id} is being stopped"));
return;
}
if self.training.cancelling(&id) {
self.say(Severity::Info, format!("run {id} is being cancelled"));
return;
}
if !running || self.training.activity(&id).abandons() {
self.say(
Severity::Info,
format!("run {id} has no running job to stop with a snapshot"),
);
return;
}
if !snapshots {
let refused = crate::runs::RunError::NoSnapshots(id);
self.say(Severity::Warn, format!("refused: {refused}"));
return;
}
if self.leaving.is_some() {
self.say(Severity::Warn, "refused: quitting; nothing new is followed");
return;
}
self.overlay = Some(Overlay::Confirm(Confirm {
title: " Stop a run with a snapshot? ".to_string(),
text: vec![format!(
"Stop run {id} on {target} with a snapshot? Its job saves a checkpoint at the end \
of its current step and stops; the checkpoint is retrieved with its results, and \
for Runpod the pod is then ended. T then starts a new run from it. A job that \
gives no snapshot within 30 minutes is cancelled."
)],
yes: "stop the run",
no: "keep it",
action: Action::Stop(id),
}));
}
pub(super) fn stop_run(&mut self, run_id: &str) -> Vec<Effect> {
if self.training.stopping(run_id) || self.training.cancelling(run_id) {
return Vec::new();
}
let Some((task, _)) = self.training.task_of(run_id) else {
return self.train(Job::Stop, run_id);
};
let Some(follow) = self.training.tasks.get_mut(&task) else {
return Vec::new();
};
follow.stop_after = true;
self.say(
Severity::Info,
format!("detaching run {run_id}, then stopping it with a snapshot"),
);
self.detach(task)
}
pub(super) fn prepared(&mut self, plan: Result<StartPlan, String>) -> Vec<Effect> {
self.prepare = None;
if self.leaving.is_some() {
return Vec::new();
}
let plan = match plan {
Ok(plan) => plan,
Err(error) => {
self.say(Severity::Warn, format!("refused: {error}"));
return Vec::new();
},
};
if self.overlay.is_some() || self.dataset.input.is_some() {
self.say(
Severity::Info,
"a run is prepared: press t again to start it",
);
return Vec::new();
}
let gpu_count = plan.runpod.as_ref().map(|runpod| runpod.spec.gpu_count);
self.overlay = Some(Overlay::Confirm(start_dialog(Box::new(plan), None)));
self.start_catalog = None;
self.start_gpus = None;
let Some(gpu_count) = gpu_count else {
return Vec::new();
};
let id = self.task_id();
self.start_catalog = Some(id);
vec![Effect::Spawn(id, Task::StartCatalog(gpu_count))]
}
pub(super) fn start_catalog_read(&mut self, gpus: Catalog) {
self.start_catalog = None;
let held = self.start_held.as_deref_mut();
let shown = match &mut self.overlay {
Some(Overlay::Confirm(Confirm {
action: Action::Start(plan),
..
})) => Some(&mut **plan),
_ => None,
};
for plan in [held, shown].into_iter().flatten() {
if let Some(runpod) = plan.runpod.as_deref_mut() {
runpod.need = Some(gpus.need.clone());
}
}
self.start_gpus = Some(gpus);
if let Some(Overlay::Confirm(confirm)) = &mut self.overlay {
match &confirm.action {
Action::Start(plan) => confirm.text = start::text(plan, self.start_gpus.as_ref()),
Action::Auto(plan) => confirm.text = auto::text(plan, self.start_gpus.as_ref()),
_ => {},
}
}
}
pub(super) fn start_run(&mut self, plan: &StartPlan) -> Vec<Effect> {
if self.start_refused() {
return Vec::new();
}
let runpod = plan.runpod.is_some();
let vram_floor = plan
.runpod
.as_ref()
.and_then(|runpod| runpod.need.as_ref())
.map_or(VramFloor::ToEstimate, |need| {
VramFloor::Known(need.as_ref().ok().map(Estimate::floor_gb))
});
let task = plan
.resume
.as_ref()
.map_or(TrainJob::Start { vram_floor }, |resume| {
TrainJob::Resume(resume.run_id.clone())
});
let effects = self.spawn_train(Job::Start { runpod }, "", task);
let resuming = plan.resume.as_ref().map_or_else(String::new, |resume| {
format!(", resuming run {}", resume.run_id)
});
self.say(
Severity::Info,
format!("starting a run on {}{resuming}", plan.target),
);
effects
}
pub(super) fn start_dropped(&mut self, id: TaskId) {
self.training.tasks.remove(&id);
self.exit_notes.push(NOT_STARTED.to_string());
self.leave_when_idle();
}
fn train(&mut self, job: Job, run_id: &str) -> Vec<Effect> {
let task = match job {
Job::Start { .. } => TrainJob::Start {
vram_floor: VramFloor::ToEstimate,
},
Job::Attach => TrainJob::Attach(run_id.to_string()),
Job::Cancel => TrainJob::Cancel(run_id.to_string()),
Job::Stop => TrainJob::Stop(run_id.to_string()),
};
self.spawn_train(job, run_id, task)
}
fn spawn_train(&mut self, job: Job, run_id: &str, task: TrainJob) -> Vec<Effect> {
let id = self.task_id();
if !run_id.is_empty() {
self.forget_notes(&NoteOf::Run(run_id.to_string()));
}
self.training.last.retain(|_, run| run != run_id);
self.training.reading.remove(run_id);
self.training.dismissed.remove(run_id);
self.training.tasks.insert(id, Follow::new(job, run_id));
vec![Effect::Spawn(id, Task::Train(task))]
}
fn ask_cancel(&mut self) {
let Some(row) = self.training.selected_run() else {
return;
};
let (id, target) = (row.record.id.clone(), row.record.target.clone());
if self.training.cancelling(&id) {
self.say(Severity::Info, format!("run {id} is being cancelled"));
return;
}
if self.leaving == Some(Exit::Signal) {
self.say(Severity::Warn, "refused: interrupted, exiting");
return;
}
let activity = self.training.activity(&id);
let starting = self
.training
.task_of(&id)
.filter(|_| activity.abandons())
.map(|(task, _)| (task, matches!(activity, RunActivity::Abandoning { .. })));
if let Some((task, abandoned)) = starting {
if abandoned {
self.say(Severity::Info, format!("run {id} is being abandoned"));
return;
}
let text = format!(
"Run {id} is still starting, so it has no job to cancel yet. Abandon it \
instead? {ABANDONED}"
);
self.overlay = Some(Overlay::Confirm(Confirm {
title: " Abandon a starting run? ".to_string(),
text: vec![text],
yes: "abandon",
no: "keep it",
action: Action::AbandonStart(task),
}));
return;
}
self.overlay = Some(Overlay::Confirm(Confirm {
title: " Cancel a run? ".to_string(),
text: vec![format!(
"Cancel run {id} on {target}? Its job is stopped (its container first), what it \
produced is retrieved best effort, and for Runpod the pod is then ended. This \
cannot be undone."
)],
yes: "cancel the run",
no: "keep it",
action: Action::Cancel(id),
}));
}
pub(super) fn cancel_run(&mut self, run_id: &str) -> Vec<Effect> {
if self.training.cancelling(run_id) {
self.say(Severity::Info, format!("run {run_id} is being cancelled"));
return Vec::new();
}
let Some((task, _)) = self.training.task_of(run_id) else {
return self.train(Job::Cancel, run_id);
};
let Some(follow) = self.training.tasks.get_mut(&task) else {
return Vec::new();
};
follow.cancel_after = true;
let said = if follow.starting() {
format!("run {run_id} is starting: it is cancelled once its job started")
} else {
format!("detaching run {run_id}, then cancelling it")
};
self.say(Severity::Info, said);
self.detach(task)
}
fn detach(&mut self, id: TaskId) -> Vec<Effect> {
let Some(follow) = self.training.tasks.get_mut(&id) else {
return Vec::new();
};
match (follow.detach, follow.watching) {
(Detach::Done, _) => Vec::new(),
(_, true) => {
follow.detach = Detach::Done;
vec![Effect::Cancel(id)]
},
(_, false) => {
follow.detach = Detach::OnStart;
Vec::new()
},
}
}
pub(super) fn on_training_message(&mut self, id: TaskId, message: Msg) -> Vec<Effect> {
if !self.training.tasks.contains_key(&id) {
self.late_training_message(id, message);
return Vec::new();
}
match message {
Msg::Event(_, event) => {
let started = self.training.event(id, event);
let waiting = self
.training
.tasks
.get(&id)
.is_some_and(|follow| follow.detach == Detach::OnStart);
if started && waiting {
return self.detach(id);
}
},
Msg::Lagged(_, skipped) => {
if let Some(follow) = self.training.tasks.get_mut(&id) {
follow.skipped += skipped;
}
},
Msg::Report(_, Report::Line(line)) => {
if let Some(follow) = self.training.tasks.get_mut(&id) {
follow.lines.push(line);
}
},
Msg::Report(_, Report::RunStopped(step)) => {
if let Some(follow) = self.training.tasks.get_mut(&id) {
follow.stopped_at = Some(step);
}
},
Msg::Report(_, Report::RunCreated(run_id)) => {
if let Some(follow) = self.training.tasks.get_mut(&id) {
follow.run_id.clone_from(&run_id);
}
self.training.wanted = Some(run_id);
return self.refresh_runs();
},
Msg::EditorExited(_)
| Msg::BrowserFailed(_)
| Msg::LogsExported(_)
| Msg::NewerRelease(_) => {},
}
Vec::new()
}
fn late_training_message(&mut self, id: TaskId, message: Msg) {
let Some(run) = self.training.last.get(&id).cloned() else {
return;
};
let healed = self.training.ended.get(&run).is_some_and(|e| e.healed);
match message {
Msg::Event(_, Event::Metric(metric)) if !healed => {
self.training
.phases
.entry(run.clone())
.or_default()
.metric(&metric);
self.training.series.entry(run).or_default().push(metric);
},
Msg::Event(_, Event::Mark(mark)) if !healed => {
self.training.phases.entry(run).or_default().mark(mark);
},
Msg::Lagged(_, skipped) => {
if let Some(ended) = self.training.ended.get_mut(&run) {
ended.skipped += skipped;
}
},
Msg::Report(_, Report::Line(line)) => {
if self.leaving.is_some() {
self.exit_notes.push(line.clone());
}
if let Some(ended) = self.training.ended.get_mut(&run) {
ended.lines.push(line);
}
},
_ => {},
}
}
pub(super) fn trained(&mut self, id: TaskId, result: Result<(), String>) -> Vec<Effect> {
let ended = self
.training
.tasks
.get(&id)
.map(|follow| (follow.run_id.clone(), follow.stopped_at));
let error = result.as_ref().err().cloned();
let effects = self.run_ended(id, result);
if let Some((run, stopped_at)) = ended {
self.auto_trained(id, &run, error.as_deref(), stopped_at);
}
effects
}
fn run_ended(&mut self, id: TaskId, result: Result<(), String>) -> Vec<Effect> {
let Some(follow) = self.training.tasks.remove(&id) else {
return Vec::new();
};
let run = follow.run_id.clone();
let name = follow.run();
let error = result.err();
let cancel = follow.cancel_after && self.leaving != Some(Exit::Signal);
let stop = follow.stop_after && self.leaving.is_none();
if self.leaving.is_some() {
self.exit_notes.extend(follow.lines.iter().cloned());
if !cancel && let Some(error) = &error {
self.note_leaving(error.clone(), NoteOf::Run(run.clone()));
}
if follow.stop_after {
self.note_leaving(
format!(
"run {run} was not stopped: the TUI ended before its stop started; stop \
it with `overbrainer train stop {run}`"
),
NoteOf::Run(run.clone()),
);
}
if follow.cancel_after && !cancel {
let note = if follow.starting() {
format!(
"run {run} was not cancelled (a signal came during its start); if it \
is running, cancel it with `overbrainer train cancel {run}`"
)
} else {
format!(
"run {run} was not cancelled: interrupted before its cancel started; \
cancel it with `overbrainer train cancel {run}`"
)
};
self.note_leaving(note, NoteOf::Run(run.clone()));
}
}
match &error {
Some(error) if !follow.cancel_after && !follow.stop_after => {
self.say(Severity::Warn, format!("{name}: {error}"));
},
Some(_) => {},
None => self.say(Severity::Info, format!("{name}: done")),
}
if run.is_empty() {
self.leave_when_idle();
return Vec::new();
}
self.training.ended.insert(
run.clone(),
Ended {
lines: follow.lines,
error,
skipped: follow.skipped,
healed: false,
},
);
self.training.last.insert(id, run.clone());
let mut effects = Vec::new();
if self.leaving.is_none() {
effects.extend(self.read_series(&run));
effects.extend(self.refresh_runs());
}
if cancel {
effects.extend(self.train(Job::Cancel, &run));
} else if stop {
effects.extend(self.train(Job::Stop, &run));
}
self.leave_when_idle();
effects
}
pub(super) fn training_quit_text(&self) -> Vec<String> {
self.training
.tasks
.values()
.map(|follow| {
let run = &follow.run_id;
let pod = match follow.job {
Job::Start { runpod: true } => " its pod",
_ => "",
};
match follow.job {
Job::Cancel => format!("Run {run}: cancel in progress, quitting waits for it."),
Job::Stop => format!(
"Run {run}: its snapshot was asked for and it keeps running until it saves \
it; attach again here or with `overbrainer train attach {run}`."
),
_ if follow.cancel_after => format!(
"Run {run}: cancel pending, it starts once the run is detached; \
quitting waits for it."
),
Job::Start { .. } if follow.starting() => format!(
"{} is starting{pod}: quitting waits until its job has started, then \
leaves it running.",
capitalized(&follow.run())
),
Job::Start { .. } | Job::Attach => format!(
"Run {run} keeps running{}; attach again from here or with \
`overbrainer train attach {run}`.",
self.where_it_runs(run)
),
}
})
.collect()
}
fn where_it_runs(&self, run: &str) -> String {
let Some(row) = self.training.runs.iter().find(|row| row.record.id == run) else {
return String::new();
};
match row
.pod
.as_ref()
.and_then(|pod| pod.pod_id.as_ref().map(|id| (id, pod)))
{
Some((pod_id, pod)) => match pod.cost_per_hour {
Some(rate) => format!(" on pod {pod_id} (${rate:.2}/h)"),
None => format!(" on pod {pod_id}"),
},
None => format!(" on target `{}`", row.record.target),
}
}
pub(super) fn detach_all(&mut self) -> Vec<Effect> {
let following: Vec<TaskId> = self
.training
.tasks
.iter()
.filter(|(_, follow)| follow.job != Job::Cancel)
.map(|(id, _)| *id)
.collect();
following
.into_iter()
.flat_map(|id| self.detach(id))
.collect()
}
pub(super) fn offer_abandon(&mut self) {
if self.leaving != Some(Exit::Quit) {
return;
}
let starting: Vec<(TaskId, String)> = self
.training
.tasks
.iter()
.filter(|(_, follow)| {
follow.job == Job::Start { runpod: true }
&& follow.starting()
&& follow.detach != Detach::Done
})
.map(|(id, follow)| (*id, follow.run()))
.collect();
if starting.is_empty() {
return;
}
let runs: Vec<&str> = starting.iter().map(|(_, run)| run.as_str()).collect();
let text = format!(
"Quitting waits until the job of {} has started, which can take minutes. Abandon \
it instead? {ABANDONED}",
runs.join(", ")
);
self.overlay = Some(Overlay::Confirm(Confirm {
title: " Abandon a starting run? ".to_string(),
text: vec![text],
yes: "abandon",
no: "wait",
action: Action::Abandon(starting.into_iter().map(|(id, _)| id).collect()),
}));
}
pub(super) fn abandon(&mut self, ids: &[TaskId]) -> Vec<Effect> {
let mut effects = Vec::new();
for id in ids {
if let Some(follow) = self.training.tasks.get_mut(id)
&& follow.starting()
&& follow.detach != Detach::Done
{
follow.detach = Detach::Done;
follow.cancel_after = false;
effects.push(Effect::Abandon(*id));
}
}
effects
}
pub(super) fn abandon_start(&mut self, id: TaskId) -> Vec<Effect> {
let Some(follow) = self.training.tasks.get(&id) else {
return Vec::new();
};
let run = follow.run();
if !follow.starting() {
self.say(
Severity::Warn,
format!(
"{run}: its job started, so it was not abandoned and its pod keeps billing; \
press c to cancel it"
),
);
return Vec::new();
}
if follow.detach == Detach::Done {
self.say(Severity::Info, format!("{run} is being abandoned"));
return Vec::new();
}
self.say(Severity::Info, format!("abandoning {run}"));
self.abandon(&[id])
}
pub(super) fn abandon_all(&mut self) -> Vec<Effect> {
self.training
.tasks
.iter_mut()
.filter(|(_, follow)| follow.job != Job::Cancel)
.map(|(id, follow)| {
follow.detach = Detach::Done;
Effect::Abandon(*id)
})
.collect()
}
pub(super) fn keep_following(&mut self) -> (usize, usize) {
let (mut detached, mut abandoned) = (0, 0);
for follow in self.training.tasks.values_mut() {
if follow.detach == Detach::OnStart && !follow.cancel_after {
follow.detach = Detach::No;
}
if follow.detach != Detach::Done || follow.job == Job::Cancel {
continue;
}
if follow.starting() {
abandoned += 1;
} else {
detached += 1;
}
}
(detached, abandoned)
}
pub(super) fn never() -> SystemTime {
SystemTime::UNIX_EPOCH
}
}
fn capitalized(text: &str) -> String {
let mut chars = text.chars();
chars.next().map_or_else(String::new, |first| {
first.to_uppercase().chain(chars).collect()
})
}
pub(super) const NOT_STARTED: &str = "a new training run was not started: the TUI ended first; \
start it again with `overbrainer train` or t";
pub(super) fn start_dialog(plan: Box<StartPlan>, gpus: Option<&Catalog>) -> Confirm {
let title = if plan.resume.is_some() {
" Resume a training run? "
} else {
" Start a training run? "
};
Confirm {
title: title.to_string(),
text: start::text(&plan, gpus),
yes: "start",
no: "cancel",
action: Action::Start(plan),
}
}