use crate::gate::GateResult;
use crate::paths::Paths;
use crate::trajectory::Trajectory;
use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
pub const RUN_SCHEMA: &str = "keel.run/1";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RunMeta {
pub schema: String,
pub id: String,
pub spec: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub task: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub driver: Option<String>,
pub keel_version: String,
pub store_hash: String,
pub started_at: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub finished_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub verdict: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base_commit: Option<String>,
}
pub struct Run {
pub dir: PathBuf,
pub meta: RunMeta,
}
impl Run {
pub fn create(
paths: &Paths,
spec: &str,
task: Option<String>,
driver: Option<String>,
store_hash: &str,
base: Option<String>,
) -> Result<Self> {
let id = crate::gate::run_id();
let dir = paths.runs().join(&id);
if dir.exists() {
bail!("run {id} already exists");
}
std::fs::create_dir_all(dir.join("evidence"))?;
std::fs::create_dir_all(dir.join("gates"))?;
let meta = RunMeta {
schema: RUN_SCHEMA.to_string(),
id,
spec: spec.to_string(),
task,
driver,
keel_version: env!("CARGO_PKG_VERSION").to_string(),
store_hash: store_hash.to_string(),
started_at: chrono::Local::now().to_rfc3339(),
finished_at: None,
verdict: None,
base_commit: base.or_else(|| head_commit(paths)),
};
let run = Self { dir, meta };
run.save()?;
Ok(run)
}
pub fn load(paths: &Paths, id: &str) -> Result<Self> {
let dir = paths.runs().join(id);
let meta_path = dir.join("run.json");
if !meta_path.exists() {
bail!("no run `{id}` at {}", paths.rel(&dir).display());
}
let raw = std::fs::read_to_string(&meta_path)?;
let meta: RunMeta = serde_json::from_str(&raw)
.with_context(|| format!("parsing {}", meta_path.display()))?;
Ok(Self { dir, meta })
}
pub fn save(&self) -> Result<()> {
let json = serde_json::to_string_pretty(&self.meta)?;
std::fs::write(self.dir.join("run.json"), format!("{json}\n"))?;
Ok(())
}
pub fn trajectory_path(&self) -> PathBuf {
self.dir.join("trajectory.jsonl")
}
pub fn open_trajectory(&self) -> Result<Trajectory> {
Trajectory::open(&self.trajectory_path())
}
pub fn gates_dir(&self) -> PathBuf {
self.dir.join("gates")
}
pub fn evidence_dir(&self) -> PathBuf {
self.dir.join("evidence")
}
pub fn write_evidence(&self, name: &str, content: &str) -> Result<String> {
let path = self.evidence_dir().join(name);
if let Some(p) = path.parent() {
std::fs::create_dir_all(p)?;
}
std::fs::write(&path, content)
.with_context(|| format!("writing {}", path.display()))?;
Ok(format!("evidence/{name}"))
}
pub fn finish(&mut self, verdict: &str) -> Result<()> {
self.meta.finished_at = Some(chrono::Local::now().to_rfc3339());
self.meta.verdict = Some(verdict.to_string());
self.save()
}
pub fn gate_results(&self) -> Result<Vec<GateResult>> {
let dir = self.gates_dir();
if !dir.is_dir() {
return Ok(vec![]);
}
let mut files: Vec<PathBuf> = std::fs::read_dir(&dir)?
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().and_then(|e| e.to_str()) == Some("json"))
.collect();
files.sort();
files.iter().map(|p| GateResult::read(p)).collect()
}
}
pub fn list(paths: &Paths) -> Result<Vec<String>> {
let dir = paths.runs();
if !dir.is_dir() {
return Ok(vec![]);
}
let mut out: Vec<String> = std::fs::read_dir(&dir)?
.filter_map(|e| e.ok())
.filter(|e| e.path().join("run.json").is_file())
.filter_map(|e| e.file_name().to_str().map(|s| s.to_string()))
.collect();
out.sort();
Ok(out)
}
pub fn latest(paths: &Paths) -> Result<Option<String>> {
Ok(list(paths)?.pop())
}
fn head_commit(paths: &Paths) -> Option<String> {
let out = std::process::Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(&paths.repo)
.output()
.ok()?;
if !out.status.success() {
return None;
}
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
(!s.is_empty()).then_some(s)
}
pub fn protected(paths: &Paths) -> Result<std::collections::BTreeSet<String>> {
let mut keep = std::collections::BTreeSet::new();
for l in crate::lesson::list(paths)? {
for src in &l.front.sources {
if let Some(id) = src.strip_prefix("runs/") {
keep.insert(id.to_string());
}
}
}
let demoted = paths.lessons().join("demoted");
if demoted.is_dir() {
for entry in std::fs::read_dir(&demoted)? {
let p = entry?.path();
if p.extension().and_then(|e| e.to_str()) != Some("md") {
continue;
}
if let Ok(l) = crate::lesson::Lesson::read(&p) {
for src in &l.front.sources {
if let Some(id) = src.strip_prefix("runs/") {
keep.insert(id.to_string());
}
}
}
}
}
Ok(keep)
}
#[derive(Debug, Clone)]
pub struct PruneCandidate {
pub id: String,
pub verdict: String,
pub bytes: u64,
pub protected_by: Option<String>,
}
pub fn prune_plan(paths: &Paths, keep: usize) -> Result<Vec<PruneCandidate>> {
let all = list(paths)?;
let protected_ids = protected(paths)?;
let cutoff = all.len().saturating_sub(keep);
let mut out = Vec::new();
for (i, id) in all.iter().enumerate() {
if i >= cutoff {
continue; }
let dir = paths.runs().join(id);
let verdict = Run::load(paths, id)
.ok()
.and_then(|r| r.meta.verdict)
.unwrap_or_else(|| "unknown".into());
out.push(PruneCandidate {
protected_by: protected_ids.contains(id).then(|| "cited by a lesson".to_string()),
id: id.clone(),
verdict,
bytes: dir_size(&dir),
});
}
Ok(out)
}
fn dir_size(dir: &std::path::Path) -> u64 {
let Ok(entries) = std::fs::read_dir(dir) else { return 0 };
entries
.filter_map(|e| e.ok())
.map(|e| {
let p = e.path();
if p.is_dir() { dir_size(&p) } else { p.metadata().map(|m| m.len()).unwrap_or(0) }
})
.sum()
}
pub fn resolve(paths: &Paths, id: Option<String>) -> Result<String> {
match id {
Some(i) => Ok(i),
None => latest(paths)?
.ok_or_else(|| anyhow::anyhow!("no runs yet — `keel run <spec>` first")),
}
}
pub fn required_members() -> &'static [&'static str] {
&["run.json", "trajectory.jsonl"]
}