use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use argonauts_core::events::run::RunOutcome;
use argonauts_core::layout::project_files;
use argonauts_core::names::ModelRef;
use argonauts_core::workflow::{StandardPhase, StepKind};
use argonauts_core::{RunId, TaskId};
use crate::error::Result;
use crate::routing::Draw;
use crate::store::Store;
use crate::types::{Assignment, LearnedWorkflowRef, RunRecord};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum LogLine {
Prediction(Prediction),
Draw(Draw),
Outcome(Outcome),
Run(RunRecord),
Reversal(Reversal),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Prediction {
pub run: RunId,
pub task: TaskId,
pub worker: ModelRef,
pub role: Predictor,
pub task_type: String,
#[serde(with = "time::serde::rfc3339")]
pub at: OffsetDateTime,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub p_pass_checks_first_try: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub p_within_time_cap: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub p_at_most_two_tries: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub p_passes_merge: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expected_minutes: Option<f64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Predictor {
Worker,
Reviewer,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Outcome {
pub run: RunId,
pub task: TaskId,
pub worker: ModelRef,
pub task_type: String,
pub first_try: bool,
pub tries: u32,
pub merged: bool,
pub spent_usd: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub step: Option<StepKey>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "time::serde::rfc3339::option"
)]
pub at: Option<OffsetDateTime>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub assigned: Option<Assignment>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub assign_p: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reviewer: Option<ModelRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub approved_first_review: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub within_time_cap: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub minutes: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub difficulty: Option<Difficulty>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub learned_workflow: Option<LearnedWorkflowRef>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
pub struct StepKey {
pub kind: StepKind,
pub standard: StandardPhase,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct Difficulty {
pub files_changed: u32,
pub lines_changed: u32,
pub dependencies: u32,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Reversal {
pub run: RunId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub task: Option<TaskId>,
pub what: String,
#[serde(with = "time::serde::rfc3339")]
pub at: OffsetDateTime,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct WorkflowStats {
pub runs: u32,
pub passed: u32,
}
pub fn append(store: &Store, run: &RunId, lines: &[LogLine]) -> Result<()> {
if lines.is_empty() {
return Ok(());
}
std::fs::create_dir_all(store.outcomes_dir())?;
let mut text = String::new();
for line in lines {
text.push_str(&serde_json::to_string(line)?);
text.push('\n');
}
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(run_file(store, run))?;
std::io::Write::write_all(&mut file, text.as_bytes())?;
Ok(())
}
pub fn read_run(store: &Store, run: &RunId) -> Result<Vec<LogLine>> {
let file = run_file(store, run);
if file.is_file() {
read_file(&file)
} else {
Ok(Vec::new())
}
}
pub fn read_all(store: &Store) -> Result<Vec<LogLine>> {
let dir = store.outcomes_dir();
if !dir.is_dir() {
return Ok(Vec::new());
}
let mut files = Vec::new();
for entry in std::fs::read_dir(dir)? {
let path = entry?.path();
if path
.extension()
.is_some_and(|extension| extension == "jsonl")
{
files.push(path);
}
}
files.sort();
let mut lines = Vec::new();
for file in files {
lines.extend(read_file(&file)?);
}
Ok(lines)
}
fn read_file(file: &Path) -> Result<Vec<LogLine>> {
let text = std::fs::read_to_string(file)?;
let mut lines = Vec::new();
for (n, line) in text.lines().enumerate() {
if line.trim().is_empty() {
continue;
}
let parsed = serde_json::from_str::<LogLine>(line)
.or_else(|_| serde_json::from_str::<Outcome>(line).map(LogLine::Outcome));
match parsed {
Ok(parsed) => lines.push(parsed),
Err(error) => tracing::warn!("skipping line {} of {}: {error}", n + 1, file.display()),
}
}
Ok(lines)
}
pub fn run_file(store: &Store, run: &RunId) -> PathBuf {
store.outcomes_dir().join(format!("{}.jsonl", run.as_str()))
}
pub fn share(store: &Store, run: &RunId, worktree: &Path) -> Result<Option<PathBuf>> {
let finished = read_run(store, run)?
.iter()
.any(|line| matches!(line, LogLine::Run(record) if &record.run == run));
if !finished {
return Ok(None);
}
let shared = worktree
.join(".argonauts")
.join(project_files::OUTCOMES)
.join(format!("{}.jsonl", run.as_str()));
if let Some(dir) = shared.parent() {
std::fs::create_dir_all(dir)?;
}
std::fs::copy(run_file(store, run), &shared)?;
Ok(Some(shared))
}
pub fn workflow_stats(lines: &[LogLine]) -> BTreeMap<(String, String), WorkflowStats> {
let mut runs: BTreeMap<&RunId, &RunRecord> = BTreeMap::new();
for line in lines {
if let LogLine::Run(record) = line {
runs.insert(&record.run, record);
}
}
let mut stats: BTreeMap<(String, String), WorkflowStats> = BTreeMap::new();
for record in runs.values() {
let Some((name, sha)) = &record.workflow else {
continue;
};
let entry = stats.entry((name.clone(), sha.clone())).or_default();
entry.runs += 1;
let merged = matches!(record.outcome, RunOutcome::Merged | RunOutcome::PullRequest);
if merged && record.final_checks_passed {
entry.passed += 1;
}
}
stats
}
#[cfg(test)]
mod tests {
use super::*;
fn outcome(task: u32) -> Outcome {
Outcome {
run: RunId::new("run-01").unwrap(),
task: TaskId::numbered(task),
worker: "codex".parse().unwrap(),
task_type: "backend".to_owned(),
first_try: true,
tries: 1,
merged: true,
spent_usd: 1.5,
step: None,
at: None,
model_id: Some("gpt-5.6".to_owned()),
assigned: Some(Assignment::Exploit),
assign_p: Some(0.8),
reviewer: None,
approved_first_review: None,
within_time_cap: None,
minutes: None,
difficulty: None,
learned_workflow: None,
}
}
#[test]
fn outcome_log_reads_old_and_new_lines() {
let dir = tempfile::tempdir().unwrap();
let store = Store::new(dir.path(), dir.path());
let run = RunId::new("run-01").unwrap();
std::fs::create_dir_all(store.outcomes_dir()).unwrap();
std::fs::write(
run_file(&store, &run),
"{\"run\":\"run-01\",\"task\":\"task-001\",\"worker\":\"claude-code\",\
\"task_type\":\"docs\",\"first_try\":false,\"tries\":2,\"merged\":true,\
\"spent_usd\":0.4}\n\
{\"run\":\"run-01\",\"task\":\"task-003\",\"worker\":\"codex/gpt-5.6\",\
\"task_type\":\"backend\",\"first_try\":true,\"tries\":1,\"merged\":true,\
\"spent_usd\":0.9,\"step\":{\"kind\":\"build\",\"standard\":\"do\"}}\n\
{\"cut short\n",
)
.unwrap();
append(&store, &run, &[LogLine::Outcome(outcome(2))]).unwrap();
let mut file = std::fs::OpenOptions::new()
.append(true)
.open(run_file(&store, &run))
.unwrap();
std::io::Write::write_all(
&mut file,
b"{\"type\":\"prediction\",\"run\":\"run-01\",\"task\":\"task-002\",\
\"worker\":\"codex\",\"role\":\"worker\",\"task_type\":\"backend\",\
\"at\":\"2026-09-30T10:00:00Z\",\"p_pass_checks_first_try\":0.7}\n\
{\"type\":\"reversal\",\"run\":\"run-01\",\"what\":\"reverted the auto-merge\",\
\"at\":\"2026-09-30T12:00:00Z\"}\n",
)
.unwrap();
let lines = read_run(&store, &run).unwrap();
assert_eq!(lines.len(), 5, "{lines:?}");
let LogLine::Outcome(old) = &lines[0] else {
panic!("{lines:?}");
};
assert_eq!(old.tries, 2);
assert_eq!(old.model_id, None);
assert_eq!(old.at, None);
let LogLine::Outcome(stepped) = &lines[1] else {
panic!("{lines:?}");
};
assert_eq!(
stepped.step,
Some(StepKey {
kind: StepKind::Build,
standard: StandardPhase::Do
})
);
assert_eq!(stepped.worker.to_string(), "codex/gpt-5.6");
assert_eq!(lines[2], LogLine::Outcome(outcome(2)));
let LogLine::Prediction(prediction) = &lines[3] else {
panic!("{lines:?}");
};
assert_eq!(prediction.role, Predictor::Worker);
assert_eq!(prediction.p_pass_checks_first_try, Some(0.7));
assert_eq!(prediction.p_within_time_cap, None);
assert!(matches!(&lines[4], LogLine::Reversal(reversal) if reversal.task.is_none()));
let earlier = RunId::new("run-00").unwrap();
append(&store, &earlier, &[LogLine::Outcome(outcome(9))]).unwrap();
let all = read_all(&store).unwrap();
assert_eq!(all.len(), 6);
assert_eq!(all[0], LogLine::Outcome(outcome(9)));
assert_eq!(&all[1..], &lines[..]);
}
}