use std::collections::{BTreeMap, BTreeSet, VecDeque};
use std::path::Path;
use std::time::{Duration, SystemTime};
use super::tasks::TaskId;
use crate::events::Event;
use crate::exec::JobStatus;
use crate::runpod::{PodRecord, PodState, PodStatus};
use crate::runs::{RunRecord, RunState, Runs};
use crate::system::SystemSample;
use crate::train::{METRICS_FILE, MetricLine, Pace, Phase, Phases, TrainMetric, parse_line};
pub(super) const SYSTEM_SAMPLES: usize = 60;
#[derive(Debug, Clone, PartialEq)]
pub(super) struct RunRow {
pub(super) record: RunRecord,
pub(super) pod: Option<PodRecord>,
}
fn live_spend(record: &PodRecord, rate: Option<f64>, now: SystemTime) -> Option<f64> {
Some(rate? * record.uptime(now)?.as_secs_f64() / 3600.0)
}
pub(super) fn pod_rate(record: &PodRecord, latest: Option<&PodStatus>) -> Option<f64> {
record.cost_per_hour.or(match latest {
Some(PodStatus::Created { cost_per_hour, .. }) => *cost_per_hour,
_ => None,
})
}
pub(super) fn pod_spend(
record: &PodRecord,
latest: Option<&PodStatus>,
now: SystemTime,
) -> Option<f64> {
match latest {
Some(PodStatus::Deleted {
estimated_spend, ..
}) => estimated_spend.or(record.estimated_spend),
_ if record.state == PodState::Deleted => record.estimated_spend,
_ => live_spend(record, pod_rate(record, latest), now),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum Job {
Start {
runpod: bool,
},
Attach,
Cancel,
Stop,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum Detach {
No,
OnStart,
Done,
}
#[derive(Debug, Clone, PartialEq)]
pub(super) struct Follow {
pub(super) job: Job,
pub(super) run_id: String,
pub(super) watching: bool,
pub(super) status: Option<JobStatus>,
pub(super) pod: Option<PodStatus>,
pub(super) lines: Vec<String>,
pub(super) skipped: u64,
pub(super) cancel_after: bool,
pub(super) stop_after: bool,
pub(super) stopped_at: Option<u64>,
pub(super) detach: Detach,
}
impl Follow {
pub(super) fn new(job: Job, run_id: &str) -> Self {
Self {
job,
run_id: run_id.to_string(),
watching: false,
status: None,
pod: None,
lines: Vec::new(),
skipped: 0,
cancel_after: false,
stop_after: false,
stopped_at: None,
detach: Detach::No,
}
}
pub(super) fn starting(&self) -> bool {
matches!(self.job, Job::Start { .. }) && !self.watching
}
pub(super) fn run(&self) -> String {
if self.run_id.is_empty() {
"a new run".to_string()
} else {
format!("run {}", self.run_id)
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum RunActivity {
None,
Followed,
Starting {
runpod: bool,
},
Abandoning {
runpod: bool,
},
Cancelling,
Stopping,
}
impl RunActivity {
pub(super) fn of(follow: Option<&Follow>) -> Self {
let Some(follow) = follow else {
return Self::None;
};
if follow.job == Job::Cancel || follow.cancel_after {
return Self::Cancelling;
}
if follow.job == Job::Stop || follow.stop_after {
return Self::Stopping;
}
let runpod = follow.job == (Job::Start { runpod: true });
match (follow.starting(), follow.detach) {
(true, Detach::Done) => Self::Abandoning { runpod },
(true, _) => Self::Starting { runpod },
(false, _) => Self::Followed,
}
}
pub(super) fn label(self) -> &'static str {
match self {
Self::None => "",
Self::Followed => "followed",
Self::Starting { .. } | Self::Abandoning { .. } => "starting",
Self::Cancelling => "cancelling",
Self::Stopping => "stopping",
}
}
pub(super) fn abandons(self) -> bool {
matches!(
self,
Self::Starting { runpod: true } | Self::Abandoning { runpod: true }
)
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(super) struct Ended {
pub(super) lines: Vec<String>,
pub(super) error: Option<String>,
pub(super) skipped: u64,
pub(super) healed: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub(super) struct Listing {
pub(super) runs: Result<Vec<RunRow>, String>,
pub(super) skipped: Vec<String>,
}
pub(super) fn list_runs(dir: &Path) -> Listing {
let runs = Runs::new(dir);
let mut skipped = Vec::new();
let listed = runs.list_with(|id, error| {
skipped.push(format!("skipping unreadable run record for {id}: {error}"));
});
let runs = match listed {
Ok(records) => Ok(records
.into_iter()
.rev()
.map(|record| {
let pod = match PodRecord::load(&runs, &record.id) {
Ok(pod) => pod,
Err(error) => {
skipped.push(format!(
"cannot read the pod record of run {}: {error}",
record.id
));
None
},
};
RunRow { record, pod }
})
.collect()),
Err(error) => Err(format!("cannot list the runs: {error}")),
};
Listing { runs, skipped }
}
#[derive(Debug, Clone, Default, PartialEq)]
pub(super) struct TrainingView {
pub(super) runs: Vec<RunRow>,
pub(super) selected: usize,
pub(super) error: Option<String>,
pub(super) series: BTreeMap<String, Vec<TrainMetric>>,
pub(super) phases: BTreeMap<String, Phases>,
pub(super) tasks: BTreeMap<TaskId, Follow>,
pub(super) ended: BTreeMap<String, Ended>,
pub(super) last: BTreeMap<TaskId, String>,
pub(super) listing: Option<TaskId>,
pub(super) list_again: bool,
pub(super) wanted: Option<String>,
pub(super) reading: BTreeMap<String, TaskId>,
pub(super) warned: BTreeSet<String>,
pub(super) hidden: BTreeSet<String>,
pub(super) dismissed: BTreeSet<String>,
pub(super) system: BTreeMap<String, VecDeque<SystemSample>>,
pub(super) push_prepare: Option<TaskId>,
pub(super) pushing: Option<Pushing>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct Pushing {
pub(super) task: TaskId,
pub(super) run: String,
pub(super) progress: Option<(u64, u64)>,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub(super) struct Progress {
pub(super) step: u64,
pub(super) max_steps: Option<u64>,
pub(super) epoch: Option<f64>,
pub(super) eta: Option<Duration>,
}
pub(super) fn progress(series: &[TrainMetric]) -> Option<Progress> {
let latest = series.last()?;
let mut pace = Pace::default();
for metric in series {
pace.add(metric);
}
Some(Progress {
step: latest.step,
max_steps: pace.max_steps(),
epoch: series.iter().rev().find_map(|m| m.epoch),
eta: pace.eta(),
})
}
pub(super) fn float(count: u64) -> f64 {
f64::from(u32::try_from(count).unwrap_or(u32::MAX))
}
pub(super) fn read_series(dir: &Path, id: &str) -> Option<(Vec<TrainMetric>, Phases)> {
let path = Runs::new(dir).run_dir(id).ok()?.join(METRICS_FILE);
let content = std::fs::read_to_string(path).ok()?;
let mut series = Vec::new();
let mut phases = Phases::default();
for line in content.lines().filter_map(|line| parse_line(line).ok()) {
phases.line(&line);
if let MetricLine::Log(metric) = line {
series.push(metric);
}
}
Some((series, phases))
}
impl TrainingView {
pub(super) fn listed(&mut self, listing: Listing) -> Vec<String> {
let selected = self.selected_run().map(|row| row.record.id.clone());
match listing.runs {
Ok(runs) => {
self.runs = runs;
let hidden = &self.hidden;
self.runs.retain(|row| !hidden.contains(&row.record.id));
self.error = None;
},
Err(error) => self.error = Some(error),
}
if let Some(id) = selected {
self.select(&id);
}
if let Some(id) = self.wanted.take() {
if self.runs.iter().any(|row| row.record.id == id) {
self.select(&id);
} else {
self.wanted = Some(id);
}
}
self.selected = self.selected.min(self.runs.len().saturating_sub(1));
listing
.skipped
.into_iter()
.filter(|warning| self.warned.insert(warning.clone()))
.collect()
}
pub(super) fn failed_runs(&self) -> Vec<String> {
self.runs
.iter()
.filter(|row| row.record.state == RunState::Failed)
.map(|row| row.record.id.clone())
.filter(|id| self.task_of(id).is_none())
.collect()
}
pub(super) fn hide(&mut self, ids: &[String]) {
let selected = self.selected_run().map(|row| row.record.id.clone());
self.hidden.extend(ids.iter().cloned());
let hidden = &self.hidden;
self.runs.retain(|row| !hidden.contains(&row.record.id));
if let Some(id) = selected {
self.select(&id);
}
self.selected = self.selected.min(self.runs.len().saturating_sub(1));
}
pub(super) fn select(&mut self, id: &str) {
if let Some(index) = self.runs.iter().position(|row| row.record.id == id) {
self.selected = index;
}
}
pub(super) fn selected_run(&self) -> Option<&RunRow> {
self.runs.get(self.selected)
}
pub(super) fn selected_ratio(&self) -> Option<f64> {
let row = self.selected_run()?;
let now = progress(self.series.get(&row.record.id)?)?;
let max = now.max_steps.filter(|max| *max > 0)?;
Some((float(now.step) / float(max)).min(1.0))
}
pub(super) fn phase(&self, row: &RunRow) -> Phase {
let id = &row.record.id;
let exited = self
.task_of(id)
.and_then(|(_, follow)| follow.status)
.is_some_and(JobStatus::is_finished)
&& row.record.state == RunState::Running;
let phases = self.phases.get(id).copied().unwrap_or_else(|| {
let mut phases = Phases::default();
for metric in self.series.get(id).into_iter().flatten() {
phases.metric(metric);
}
phases
});
phases.phase(exited)
}
pub(super) fn activity(&self, id: &str) -> RunActivity {
RunActivity::of(self.task_of(id).map(|(_, follow)| follow))
}
pub(super) fn selected_activity(&self) -> RunActivity {
self.selected_run()
.map_or(RunActivity::None, |row| self.activity(&row.record.id))
}
pub(super) fn task_of(&self, id: &str) -> Option<(TaskId, &Follow)> {
self.tasks
.iter()
.find(|(_, follow)| follow.run_id == id)
.map(|(task, follow)| (*task, follow))
}
pub(super) fn is_training(&self, id: TaskId) -> bool {
self.tasks.contains_key(&id) || self.last.contains_key(&id)
}
pub(super) fn to_read(&self) -> Option<String> {
let id = &self.selected_run()?.record.id;
let skip = self.series.contains_key(id)
|| self.reading.contains_key(id)
|| self.task_of(id).is_some();
(!skip).then(|| id.clone())
}
pub(super) fn cancelling(&self, id: &str) -> bool {
self.activity(id) == RunActivity::Cancelling
}
pub(super) fn stopping(&self, id: &str) -> bool {
self.activity(id) == RunActivity::Stopping
}
pub(super) fn event(&mut self, id: TaskId, event: Event) -> bool {
let Some(follow) = self.tasks.get_mut(&id) else {
return false;
};
match event {
Event::Metric(metric) => {
self.phases
.entry(follow.run_id.clone())
.or_default()
.metric(&metric);
self.series
.entry(follow.run_id.clone())
.or_default()
.push(metric);
},
Event::Mark(mark) => {
self.phases
.entry(follow.run_id.clone())
.or_default()
.mark(mark);
},
Event::JobStatus(status) => {
follow.status = Some(status);
if !follow.watching {
follow.watching = true;
return true;
}
},
Event::PodStatus(status) => follow.pod = Some(status),
Event::System(sample) => {
let samples = self.system.entry(follow.run_id.clone()).or_default();
samples.push_back(sample);
while samples.len() > SYSTEM_SAMPLES {
samples.pop_front();
}
},
_ => {},
}
false
}
}
#[cfg(test)]
mod tests {
use super::*;
fn log(time: f64, step: u64, loss: Option<f64>) -> TrainMetric {
TrainMetric {
time,
step,
epoch: Some(0.5),
max_steps: Some(400),
loss,
eval_loss: loss.is_none().then_some(1.0),
learning_rate: Some(2e-4),
grad_norm: Some(0.8),
}
}
#[test]
fn the_eta_follows_the_training_logs_on_the_plugins_clock() -> Result<(), String> {
let series = [
log(100.0, 10, Some(2.0)),
log(150.0, 60, None),
log(200.0, 110, Some(1.5)),
];
let now = progress(&series).ok_or("no progress")?;
assert_eq!((now.step, now.max_steps), (110, Some(400)));
assert_eq!(now.eta, Some(Duration::from_secs(290)));
assert_eq!(progress(&series[..1]).and_then(|p| p.eta), None);
assert_eq!(progress(&[]), None);
Ok(())
}
#[test]
fn one_activity_says_what_a_task_does_to_its_run() {
let runpod = Job::Start { runpod: true };
let task = |job: &Job, change: &dyn Fn(&mut Follow)| {
let mut follow = Follow::new(job.clone(), "run");
change(&mut follow);
RunActivity::of(Some(&follow))
};
let keep = |_: &mut Follow| {};
assert_eq!(RunActivity::of(None), RunActivity::None);
assert_eq!(task(&Job::Attach, &keep), RunActivity::Followed);
assert_eq!(task(&runpod, &|f| f.watching = true), RunActivity::Followed);
assert_eq!(task(&runpod, &keep), RunActivity::Starting { runpod: true });
assert_eq!(
task(&Job::Start { runpod: false }, &keep),
RunActivity::Starting { runpod: false }
);
assert_eq!(
task(&runpod, &|f| f.detach = Detach::Done),
RunActivity::Abandoning { runpod: true }
);
assert_eq!(task(&Job::Cancel, &keep), RunActivity::Cancelling);
assert_eq!(
task(&runpod, &|f| f.cancel_after = true),
RunActivity::Cancelling
);
assert!(task(&runpod, &keep).abandons());
assert!(task(&runpod, &|f| f.detach = Detach::Done).abandons());
assert!(!task(&Job::Start { runpod: false }, &keep).abandons());
assert!(!task(&Job::Attach, &keep).abandons());
let labels: Vec<&str> = [
RunActivity::None,
RunActivity::Followed,
RunActivity::Starting { runpod: true },
RunActivity::Abandoning { runpod: true },
RunActivity::Cancelling,
]
.map(RunActivity::label)
.to_vec();
assert_eq!(
labels,
["", "followed", "starting", "starting", "cancelling"]
);
}
#[test]
fn the_machine_of_a_run_keeps_its_last_samples() {
let mut view = TrainingView::default();
view.tasks
.insert(TaskId(1), Follow::new(Job::Attach, "run-a"));
let sample = |seconds| SystemSample {
at: SystemTime::UNIX_EPOCH + Duration::from_secs(seconds),
disks: Vec::new(),
cpu: None,
memory: None,
gpus: Vec::new(),
};
for seconds in 0..70 {
assert!(!view.event(TaskId(1), Event::System(sample(seconds))));
}
view.event(TaskId(2), Event::System(sample(99)));
let kept = view
.system
.get("run-a")
.map(|s| s.iter().map(|x| x.at).collect::<Vec<_>>());
let expected: Vec<SystemTime> = (10..70).map(sample).map(|s| s.at).collect();
assert_eq!(kept, Some(expected));
assert_eq!(view.system.len(), 1);
}
#[test]
fn the_latest_job_status_and_marks_tell_the_phase() -> Result<(), serde_json::Error> {
let mut view = TrainingView::default();
let mut record: RunRecord = serde_json::from_value(serde_json::json!({
"id": "run-a",
"target": "box",
"remote_dir": "/w/run-a",
"state": "running",
"created": "2026-09-21T14:00:00Z",
}))?;
view.runs.push(RunRow {
record: record.clone(),
pod: None,
});
view.tasks
.insert(TaskId(1), Follow::new(Job::Attach, "run-a"));
let row = |view: &TrainingView| view.runs[0].clone();
assert!(view.event(TaskId(1), Event::JobStatus(JobStatus::Running)));
assert!(!view.event(TaskId(1), Event::Metric(log(1.0, 400, Some(1.0)))));
assert_eq!(view.phase(&row(&view)), Phase::Finalizing);
view.event(
TaskId(1),
Event::Mark(crate::train::Mark::Stage(crate::train::JobStage::Merge)),
);
assert_eq!(view.phase(&row(&view)), Phase::Merging);
assert!(!view.event(TaskId(1), Event::JobStatus(JobStatus::Exited(0))));
assert_eq!(view.phase(&row(&view)), Phase::Retrieving);
record.state = RunState::Succeeded;
view.runs[0].record = record;
assert_eq!(view.phase(&row(&view)), Phase::Merging);
Ok(())
}
#[test]
fn a_series_is_read_from_the_local_metrics_file() -> Result<(), Box<dyn std::error::Error>> {
let dir = tempfile::tempdir()?;
let run = dir.path().join("runs/20260921-140000-a1b2");
std::fs::create_dir_all(&run)?;
std::fs::write(
run.join(METRICS_FILE),
"{\"event\": \"begin\", \"time\": 1, \"max_steps\": 2}\nnot json\n\
{\"event\": \"log\", \"time\": 2, \"step\": 1, \"loss\": 1.5}\n\
{\"event\": \"end\", \"time\": 3, \"step\": 1}\n",
)?;
let series = read_series(dir.path(), "20260921-140000-a1b2");
assert_eq!(
series.map(|(s, phases)| (s.len(), phases.phase(false))),
Some((1, Phase::Finalizing))
);
assert_eq!(read_series(dir.path(), "20260921-140000-ffff"), None);
Ok(())
}
}