rom: bump dependencies; further work for feature parity
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I27d00ffe0d27497bdc6c1f890af3bdfe6a6a6964
This commit is contained in:
parent
2897c607c6
commit
27ecaac59c
11 changed files with 1274 additions and 337 deletions
588
Cargo.lock
generated
588
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -7,7 +7,7 @@ version = "0.1.0"
|
|||
edition = "2024"
|
||||
authors = ["NotAShelf <raf@notashelf.dev>"]
|
||||
description = "Pretty build graphs for Nix builds"
|
||||
rust-version = "1.85"
|
||||
rust-version = "1.91.1"
|
||||
|
||||
[workspace.dependencies]
|
||||
anyhow = "1.0.100"
|
||||
|
|
@ -19,6 +19,7 @@ crossterm = "0.29.0"
|
|||
ratatui = "0.29.0"
|
||||
indexmap = { version = "2.12.0", features = ["serde"] }
|
||||
csv = "1.4.0"
|
||||
chrono = "0.4.42"
|
||||
thiserror = "2.0.17"
|
||||
tracing = "0.1.41"
|
||||
tracing-subscriber = { version = "0.3.20", features = ["env-filter"] }
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ crossterm = "0.29"
|
|||
ratatui = "0.29"
|
||||
indexmap.workspace = true
|
||||
csv.workspace = true
|
||||
chrono.workspace = true
|
||||
thiserror.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
|
|
|
|||
397
rom/src/cache.rs
Normal file
397
rom/src/cache.rs
Normal file
|
|
@ -0,0 +1,397 @@
|
|||
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fs::{self, File, OpenOptions},
|
||||
io::{BufReader, BufWriter},
|
||||
path::PathBuf,
|
||||
time::SystemTime,
|
||||
};
|
||||
|
||||
use csv::{Reader, Writer};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::state::BuildReport;
|
||||
|
||||
/// Maximum number of historical builds to keep per derivation
|
||||
const HISTORY_LIMIT: usize = 10;
|
||||
|
||||
/// Build report cache for CSV persistence
|
||||
pub struct BuildReportCache {
|
||||
cache_path: PathBuf,
|
||||
}
|
||||
|
||||
/// CSV row format for build reports
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct BuildReportRow {
|
||||
hostname: String,
|
||||
derivation_name: String,
|
||||
utc_time: String,
|
||||
build_seconds: u64,
|
||||
}
|
||||
|
||||
impl BuildReportCache {
|
||||
/// Create a new cache instance with the given path
|
||||
#[must_use]
|
||||
pub fn new(cache_path: PathBuf) -> Self {
|
||||
Self { cache_path }
|
||||
}
|
||||
|
||||
// FIXME: just use the dirs crate for this
|
||||
/// Get the default cache directory path
|
||||
///
|
||||
/// Uses `$XDG_STATE_HOME` if set, otherwise ``~/.local/state`
|
||||
#[must_use]
|
||||
pub fn default_cache_dir() -> PathBuf {
|
||||
if let Ok(xdg_state) = std::env::var("XDG_STATE_HOME") {
|
||||
PathBuf::from(xdg_state).join("rom")
|
||||
} else if let Ok(home) = std::env::var("HOME") {
|
||||
PathBuf::from(home).join(".local/state/rom")
|
||||
} else {
|
||||
PathBuf::from(".rom")
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the default cache file path
|
||||
#[must_use]
|
||||
pub fn default_cache_path() -> PathBuf {
|
||||
Self::default_cache_dir().join("build-reports.csv")
|
||||
}
|
||||
|
||||
/// Load build reports from CSV
|
||||
///
|
||||
/// Returns empty [`HashMap`] if file doesn't exist or parsing fails
|
||||
pub fn load(&self) -> HashMap<(String, String), Vec<BuildReport>> {
|
||||
if !self.cache_path.exists() {
|
||||
return HashMap::new();
|
||||
}
|
||||
|
||||
let file = match File::open(&self.cache_path) {
|
||||
Ok(f) => f,
|
||||
Err(_) => return HashMap::new(),
|
||||
};
|
||||
|
||||
let reader = BufReader::new(file);
|
||||
let mut csv_reader = Reader::from_reader(reader);
|
||||
|
||||
let mut reports: HashMap<(String, String), Vec<BuildReport>> =
|
||||
HashMap::new();
|
||||
|
||||
for result in csv_reader.deserialize() {
|
||||
let row: BuildReportRow = match result {
|
||||
Ok(r) => r,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let completed_at = match parse_utc_time(&row.utc_time) {
|
||||
Some(t) => t,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
let report = BuildReport {
|
||||
derivation_name: row.derivation_name.clone(),
|
||||
platform: String::new(), // FIXME: not stored in CSV, for simplicity and because I'm lazy
|
||||
duration_secs: row.build_seconds as f64,
|
||||
completed_at,
|
||||
host: row.hostname.clone(),
|
||||
success: true, // only successful builds are cached
|
||||
};
|
||||
|
||||
let key = (row.hostname, row.derivation_name);
|
||||
reports.entry(key).or_default().push(report);
|
||||
}
|
||||
|
||||
// Sort each entry by timestamp (newest first) and limit to HISTORY_LIMIT
|
||||
for entries in reports.values_mut() {
|
||||
entries.sort_by(|a, b| b.completed_at.cmp(&a.completed_at));
|
||||
entries.truncate(HISTORY_LIMIT);
|
||||
}
|
||||
|
||||
reports
|
||||
}
|
||||
|
||||
/// Save build reports to CSV
|
||||
///
|
||||
/// Merges with existing reports and enforces history limit
|
||||
pub fn save(
|
||||
&self,
|
||||
reports: &HashMap<(String, String), Vec<BuildReport>>,
|
||||
) -> Result<(), std::io::Error> {
|
||||
// Ensure directory exists
|
||||
if let Some(parent) = self.cache_path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
// Load existing reports to merge
|
||||
let mut merged = self.load();
|
||||
|
||||
// Merge new reports
|
||||
for ((host, drv_name), new_reports) in reports {
|
||||
let key = (host.clone(), drv_name.clone());
|
||||
let existing = merged.entry(key).or_default();
|
||||
|
||||
// Add new reports
|
||||
existing.extend(new_reports.iter().cloned());
|
||||
|
||||
// Sort by timestamp (newest first)
|
||||
existing.sort_by(|a, b| b.completed_at.cmp(&a.completed_at));
|
||||
|
||||
// Keep only most recent HISTORY_LIMIT entries
|
||||
existing.truncate(HISTORY_LIMIT);
|
||||
}
|
||||
|
||||
// Write to CSV
|
||||
let file = OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.open(&self.cache_path)?;
|
||||
|
||||
let writer = BufWriter::new(file);
|
||||
let mut csv_writer = Writer::from_writer(writer);
|
||||
|
||||
// Flatten and write all reports
|
||||
for ((hostname, derivation_name), entries) in merged {
|
||||
for report in entries {
|
||||
let row = BuildReportRow {
|
||||
hostname: hostname.clone(),
|
||||
derivation_name: derivation_name.clone(),
|
||||
utc_time: format_utc_time(report.completed_at),
|
||||
build_seconds: report.duration_secs as u64,
|
||||
};
|
||||
csv_writer.serialize(row)?;
|
||||
}
|
||||
}
|
||||
|
||||
csv_writer.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Calculate median build time from historical reports
|
||||
///
|
||||
/// Returns [`None`] if there are no reports
|
||||
#[must_use]
|
||||
pub fn calculate_median(reports: &[BuildReport]) -> Option<u64> {
|
||||
if reports.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut durations: Vec<u64> =
|
||||
reports.iter().map(|r| r.duration_secs as u64).collect();
|
||||
durations.sort_unstable();
|
||||
|
||||
let len = durations.len();
|
||||
if len % 2 == 1 {
|
||||
Some(durations[len / 2])
|
||||
} else {
|
||||
let mid1 = durations[len / 2 - 1];
|
||||
let mid2 = durations[len / 2];
|
||||
Some((mid1 + mid2) / 2)
|
||||
}
|
||||
}
|
||||
|
||||
/// Get median build time for a specific derivation on a host
|
||||
#[must_use]
|
||||
pub fn get_estimate(
|
||||
&self,
|
||||
reports: &HashMap<(String, String), Vec<BuildReport>>,
|
||||
host: &str,
|
||||
derivation_name: &str,
|
||||
) -> Option<u64> {
|
||||
let key = (host.to_string(), derivation_name.to_string());
|
||||
let entries = reports.get(&key)?;
|
||||
Self::calculate_median(entries)
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse UTC time string in format "%Y-%m-%d %H:%M:%S"
|
||||
fn parse_utc_time(s: &str) -> Option<SystemTime> {
|
||||
// Simple parsing for "YYYY-MM-DD HH:MM:SS" format
|
||||
let parts: Vec<&str> = s.split([' ', '-', ':']).collect();
|
||||
if parts.len() != 6 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let year: i64 = parts[0].parse().ok()?;
|
||||
let month: u64 = parts[1].parse().ok()?;
|
||||
let day: u64 = parts[2].parse().ok()?;
|
||||
let hour: u64 = parts[3].parse().ok()?;
|
||||
let minute: u64 = parts[4].parse().ok()?;
|
||||
let second: u64 = parts[5].parse().ok()?;
|
||||
|
||||
// Approximate conversion to Unix timestamp
|
||||
// This is a simplified calculation that doesn't handle leap years perfectly
|
||||
let days_since_epoch = (year - 1970) * 365
|
||||
+ (year - 1969) / 4
|
||||
+ days_until_month(month)
|
||||
+ day as i64
|
||||
- 1;
|
||||
let seconds_since_epoch =
|
||||
days_since_epoch as u64 * 86400 + hour * 3600 + minute * 60 + second;
|
||||
|
||||
Some(
|
||||
SystemTime::UNIX_EPOCH
|
||||
+ std::time::Duration::from_secs(seconds_since_epoch),
|
||||
)
|
||||
}
|
||||
|
||||
// FIXME: I'm really sure there's a library for this but lets just get
|
||||
// this thing compiling
|
||||
/// Calculate days until the start of a month (approximation)
|
||||
const fn days_until_month(month: u64) -> i64 {
|
||||
match month {
|
||||
1 => 0,
|
||||
2 => 31,
|
||||
3 => 59,
|
||||
4 => 90,
|
||||
5 => 120,
|
||||
6 => 151,
|
||||
7 => 181,
|
||||
8 => 212,
|
||||
9 => 243,
|
||||
10 => 273,
|
||||
11 => 304,
|
||||
12 => 334,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
// FIXME: does Chrono do this?
|
||||
/// Format SystemTime as UTC string in format "%Y-%m-%d %H:%M:%S"
|
||||
fn format_utc_time(time: SystemTime) -> String {
|
||||
let duration = time
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.unwrap_or_default();
|
||||
let secs = duration.as_secs();
|
||||
|
||||
let days = secs / 86400;
|
||||
let remaining = secs % 86400;
|
||||
let hours = remaining / 3600;
|
||||
let minutes = (remaining % 3600) / 60;
|
||||
let seconds = remaining % 60;
|
||||
|
||||
// Approximate conversion from days since epoch to date
|
||||
let mut year = 1970;
|
||||
let mut days_left = days as i64;
|
||||
|
||||
// Subtract full years
|
||||
while days_left >= 365 {
|
||||
if is_leap_year(year) && days_left >= 366 {
|
||||
days_left -= 366;
|
||||
year += 1;
|
||||
} else if !is_leap_year(year) {
|
||||
days_left -= 365;
|
||||
year += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate month and day
|
||||
let (month, day) = calculate_month_day(days_left as u64, is_leap_year(year));
|
||||
|
||||
format!("{year:04}-{month:02}-{day:02} {hours:02}:{minutes:02}:{seconds:02}")
|
||||
}
|
||||
|
||||
const fn is_leap_year(year: i64) -> bool {
|
||||
(year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
|
||||
}
|
||||
|
||||
fn calculate_month_day(days: u64, is_leap: bool) -> (u8, u8) {
|
||||
let days_in_month: [u8; 12] = if is_leap {
|
||||
[31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
|
||||
} else {
|
||||
[31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
|
||||
};
|
||||
|
||||
let mut remaining = days as i32;
|
||||
for (i, &month_days) in days_in_month.iter().enumerate() {
|
||||
if remaining < i32::from(month_days) {
|
||||
return ((i + 1) as u8, (remaining + 1) as u8);
|
||||
}
|
||||
remaining -= i32::from(month_days);
|
||||
}
|
||||
|
||||
(12, 31)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_calculate_median_odd() {
|
||||
let reports = vec![
|
||||
BuildReport {
|
||||
derivation_name: "test".to_string(),
|
||||
platform: "x86_64-linux".to_string(),
|
||||
duration_secs: 10.0,
|
||||
completed_at: SystemTime::UNIX_EPOCH,
|
||||
host: "localhost".to_string(),
|
||||
success: true,
|
||||
},
|
||||
BuildReport {
|
||||
derivation_name: "test".to_string(),
|
||||
platform: "x86_64-linux".to_string(),
|
||||
duration_secs: 20.0,
|
||||
completed_at: SystemTime::UNIX_EPOCH,
|
||||
host: "localhost".to_string(),
|
||||
success: true,
|
||||
},
|
||||
BuildReport {
|
||||
derivation_name: "test".to_string(),
|
||||
platform: "x86_64-linux".to_string(),
|
||||
duration_secs: 30.0,
|
||||
completed_at: SystemTime::UNIX_EPOCH,
|
||||
host: "localhost".to_string(),
|
||||
success: true,
|
||||
},
|
||||
];
|
||||
|
||||
assert_eq!(BuildReportCache::calculate_median(&reports), Some(20));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_calculate_median_even() {
|
||||
let reports = vec![
|
||||
BuildReport {
|
||||
derivation_name: "test".to_string(),
|
||||
platform: "x86_64-linux".to_string(),
|
||||
duration_secs: 10.0,
|
||||
completed_at: SystemTime::UNIX_EPOCH,
|
||||
host: "localhost".to_string(),
|
||||
success: true,
|
||||
},
|
||||
BuildReport {
|
||||
derivation_name: "test".to_string(),
|
||||
platform: "x86_64-linux".to_string(),
|
||||
duration_secs: 20.0,
|
||||
completed_at: SystemTime::UNIX_EPOCH,
|
||||
host: "localhost".to_string(),
|
||||
success: true,
|
||||
},
|
||||
];
|
||||
|
||||
assert_eq!(BuildReportCache::calculate_median(&reports), Some(15));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_calculate_median_empty() {
|
||||
let reports = vec![];
|
||||
assert_eq!(BuildReportCache::calculate_median(&reports), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_parse_utc_time() {
|
||||
let time =
|
||||
SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_000_000);
|
||||
let formatted = format_utc_time(time);
|
||||
let parsed = parse_utc_time(&formatted).unwrap();
|
||||
|
||||
// Allow small difference due to approximation
|
||||
let diff = parsed
|
||||
.duration_since(time)
|
||||
.unwrap_or_else(|e| e.duration())
|
||||
.as_secs();
|
||||
assert!(diff < 86400); // less than 1 day difference
|
||||
}
|
||||
}
|
||||
179
rom/src/cli.rs
179
rom/src/cli.rs
|
|
@ -33,6 +33,14 @@ pub struct Cli {
|
|||
/// Summary display style: concise, table, full
|
||||
#[arg(long, global = true, default_value = "concise")]
|
||||
pub summary: String,
|
||||
|
||||
/// Log prefix style: short, full, none
|
||||
#[arg(long, global = true, default_value = "short")]
|
||||
pub log_prefix: String,
|
||||
|
||||
/// Maximum number of log lines to display
|
||||
#[arg(long, global = true)]
|
||||
pub log_lines: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, clap::Subcommand)]
|
||||
|
|
@ -86,6 +94,8 @@ pub fn run() -> eyre::Result<()> {
|
|||
cli.format.clone(),
|
||||
cli.legend.clone(),
|
||||
cli.summary.clone(),
|
||||
cli.log_prefix.clone(),
|
||||
cli.log_lines,
|
||||
)?;
|
||||
Ok(())
|
||||
},
|
||||
|
|
@ -101,6 +111,8 @@ pub fn run() -> eyre::Result<()> {
|
|||
cli.format.clone(),
|
||||
cli.legend.clone(),
|
||||
cli.summary.clone(),
|
||||
cli.log_prefix.clone(),
|
||||
cli.log_lines,
|
||||
)?;
|
||||
Ok(())
|
||||
},
|
||||
|
|
@ -110,14 +122,18 @@ pub fn run() -> eyre::Result<()> {
|
|||
// If no args provided and --json is set, use piping mode from stdin
|
||||
if args.is_empty() && cli.json {
|
||||
let config = crate::types::Config {
|
||||
piping: false,
|
||||
silent: cli.silent,
|
||||
input_mode: crate::types::InputMode::Json,
|
||||
show_timers: true,
|
||||
width: None,
|
||||
format: crate::types::DisplayFormat::from_str(&cli.format),
|
||||
legend_style: cli.legend.clone(),
|
||||
summary_style: cli.summary.clone(),
|
||||
piping: false,
|
||||
silent: cli.silent,
|
||||
input_mode: crate::types::InputMode::Json,
|
||||
show_timers: true,
|
||||
width: None,
|
||||
format: crate::types::DisplayFormat::from_str(&cli.format),
|
||||
legend_style: cli.legend.clone(),
|
||||
summary_style: cli.summary.clone(),
|
||||
log_prefix_style: crate::types::LogPrefixStyle::from_str(
|
||||
&cli.log_prefix,
|
||||
),
|
||||
log_line_limit: cli.log_lines,
|
||||
};
|
||||
|
||||
let stdin = io::stdin();
|
||||
|
|
@ -140,6 +156,8 @@ pub fn run() -> eyre::Result<()> {
|
|||
cli.format.clone(),
|
||||
cli.legend.clone(),
|
||||
cli.summary.clone(),
|
||||
cli.log_prefix.clone(),
|
||||
cli.log_lines,
|
||||
)?;
|
||||
Ok(())
|
||||
},
|
||||
|
|
@ -149,14 +167,18 @@ pub fn run() -> eyre::Result<()> {
|
|||
// If no args provided and --json is set, use piping mode from stdin
|
||||
if args.is_empty() && cli.json {
|
||||
let config = crate::types::Config {
|
||||
piping: false,
|
||||
silent: cli.silent,
|
||||
input_mode: crate::types::InputMode::Json,
|
||||
show_timers: true,
|
||||
width: None,
|
||||
format: crate::types::DisplayFormat::from_str(&cli.format),
|
||||
legend_style: cli.legend.clone(),
|
||||
summary_style: cli.summary.clone(),
|
||||
piping: false,
|
||||
silent: cli.silent,
|
||||
input_mode: crate::types::InputMode::Json,
|
||||
show_timers: true,
|
||||
width: None,
|
||||
format: crate::types::DisplayFormat::from_str(&cli.format),
|
||||
legend_style: cli.legend.clone(),
|
||||
summary_style: cli.summary.clone(),
|
||||
log_prefix_style: crate::types::LogPrefixStyle::from_str(
|
||||
&cli.log_prefix,
|
||||
),
|
||||
log_line_limit: cli.log_lines,
|
||||
};
|
||||
|
||||
let stdin = io::stdin();
|
||||
|
|
@ -179,6 +201,8 @@ pub fn run() -> eyre::Result<()> {
|
|||
cli.format.clone(),
|
||||
cli.legend.clone(),
|
||||
cli.summary.clone(),
|
||||
cli.log_prefix.clone(),
|
||||
cli.log_lines,
|
||||
)?;
|
||||
Ok(())
|
||||
},
|
||||
|
|
@ -188,14 +212,18 @@ pub fn run() -> eyre::Result<()> {
|
|||
// If no args provided and --json is set, use piping mode from stdin
|
||||
if args.is_empty() && cli.json {
|
||||
let config = crate::types::Config {
|
||||
piping: false,
|
||||
silent: cli.silent,
|
||||
input_mode: crate::types::InputMode::Json,
|
||||
show_timers: true,
|
||||
width: None,
|
||||
format: crate::types::DisplayFormat::from_str(&cli.format),
|
||||
legend_style: cli.legend.clone(),
|
||||
summary_style: cli.summary.clone(),
|
||||
piping: false,
|
||||
silent: cli.silent,
|
||||
input_mode: crate::types::InputMode::Json,
|
||||
show_timers: true,
|
||||
width: None,
|
||||
format: crate::types::DisplayFormat::from_str(&cli.format),
|
||||
legend_style: cli.legend.clone(),
|
||||
summary_style: cli.summary.clone(),
|
||||
log_prefix_style: crate::types::LogPrefixStyle::from_str(
|
||||
&cli.log_prefix,
|
||||
),
|
||||
log_line_limit: cli.log_lines,
|
||||
};
|
||||
|
||||
let stdin = io::stdin();
|
||||
|
|
@ -218,6 +246,8 @@ pub fn run() -> eyre::Result<()> {
|
|||
cli.format.clone(),
|
||||
cli.legend.clone(),
|
||||
cli.summary.clone(),
|
||||
cli.log_prefix.clone(),
|
||||
cli.log_lines,
|
||||
)?;
|
||||
Ok(())
|
||||
},
|
||||
|
|
@ -239,6 +269,10 @@ pub fn run() -> eyre::Result<()> {
|
|||
format: crate::types::DisplayFormat::from_str(&cli.format),
|
||||
legend_style: cli.legend.clone(),
|
||||
summary_style: cli.summary.clone(),
|
||||
log_prefix_style: crate::types::LogPrefixStyle::from_str(
|
||||
&cli.log_prefix,
|
||||
),
|
||||
log_line_limit: cli.log_lines,
|
||||
};
|
||||
|
||||
let stdin = io::stdin();
|
||||
|
|
@ -280,6 +314,8 @@ fn run_nix_build_wrapper(
|
|||
format: String,
|
||||
legend_style: String,
|
||||
summary_style: String,
|
||||
log_prefix: String,
|
||||
log_lines: Option<usize>,
|
||||
) -> eyre::Result<()> {
|
||||
// Validate that at least one package/flake is specified
|
||||
if package_and_rom_args.is_empty() {
|
||||
|
|
@ -310,6 +346,8 @@ fn run_nix_build_wrapper(
|
|||
format,
|
||||
legend_style,
|
||||
summary_style,
|
||||
crate::types::LogPrefixStyle::from_str(&log_prefix),
|
||||
log_lines,
|
||||
)?;
|
||||
if exit_code != 0 {
|
||||
std::process::exit(exit_code);
|
||||
|
|
@ -325,6 +363,8 @@ fn run_nix_shell_wrapper(
|
|||
format: String,
|
||||
legend_style: String,
|
||||
summary_style: String,
|
||||
log_prefix: String,
|
||||
log_lines: Option<usize>,
|
||||
) -> eyre::Result<()> {
|
||||
// Validate that at least one package/flake is specified
|
||||
if package_and_rom_args.is_empty() {
|
||||
|
|
@ -360,6 +400,8 @@ fn run_nix_shell_wrapper(
|
|||
format,
|
||||
legend_style,
|
||||
summary_style,
|
||||
crate::types::LogPrefixStyle::from_str(&log_prefix),
|
||||
log_lines,
|
||||
)?;
|
||||
|
||||
if exit_code != 0 {
|
||||
|
|
@ -391,6 +433,8 @@ fn run_nix_develop_wrapper(
|
|||
format: String,
|
||||
legend_style: String,
|
||||
summary_style: String,
|
||||
log_prefix: String,
|
||||
log_lines: Option<usize>,
|
||||
) -> eyre::Result<()> {
|
||||
// Validate that at least one package/flake is specified (can be empty for
|
||||
// current flake) develop without args is valid (uses current directory's
|
||||
|
|
@ -419,6 +463,8 @@ fn run_nix_develop_wrapper(
|
|||
format,
|
||||
legend_style,
|
||||
summary_style,
|
||||
crate::types::LogPrefixStyle::from_str(&log_prefix),
|
||||
log_lines,
|
||||
)?;
|
||||
|
||||
if exit_code != 0 {
|
||||
|
|
@ -450,6 +496,8 @@ fn run_monitored_command(
|
|||
format_str: String,
|
||||
legend_style_str: String,
|
||||
summary_style_str: String,
|
||||
log_prefix_style: crate::types::LogPrefixStyle,
|
||||
log_line_limit: Option<usize>,
|
||||
) -> eyre::Result<i32> {
|
||||
use std::{
|
||||
io::{BufRead, BufReader},
|
||||
|
|
@ -481,6 +529,13 @@ fn run_monitored_command(
|
|||
let start_time = Arc::new(Mutex::new(crate::state::current_time()));
|
||||
let start_time_clone = start_time.clone();
|
||||
|
||||
// Buffer for build logs - collected and passed to Display for coordinated
|
||||
// rendering
|
||||
let log_buffer =
|
||||
Arc::new(Mutex::new(std::collections::VecDeque::<String>::new()));
|
||||
let log_buffer_clone = log_buffer.clone();
|
||||
let log_buffer_render = log_buffer.clone();
|
||||
|
||||
// Spawn thread to read and parse stderr (where nix outputs logs)
|
||||
let stderr_thread = thread::spawn(move || {
|
||||
use tracing::debug;
|
||||
|
|
@ -495,19 +550,62 @@ fn run_monitored_command(
|
|||
if let Ok(action) = serde_json::from_str::<cognos::Actions>(json_line) {
|
||||
debug!("Parsed JSON message #{}: {:?}", json_count, action);
|
||||
|
||||
// Print messages immediately to stdout
|
||||
if let cognos::Actions::Message { msg, .. } = &action {
|
||||
println!("{msg}");
|
||||
}
|
||||
|
||||
// Process the action first to update state
|
||||
let mut state = state_clone.lock().unwrap();
|
||||
let derivation_count_before = state.derivation_infos.len();
|
||||
crate::update::process_message(&mut state, action);
|
||||
crate::update::process_message(&mut state, action.clone());
|
||||
crate::update::maintain_state(
|
||||
&mut state,
|
||||
crate::state::current_time(),
|
||||
);
|
||||
let derivation_count_after = state.derivation_infos.len();
|
||||
|
||||
// Now handle build log messages after state is updated
|
||||
// Buffer them for coordinated rendering with the display
|
||||
match &action {
|
||||
cognos::Actions::Message { msg, .. } => {
|
||||
let mut logs = log_buffer_clone.lock().unwrap();
|
||||
logs.push_back(msg.clone());
|
||||
// Keep only recent logs based on limit
|
||||
if let Some(limit) = log_line_limit {
|
||||
while logs.len() > limit {
|
||||
logs.pop_front();
|
||||
}
|
||||
}
|
||||
},
|
||||
cognos::Actions::Result {
|
||||
fields,
|
||||
activity,
|
||||
id,
|
||||
} => {
|
||||
// Build log lines come as Result actions with FileTransfer
|
||||
// activity (101) and fields containing just the log
|
||||
// text: fields = ["log line text"]
|
||||
if matches!(activity, cognos::Activities::FileTransfer)
|
||||
&& !fields.is_empty()
|
||||
{
|
||||
if let Some(log_text) = fields[0].as_str() {
|
||||
// Get the activity prefix (e.g., "hello> ")
|
||||
let use_color = !silent;
|
||||
let prefix = state
|
||||
.get_activity_prefix(*id, &log_prefix_style, use_color)
|
||||
.unwrap_or_default();
|
||||
|
||||
let prefixed_log = format!("{prefix}{log_text}");
|
||||
let mut logs = log_buffer_clone.lock().unwrap();
|
||||
logs.push_back(prefixed_log);
|
||||
// Keep only recent logs based on limit
|
||||
if let Some(limit) = log_line_limit {
|
||||
while logs.len() > limit {
|
||||
logs.pop_front();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
_ => {},
|
||||
}
|
||||
|
||||
if derivation_count_after != derivation_count_before {
|
||||
debug!(
|
||||
"Derivation count changed: {} -> {}",
|
||||
|
|
@ -518,9 +616,16 @@ fn run_monitored_command(
|
|||
debug!("Failed to parse JSON: {}", json_line);
|
||||
}
|
||||
} else {
|
||||
// Non-JSON lines, pass through
|
||||
// Non-JSON lines, buffer them
|
||||
non_json_count += 1;
|
||||
println!("{line}");
|
||||
let mut logs = log_buffer_clone.lock().unwrap();
|
||||
logs.push_back(line.clone());
|
||||
// Keep only recent logs based on limit
|
||||
if let Some(limit) = log_line_limit {
|
||||
while logs.len() > limit {
|
||||
logs.pop_front();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
debug!(
|
||||
|
|
@ -583,13 +688,16 @@ fn run_monitored_command(
|
|||
|| !state.full_summary.planned_builds.is_empty();
|
||||
|
||||
if !silent {
|
||||
// Get buffered logs for coordinated rendering
|
||||
let logs: Vec<String> =
|
||||
log_buffer_render.lock().unwrap().iter().cloned().collect();
|
||||
|
||||
if has_activity || state.progress_state != ProgressState::JustStarted {
|
||||
// Clear any previous timer display
|
||||
if last_timer_display.is_some() {
|
||||
display.clear_previous().ok();
|
||||
last_timer_display = None;
|
||||
}
|
||||
let _ = display.render(&state, &[]);
|
||||
let _ = display.render(&state, &logs);
|
||||
} else {
|
||||
// Show initial timer while waiting for activity
|
||||
let start = *start_time_clone.lock().unwrap();
|
||||
|
|
@ -599,8 +707,7 @@ fn run_monitored_command(
|
|||
|
||||
// Only update if changed (to avoid flicker)
|
||||
if last_timer_display.as_ref() != Some(&timer_text) {
|
||||
display.clear_previous().ok();
|
||||
eprintln!("{timer_text}");
|
||||
let _ = display.render(&state, &logs);
|
||||
last_timer_display = Some(timer_text);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -114,7 +114,7 @@ impl<W: Write> Display<W> {
|
|||
|
||||
let mut lines = Vec::new();
|
||||
|
||||
// Print accumulated logs first (these go above the tree)
|
||||
// Print build logs ABOVE the graph
|
||||
for log in logs {
|
||||
lines.push(log.clone());
|
||||
}
|
||||
|
|
@ -153,6 +153,8 @@ impl<W: Write> Display<W> {
|
|||
}
|
||||
|
||||
pub fn render_final(&mut self, state: &State) -> io::Result<()> {
|
||||
tracing::debug!("render_final called");
|
||||
|
||||
// Clear any previous render
|
||||
self.clear_previous()?;
|
||||
|
||||
|
|
@ -180,6 +182,8 @@ impl<W: Write> Display<W> {
|
|||
},
|
||||
}
|
||||
|
||||
tracing::debug!("render_final: {} lines to print", lines.len());
|
||||
|
||||
// Print final output (don't track last_lines since this is final)
|
||||
for line in lines {
|
||||
writeln!(self.writer, "{line}")?;
|
||||
|
|
@ -207,8 +211,10 @@ impl<W: Write> Display<W> {
|
|||
let failed = state.full_summary.failed_builds.len();
|
||||
let planned = state.full_summary.planned_builds.len();
|
||||
|
||||
let duration = current_time() - state.start_time;
|
||||
|
||||
// Always print summary (like NOM's "Finished at HH:MM:SS after Xs")
|
||||
if running > 0 || completed > 0 || failed > 0 || planned > 0 {
|
||||
let duration = current_time() - state.start_time;
|
||||
lines.push(format!(
|
||||
"{} {} {} │ {} {} │ {} {} │ {} {} │ {} {}",
|
||||
self.colored("━", Color::Blue),
|
||||
|
|
@ -223,6 +229,18 @@ impl<W: Write> Display<W> {
|
|||
self.colored("⏱", Color::Grey),
|
||||
self.format_duration(duration)
|
||||
));
|
||||
} else {
|
||||
// Nothing built - just show "Finished after Xs"
|
||||
let now = chrono::Local::now();
|
||||
let time_str = now.format("%H:%M:%S");
|
||||
lines.push(format!(
|
||||
"{} {}",
|
||||
self.colored(&format!("Finished at {time_str}"), Color::Green),
|
||||
self.colored(
|
||||
&format!("after {}", self.format_duration(duration)),
|
||||
Color::Green
|
||||
)
|
||||
));
|
||||
}
|
||||
|
||||
lines
|
||||
|
|
@ -680,11 +698,23 @@ impl<W: Write> Display<W> {
|
|||
if let Some(info) = state.get_derivation_info(*drv_id) {
|
||||
let name = &info.name.name;
|
||||
let elapsed = current_time() - build.start;
|
||||
|
||||
// Format time info
|
||||
let mut time_info = String::new();
|
||||
if let Some(estimate_secs) = build.estimate {
|
||||
let remaining = estimate_secs.saturating_sub(elapsed as u64);
|
||||
time_info.push_str(&format!(
|
||||
"∅ {} ",
|
||||
self.format_duration(remaining as f64)
|
||||
));
|
||||
}
|
||||
time_info.push_str(&self.format_duration(elapsed));
|
||||
|
||||
lines.push(format!(
|
||||
" {} {} {}",
|
||||
self.colored("⏵", Color::Yellow),
|
||||
name,
|
||||
self.format_duration(elapsed)
|
||||
time_info
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
@ -954,8 +984,19 @@ impl<W: Write> Display<W> {
|
|||
}
|
||||
}
|
||||
|
||||
// Time elapsed
|
||||
// Time information
|
||||
let elapsed = current_time() - build_info.start;
|
||||
|
||||
// Show estimate if available
|
||||
if let Some(estimate_secs) = build_info.estimate {
|
||||
let remaining = estimate_secs.saturating_sub(elapsed as u64);
|
||||
line.push_str(&self.colored(
|
||||
&format!(" ∅ {}", self.format_duration(remaining as f64)),
|
||||
Color::DarkGrey,
|
||||
));
|
||||
}
|
||||
|
||||
// Show elapsed time
|
||||
line.push_str(&self.colored(
|
||||
&format!(" ⏱ {}", self.format_duration(elapsed)),
|
||||
Color::DarkGrey,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
//! ROM - Rust Output Monitor
|
||||
pub mod cache;
|
||||
pub mod cli;
|
||||
pub mod display;
|
||||
pub mod error;
|
||||
|
|
|
|||
|
|
@ -1,12 +1,15 @@
|
|||
//! Monitor module for orchestrating state updates and display rendering
|
||||
|
||||
use std::{
|
||||
io::{BufRead, Write},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use cognos::Host;
|
||||
use tracing::debug;
|
||||
|
||||
use crate::{
|
||||
cache::BuildReportCache,
|
||||
display::{Display, DisplayConfig, LegendStyle, SummaryStyle},
|
||||
error::{Result, RomError},
|
||||
state::{
|
||||
|
|
@ -54,7 +57,12 @@ impl<W: Write> Monitor<W> {
|
|||
};
|
||||
|
||||
let display = Display::new(writer, display_config)?;
|
||||
let state = State::new();
|
||||
let mut state = State::new();
|
||||
|
||||
// Load build cache for predictions
|
||||
let cache_path = BuildReportCache::default_cache_path();
|
||||
let cache = BuildReportCache::new(cache_path);
|
||||
state.build_cache = cache.load();
|
||||
|
||||
Ok(Self {
|
||||
state,
|
||||
|
|
@ -90,6 +98,14 @@ impl<W: Write> Monitor<W> {
|
|||
self.display.render_final(&self.state)?;
|
||||
}
|
||||
|
||||
// Save build cache for future predictions
|
||||
let cache_path = BuildReportCache::default_cache_path();
|
||||
let cache = BuildReportCache::new(cache_path);
|
||||
if let Err(e) = cache.save(&self.state.build_cache) {
|
||||
debug!("Failed to save build cache: {}", e);
|
||||
// Don't fail the build if cache save fails
|
||||
}
|
||||
|
||||
// Return error code if there were failures
|
||||
if self.state.has_errors() {
|
||||
return Err(RomError::BuildFailed);
|
||||
|
|
@ -140,10 +156,6 @@ impl<W: Write> Monitor<W> {
|
|||
|
||||
/// Process a human-readable line
|
||||
fn process_human_line(&mut self, line: &str) -> Result<bool> {
|
||||
// Parse human-readable nix output
|
||||
// This is a simplified version - the full implementation would need
|
||||
// comprehensive parsing of nix's output format
|
||||
|
||||
let line = line.trim();
|
||||
|
||||
// Skip empty lines
|
||||
|
|
@ -270,10 +282,8 @@ impl<W: Write> Monitor<W> {
|
|||
// Extract number of paths if present
|
||||
let words: Vec<&str> = line.split_whitespace().collect();
|
||||
if words.len() >= 2 {
|
||||
if let Ok(_count) = words[1].parse::<usize>() {
|
||||
// XXX: This is a PlanCopies message, we'll probably track this
|
||||
// For now just acknowledge it, and let future work decide how
|
||||
// we should go around doing it.
|
||||
if let Ok(count) = words[1].parse::<usize>() {
|
||||
debug!("Copying {} paths", count);
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
106
rom/src/state.rs
106
rom/src/state.rs
|
|
@ -360,6 +360,7 @@ pub struct State {
|
|||
pub full_summary: DependencySummary,
|
||||
pub forest_roots: Vec<DerivationId>,
|
||||
pub build_reports: HashMap<String, Vec<BuildReport>>,
|
||||
pub build_cache: HashMap<(String, String), Vec<BuildReport>>,
|
||||
pub start_time: f64,
|
||||
pub progress_state: ProgressState,
|
||||
pub store_path_ids: HashMap<StorePath, StorePathId>,
|
||||
|
|
@ -371,6 +372,8 @@ pub struct State {
|
|||
pub traces: Vec<String>,
|
||||
pub build_platform: Option<String>,
|
||||
pub evaluation_state: EvalInfo,
|
||||
pub builds_activity: Option<ActivityId>,
|
||||
pub success_tokens: u64,
|
||||
next_store_path_id: StorePathId,
|
||||
next_derivation_id: DerivationId,
|
||||
}
|
||||
|
|
@ -390,6 +393,7 @@ impl State {
|
|||
full_summary: DependencySummary::default(),
|
||||
forest_roots: Vec::new(),
|
||||
build_reports: HashMap::new(),
|
||||
build_cache: HashMap::new(),
|
||||
start_time: current_time(),
|
||||
progress_state: ProgressState::JustStarted,
|
||||
store_path_ids: HashMap::new(),
|
||||
|
|
@ -401,6 +405,8 @@ impl State {
|
|||
traces: Vec::new(),
|
||||
build_platform: None,
|
||||
evaluation_state: EvalInfo::default(),
|
||||
builds_activity: None,
|
||||
success_tokens: 0,
|
||||
next_store_path_id: 0,
|
||||
next_derivation_id: 0,
|
||||
}
|
||||
|
|
@ -697,6 +703,106 @@ impl State {
|
|||
.copied()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get the activity prefix for a given activity ID by walking up the parent
|
||||
/// chain to find a Build activity and extracting its derivation name.
|
||||
/// Returns a prefix like "hello> " suitable for prepending to log lines.
|
||||
/// If `use_color` is true and stderr is a TTY, the prefix will be blue.
|
||||
/// The `prefix_style` determines whether to use short (pname only), full, or
|
||||
/// no prefix.
|
||||
#[must_use]
|
||||
pub fn get_activity_prefix(
|
||||
&self,
|
||||
activity_id: ActivityId,
|
||||
prefix_style: &crate::types::LogPrefixStyle,
|
||||
use_color: bool,
|
||||
) -> Option<String> {
|
||||
use cognos::Activities;
|
||||
|
||||
use crate::types::LogPrefixStyle;
|
||||
|
||||
// If prefix style is None, return empty string
|
||||
if matches!(prefix_style, LogPrefixStyle::None) {
|
||||
return Some(String::new());
|
||||
}
|
||||
|
||||
let mut current_id = activity_id;
|
||||
let max_depth = 10; // Prevent infinite loops
|
||||
let mut depth = 0;
|
||||
|
||||
while depth < max_depth {
|
||||
if let Some(activity) = self.activities.get(¤t_id) {
|
||||
// Check if this is a Build activity (type 105)
|
||||
if activity.activity == Activities::Build as u8 {
|
||||
// Extract derivation path from the text field
|
||||
// The text field typically contains something like:
|
||||
// "building '/nix/store/...-hello-2.10.drv'"
|
||||
if let Some(drv) = extract_derivation_from_text(&activity.text) {
|
||||
// Look up the DerivationInfo for this derivation
|
||||
let drv_id = self.derivation_ids.get(&drv);
|
||||
let name = if matches!(prefix_style, LogPrefixStyle::Short) {
|
||||
// Try to use pname if available
|
||||
if let Some(id) = drv_id {
|
||||
if let Some(drv_info) = self.derivation_infos.get(id) {
|
||||
if let Some(pname) = &drv_info.pname {
|
||||
pname.clone()
|
||||
} else {
|
||||
drv.name.clone()
|
||||
}
|
||||
} else {
|
||||
drv.name.clone()
|
||||
}
|
||||
} else {
|
||||
drv.name.clone()
|
||||
}
|
||||
} else {
|
||||
// Full style - use full derivation name
|
||||
drv.name.clone()
|
||||
};
|
||||
|
||||
// Apply color if requested and stderr is a TTY
|
||||
let colored_name = if use_color
|
||||
&& std::io::IsTerminal::is_terminal(&std::io::stderr())
|
||||
{
|
||||
format!("\x1b[34m{name}\x1b[0m")
|
||||
} else {
|
||||
name
|
||||
};
|
||||
|
||||
return Some(format!("{colored_name}> "));
|
||||
}
|
||||
}
|
||||
|
||||
// Move to parent activity
|
||||
if let Some(parent_id) = activity.parent {
|
||||
if parent_id == 0 {
|
||||
break; // Reached root
|
||||
}
|
||||
current_id = parent_id;
|
||||
depth += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract derivation from activity text like "building
|
||||
/// '/nix/store/...-hello-2.10.drv'" Returns the Derivation object
|
||||
fn extract_derivation_from_text(text: &str) -> Option<Derivation> {
|
||||
// Look for .drv path in text
|
||||
if let Some(start) = text.find("/nix/store/") {
|
||||
if let Some(end) = text[start..].find(".drv") {
|
||||
let drv_path = &text[start..start + end + 4]; // Include .drv
|
||||
return Derivation::parse(drv_path);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
|
|
|
|||
|
|
@ -11,6 +11,17 @@ pub enum DisplayFormat {
|
|||
Dashboard,
|
||||
}
|
||||
|
||||
/// Log prefix style for build logs
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum LogPrefixStyle {
|
||||
/// Just package name (pname)
|
||||
Short,
|
||||
/// Full derivation name with version
|
||||
Full,
|
||||
/// No prefix
|
||||
None,
|
||||
}
|
||||
|
||||
/// Summary display style
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum SummaryStyle {
|
||||
|
|
@ -34,6 +45,18 @@ impl SummaryStyle {
|
|||
}
|
||||
}
|
||||
|
||||
impl LogPrefixStyle {
|
||||
#[must_use]
|
||||
pub fn from_str(s: &str) -> Self {
|
||||
match s.to_lowercase().as_str() {
|
||||
"short" => Self::Short,
|
||||
"full" => Self::Full,
|
||||
"none" => Self::None,
|
||||
_ => Self::Short,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DisplayFormat {
|
||||
#[must_use]
|
||||
pub fn from_str(s: &str) -> Self {
|
||||
|
|
@ -50,34 +73,40 @@ impl DisplayFormat {
|
|||
#[derive(Debug, Clone)]
|
||||
pub struct Config {
|
||||
/// Whether we're piping output through
|
||||
pub piping: bool,
|
||||
pub piping: bool,
|
||||
/// Silent mode - minimal output
|
||||
pub silent: bool,
|
||||
pub silent: bool,
|
||||
/// Input parsing mode
|
||||
pub input_mode: InputMode,
|
||||
pub input_mode: InputMode,
|
||||
/// Show completion times
|
||||
pub show_timers: bool,
|
||||
pub show_timers: bool,
|
||||
/// Terminal width override
|
||||
pub width: Option<usize>,
|
||||
pub width: Option<usize>,
|
||||
/// Display format
|
||||
pub format: DisplayFormat,
|
||||
pub format: DisplayFormat,
|
||||
/// Legend display style
|
||||
pub legend_style: String,
|
||||
pub legend_style: String,
|
||||
/// Summary display style
|
||||
pub summary_style: String,
|
||||
pub summary_style: String,
|
||||
/// Log prefix style for build logs
|
||||
pub log_prefix_style: LogPrefixStyle,
|
||||
/// Maximum number of log lines to display (None = unlimited)
|
||||
pub log_line_limit: Option<usize>,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
piping: false,
|
||||
silent: false,
|
||||
input_mode: InputMode::Human,
|
||||
show_timers: true,
|
||||
width: None,
|
||||
format: DisplayFormat::Tree,
|
||||
legend_style: "table".to_string(),
|
||||
summary_style: "concise".to_string(),
|
||||
piping: false,
|
||||
silent: false,
|
||||
input_mode: InputMode::Human,
|
||||
show_timers: true,
|
||||
width: None,
|
||||
format: DisplayFormat::Tree,
|
||||
legend_style: "table".to_string(),
|
||||
summary_style: "concise".to_string(),
|
||||
log_prefix_style: LogPrefixStyle::Short,
|
||||
log_line_limit: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -103,6 +132,8 @@ mod tests {
|
|||
assert_eq!(config.input_mode, InputMode::Human);
|
||||
assert!(config.show_timers);
|
||||
assert_eq!(config.format, DisplayFormat::Tree);
|
||||
assert_eq!(config.log_prefix_style, LogPrefixStyle::Short);
|
||||
assert_eq!(config.log_line_limit, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -3,25 +3,29 @@
|
|||
use cognos::{Actions, Activities, Host, Id, ProgressState, Verbosity};
|
||||
use tracing::{debug, trace};
|
||||
|
||||
use crate::state::{
|
||||
ActivityProgress,
|
||||
ActivityStatus,
|
||||
BuildFail,
|
||||
BuildInfo,
|
||||
BuildStatus,
|
||||
CompletedBuildInfo,
|
||||
CompletedTransferInfo,
|
||||
Derivation,
|
||||
DerivationId,
|
||||
FailType,
|
||||
FailedBuildInfo,
|
||||
InputDerivation,
|
||||
State,
|
||||
StorePath,
|
||||
StorePathId,
|
||||
StorePathState,
|
||||
TransferInfo,
|
||||
current_time,
|
||||
use crate::{
|
||||
cache::BuildReportCache,
|
||||
state::{
|
||||
ActivityProgress,
|
||||
ActivityStatus,
|
||||
BuildFail,
|
||||
BuildInfo,
|
||||
BuildReport,
|
||||
BuildStatus,
|
||||
CompletedBuildInfo,
|
||||
CompletedTransferInfo,
|
||||
Derivation,
|
||||
DerivationId,
|
||||
FailType,
|
||||
FailedBuildInfo,
|
||||
InputDerivation,
|
||||
State,
|
||||
StorePath,
|
||||
StorePathId,
|
||||
StorePathState,
|
||||
TransferInfo,
|
||||
current_time,
|
||||
},
|
||||
};
|
||||
|
||||
/// Process a nix JSON message and update state
|
||||
|
|
@ -96,10 +100,19 @@ fn handle_start(
|
|||
109 => handle_query_path_info_start(state, id, &text, &fields, now), /* QueryPathInfo */
|
||||
110 => handle_post_build_hook_start(state, id, &text, &fields, now), /* PostBuildHook */
|
||||
101 => handle_file_transfer_start(state, id, &text, &fields, now), /* FileTransfer */
|
||||
100 => handle_copy_path_start(state, id, &text, &fields, now), // CopyPath
|
||||
102 | 103 | 104 | 106 | 107 | 111 | 112 => {
|
||||
// Realise, CopyPaths, Builds, OptimiseStore, VerifyPaths, BuildWaiting,
|
||||
// FetchTree These activities have no fields and are just tracked
|
||||
100 => handle_copy_path_start(state, id, &text, &fields, now), /* CopyPath */
|
||||
104 => {
|
||||
// Builds activity - track this as the top-level builds activity
|
||||
if state.builds_activity.is_none() {
|
||||
state.builds_activity = Some(id);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
},
|
||||
102 | 103 | 106 | 107 | 111 | 112 => {
|
||||
// Realise, CopyPaths, OptimiseStore, VerifyPaths, BuildWaiting, FetchTree
|
||||
// These activities have no fields and are just tracked
|
||||
true
|
||||
},
|
||||
_ => {
|
||||
|
|
@ -281,11 +294,12 @@ fn handle_result(
|
|||
|
||||
match result_type {
|
||||
100 => {
|
||||
// FileLinked: 2 int fields
|
||||
// FileLinked: 2 int fields (linked count, total count)
|
||||
if fields.len() >= 2 {
|
||||
let _linked = fields[0].as_u64();
|
||||
let _total = fields[1].as_u64();
|
||||
// TODO: Track file linking progress
|
||||
let linked = fields[0].as_u64().unwrap_or(0);
|
||||
let total = fields[1].as_u64().unwrap_or(0);
|
||||
debug!("FileLinked: {}/{}", linked, total);
|
||||
// File linking is reported but doesn't need state tracking
|
||||
}
|
||||
false
|
||||
},
|
||||
|
|
@ -300,17 +314,18 @@ fn handle_result(
|
|||
102 => {
|
||||
// UntrustedPath: 1 text field (store path)
|
||||
if let Some(path_str) = fields.first().and_then(|f| f.as_str()) {
|
||||
debug!("Untrusted path: {}", path_str);
|
||||
// TODO: Track untrusted paths
|
||||
debug!("Untrusted path reported: {}", path_str);
|
||||
state
|
||||
.nix_errors
|
||||
.push(format!("Untrusted path: {}", path_str));
|
||||
return true;
|
||||
}
|
||||
false
|
||||
},
|
||||
103 => {
|
||||
// CorruptedPath: 1 text field (store path)
|
||||
if let Some(path_str) = fields.first().and_then(|f| f.as_str()) {
|
||||
state
|
||||
.nix_errors
|
||||
.push(format!("Corrupted path: {path_str}"));
|
||||
state.nix_errors.push(format!("Corrupted path: {path_str}"));
|
||||
return true;
|
||||
}
|
||||
false
|
||||
|
|
@ -334,6 +349,19 @@ fn handle_result(
|
|||
fields[2].as_u64(),
|
||||
fields[3].as_u64(),
|
||||
) {
|
||||
// If this progress is for the Builds activity, track success tokens
|
||||
if state.builds_activity == Some(id) {
|
||||
if let Some(activity) = state.activities.get(&id) {
|
||||
if let Some(prev_progress) = &activity.progress {
|
||||
let new_done = done.saturating_sub(prev_progress.done);
|
||||
if new_done > 0 {
|
||||
state.success_tokens =
|
||||
state.success_tokens.saturating_add(new_done);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(activity) = state.activities.get_mut(&id) {
|
||||
activity.progress = Some(ActivityProgress {
|
||||
done,
|
||||
|
|
@ -350,9 +378,13 @@ fn handle_result(
|
|||
106 => {
|
||||
// SetExpected: 2 int fields (activity type, count)
|
||||
if fields.len() >= 2 {
|
||||
let _activity_type = fields[0].as_u64();
|
||||
let _expected_count = fields[1].as_u64();
|
||||
// TODO: Track expected counts
|
||||
let activity_type = fields[0].as_u64().unwrap_or(0);
|
||||
let expected_count = fields[1].as_u64().unwrap_or(0);
|
||||
debug!(
|
||||
"SetExpected: activity_type={}, count={}",
|
||||
activity_type, expected_count
|
||||
);
|
||||
// Expected counts are informational and don't affect state tracking
|
||||
}
|
||||
false
|
||||
},
|
||||
|
|
@ -368,7 +400,7 @@ fn handle_result(
|
|||
// FetchStatus: 1 text field
|
||||
if let Some(status) = fields.first().and_then(|f| f.as_str()) {
|
||||
debug!("Fetch status: {}", status);
|
||||
// TODO: Track fetch status
|
||||
// Fetch status is informational
|
||||
}
|
||||
false
|
||||
},
|
||||
|
|
@ -379,6 +411,50 @@ fn handle_result(
|
|||
}
|
||||
}
|
||||
|
||||
/// Get build time estimate from cache
|
||||
fn get_build_estimate(
|
||||
state: &State,
|
||||
derivation_name: &str,
|
||||
host: &Host,
|
||||
) -> Option<u64> {
|
||||
// Use pname if available, otherwise derivation name
|
||||
let lookup_name = derivation_name.to_string();
|
||||
let host_str = host.name();
|
||||
|
||||
BuildReportCache::calculate_median(
|
||||
state
|
||||
.build_cache
|
||||
.get(&(host_str.to_string(), lookup_name))?
|
||||
.as_slice(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Record completed build for future predictions
|
||||
fn record_build_completion(
|
||||
state: &mut State,
|
||||
derivation_name: String,
|
||||
platform: Option<String>,
|
||||
start: f64,
|
||||
end: f64,
|
||||
host: &Host,
|
||||
) {
|
||||
let duration_secs = end - start;
|
||||
let completed_at = std::time::SystemTime::now();
|
||||
|
||||
let report = BuildReport {
|
||||
derivation_name: derivation_name.clone(),
|
||||
platform: platform.unwrap_or_default(),
|
||||
duration_secs,
|
||||
completed_at,
|
||||
host: host.name().to_string(),
|
||||
success: true,
|
||||
};
|
||||
|
||||
// Store in state for later CSV persistence
|
||||
let key = (host.name().to_string(), derivation_name);
|
||||
state.build_cache.entry(key).or_default().push(report);
|
||||
}
|
||||
|
||||
fn handle_build_start(
|
||||
state: &mut State,
|
||||
id: Id,
|
||||
|
|
@ -402,13 +478,16 @@ fn handle_build_start(
|
|||
if let Some(drv_path) = drv_path {
|
||||
debug!("Extracted derivation path: {}", drv_path);
|
||||
if let Some(drv) = Derivation::parse(&drv_path) {
|
||||
let drv_id = state.get_or_create_derivation_id(drv);
|
||||
let drv_id = state.get_or_create_derivation_id(drv.clone());
|
||||
let host = extract_host(text);
|
||||
|
||||
// Get build time estimate from cache
|
||||
let estimate = get_build_estimate(state, &drv.name, &host);
|
||||
|
||||
let build_info = BuildInfo {
|
||||
start: now,
|
||||
host,
|
||||
estimate: None,
|
||||
estimate,
|
||||
activity_id: Some(id),
|
||||
};
|
||||
|
||||
|
|
@ -444,21 +523,42 @@ fn handle_build_start(
|
|||
false
|
||||
}
|
||||
|
||||
fn handle_build_stop(state: &mut State, id: Id, _now: f64) -> bool {
|
||||
// Find the derivation associated with this activity
|
||||
for (drv_id, info) in &state.derivation_infos {
|
||||
match &info.build_status {
|
||||
BuildStatus::Building(build_info)
|
||||
if build_info.activity_id == Some(id) =>
|
||||
{
|
||||
// Build was stopped but not marked as completed
|
||||
// It might be cancelled
|
||||
debug!("Build stopped for derivation {}", drv_id);
|
||||
return false;
|
||||
},
|
||||
_ => {},
|
||||
fn handle_build_stop(state: &mut State, id: Id, now: f64) -> bool {
|
||||
// Check if we have success tokens to consume
|
||||
if state.success_tokens > 0 {
|
||||
// Find the derivation associated with this activity
|
||||
for (drv_id, info) in state.derivation_infos.clone().iter() {
|
||||
if let BuildStatus::Building(build_info) = &info.build_status {
|
||||
if build_info.activity_id == Some(id) {
|
||||
// Consume a success token and mark build as complete
|
||||
state.success_tokens = state.success_tokens.saturating_sub(1);
|
||||
state.update_build_status(*drv_id, BuildStatus::Built {
|
||||
info: build_info.clone(),
|
||||
end: now,
|
||||
});
|
||||
|
||||
// Record build completion for future predictions
|
||||
record_build_completion(
|
||||
state,
|
||||
info.name.name.clone(),
|
||||
info.platform.clone(),
|
||||
build_info.start,
|
||||
now,
|
||||
&build_info.host,
|
||||
);
|
||||
|
||||
debug!(
|
||||
"Build completed for derivation {} (success_tokens: {})",
|
||||
drv_id, state.success_tokens
|
||||
);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No success tokens - build was stopped without completion signal
|
||||
debug!("Build stopped for activity {} without success token", id);
|
||||
false
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue