1use std::collections::BTreeMap;
18use std::path::{Path, PathBuf};
19use std::time::{SystemTime, UNIX_EPOCH};
20
21use anyhow::{Context, Result};
22use serde::{Deserialize, Serialize};
23
24use crate::artifacts::ArtifactStore;
25use crate::benchmark::BenchmarkDescriptor;
26use crate::params::{ParamSpec, ParamValues};
27use crate::plugin::TargetEndpoint;
28use crate::result::{BenchmarkResult, VerdictKind};
29
30pub const SCHEMA: u32 = 1;
33
34#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(rename_all = "lowercase")]
37pub enum RunSource {
38 Tui,
39 Cli,
40 #[default]
43 Unknown,
44}
45
46#[derive(Clone, Debug, Serialize, Deserialize)]
50pub struct RunRecord {
51 #[serde(default)]
52 pub schema: u32,
53 #[serde(default)]
56 pub run_id: String,
57 pub benchmark_id: String,
58 #[serde(default)]
59 pub benchmark_name: String,
60 pub recorded_at: u64,
63 #[serde(default)]
67 pub target_url: String,
68 #[serde(default)]
69 pub target_model: String,
70 #[serde(default)]
72 pub params: BTreeMap<String, String>,
73 #[serde(default)]
79 pub serve_overrides: BTreeMap<String, String>,
80 #[serde(default)]
81 pub source: RunSource,
82 #[serde(default)]
83 pub metrale_version: String,
84 pub frame: BenchmarkResult,
86}
87
88impl RunRecord {
89 pub fn new(
92 descriptor: &BenchmarkDescriptor,
93 values: &ParamValues,
94 target: &TargetEndpoint,
95 serve_overrides: BTreeMap<String, String>,
96 source: RunSource,
97 metrale_version: &str,
98 frame: BenchmarkResult,
99 ) -> Self {
100 Self {
101 schema: SCHEMA,
102 run_id: String::new(),
103 benchmark_id: descriptor.id.to_string(),
104 benchmark_name: descriptor.name.to_string(),
105 recorded_at: now_secs(),
106 target_url: target.base_url.clone(),
107 target_model: target.model.clone(),
108 params: values.to_strings(),
109 serve_overrides,
110 source,
111 metrale_version: metrale_version.to_string(),
112 frame,
113 }
114 }
115
116 pub fn target(&self) -> TargetEndpoint {
118 TargetEndpoint::new(&self.target_url, &self.target_model)
119 }
120
121 pub fn values(&self, specs: &[ParamSpec]) -> Result<ParamValues> {
125 let pairs = self
126 .params
127 .iter()
128 .map(|(k, v)| (k.as_str(), v.as_str()))
129 .collect::<Vec<_>>();
130 ParamValues::from_overrides(specs, pairs)
131 }
132
133 pub fn verdict_kind(&self) -> Option<VerdictKind> {
134 self.frame.verdict.as_ref().map(|v| v.kind)
135 }
136
137 pub fn is_legacy(&self) -> bool {
139 self.schema == 0
140 }
141
142 pub fn age_text(&self) -> String {
144 let secs = now_secs().saturating_sub(self.recorded_at);
145 match secs {
146 0..=59 => format!("{secs}s ago"),
147 60..=3599 => format!("{}m ago", secs / 60),
148 3600..=86_399 => format!("{}h ago", secs / 3600),
149 _ => format!("{}d ago", secs / 86_400),
150 }
151 }
152
153 fn from_legacy(benchmark_id: &str, run_id: &str, frame: BenchmarkResult) -> Self {
154 Self {
155 schema: 0,
156 recorded_at: run_id
157 .trim_start_matches("run-")
158 .parse()
159 .unwrap_or_default(),
160 run_id: run_id.to_string(),
161 benchmark_id: benchmark_id.to_string(),
162 benchmark_name: String::new(),
163 target_url: String::new(),
164 target_model: String::new(),
165 params: BTreeMap::new(),
166 serve_overrides: BTreeMap::new(),
167 source: RunSource::Unknown,
168 metrale_version: String::new(),
169 frame,
170 }
171 }
172}
173
174fn now_secs() -> u64 {
175 SystemTime::now()
176 .duration_since(UNIX_EPOCH)
177 .map(|d| d.as_secs())
178 .unwrap_or_default()
179}
180
181pub fn save(store: &ArtifactStore, record: &mut RunRecord) -> Result<PathBuf> {
188 let nanos = SystemTime::now()
189 .duration_since(UNIX_EPOCH)
190 .map(|d| d.as_nanos() as u64)
191 .unwrap_or_default();
192 save_at(store, record, nanos, |_| {})
193}
194
195fn save_at<F>(
198 store: &ArtifactStore,
199 record: &mut RunRecord,
200 mut nanos: u64,
201 after_claim: F,
202) -> Result<PathBuf>
203where
204 F: FnOnce(&Path),
205{
206 let dir = store.runs_dir(&record.benchmark_id)?;
207 let (run_id, path) = loop {
210 let id = format!("run-{nanos:019}");
211 let path = dir.join(format!("{id}.json"));
212 match std::fs::OpenOptions::new()
213 .write(true)
214 .create_new(true)
215 .open(&path)
216 {
217 Ok(_) => break (id, path),
218 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
219 nanos = nanos.saturating_add(1);
220 }
221 Err(e) => {
222 return Err(e).with_context(|| format!("claiming {}", path.display()));
223 }
224 }
225 };
226 record.run_id = run_id;
227 after_claim(&path);
228 let json = serde_json::to_string_pretty(&record).context("serializing the run record")?;
229 let tmp = path.with_extension("json.tmp");
232 std::fs::write(&tmp, json).with_context(|| format!("writing {}", tmp.display()))?;
233 std::fs::rename(&tmp, &path).with_context(|| format!("publishing {}", path.display()))?;
234 Ok(path)
235}
236
237pub fn load(store: &ArtifactStore, benchmark_id: &str) -> Vec<RunRecord> {
241 let Ok(dir) = store.runs_dir(benchmark_id) else {
242 return Vec::new();
243 };
244 let Ok(entries) = std::fs::read_dir(&dir) else {
245 return Vec::new();
246 };
247 let mut out: Vec<RunRecord> = entries
248 .flatten()
249 .filter_map(|e| read_one(benchmark_id, &e.path()))
250 .collect();
251 sort_newest_first(&mut out);
252 out
253}
254
255pub fn load_all(store: &ArtifactStore) -> Vec<RunRecord> {
258 let mut out: Vec<RunRecord> = crate::registry::all()
259 .iter()
260 .flat_map(|d| load(store, d.id))
261 .collect();
262 sort_newest_first(&mut out);
263 out
264}
265
266pub fn find(store: &ArtifactStore, run_id: &str) -> Option<RunRecord> {
268 load_all(store).into_iter().find(|r| r.run_id == run_id)
269}
270
271fn sort_newest_first(records: &mut [RunRecord]) {
272 records.sort_by(|a, b| {
273 b.recorded_at
274 .cmp(&a.recorded_at)
275 .then_with(|| b.run_id.cmp(&a.run_id))
276 });
277}
278
279fn read_one(benchmark_id: &str, path: &Path) -> Option<RunRecord> {
282 let stem = path.file_stem()?.to_str()?;
283 if !stem.starts_with("run-") || path.extension()? != "json" {
284 return None; }
286 let text = std::fs::read_to_string(path).ok()?;
287 if let Ok(mut record) = serde_json::from_str::<RunRecord>(&text) {
288 if record.run_id.is_empty() {
289 record.run_id = stem.to_string();
290 }
291 return Some(record);
292 }
293 let frame = serde_json::from_str::<BenchmarkResult>(&text).ok()?;
294 Some(RunRecord::from_legacy(benchmark_id, stem, frame))
295}
296
297#[cfg(test)]
298#[path = "history_tests.rs"]
299mod tests;