use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use serde_json::{from_str, to_string_pretty};
use tracing::{info, warn};
use crate::decision::{DecideFuture, DecisionOutput, DecisionProvider, DecisionRequest};
use crate::error::AgentError;
const PROVIDER_NAME: &str = "record-replay-decision";
#[derive(Serialize, Deserialize)]
struct Fixture {
request: DecisionRequest,
output: DecisionOutput,
}
pub fn hash_request(request: &DecisionRequest) -> String {
use sha2::{Digest, Sha256};
let canonical = serde_json::to_string(request).unwrap_or_default();
let mut hasher = Sha256::new();
hasher.update(canonical.as_bytes());
let result = hasher.finalize();
format!(
"{:016x}",
u64::from_be_bytes(result[..8].try_into().unwrap())
)
}
pub struct RecordReplayDecisionProvider {
inner: Option<Arc<dyn DecisionProvider>>,
fixtures_dir: PathBuf,
recording: bool,
}
impl RecordReplayDecisionProvider {
pub fn replay(fixtures_dir: &str) -> Self {
Self {
inner: None,
fixtures_dir: PathBuf::from(fixtures_dir),
recording: false,
}
}
pub fn with_inner(inner: Arc<dyn DecisionProvider>, fixtures_dir: &str) -> Self {
let recording = std::env::var("IRONFLOW_RECORD").is_ok();
if recording && let Err(e) = fs::create_dir_all(fixtures_dir) {
warn!(path = %fixtures_dir, error = %e, "failed to create fixtures directory");
}
Self {
inner: Some(inner),
fixtures_dir: PathBuf::from(fixtures_dir),
recording,
}
}
pub fn record(inner: Arc<dyn DecisionProvider>, fixtures_dir: &str) -> Self {
fs::create_dir_all(fixtures_dir).ok();
Self {
inner: Some(inner),
fixtures_dir: PathBuf::from(fixtures_dir),
recording: true,
}
}
pub fn fixtures_dir(&self) -> &Path {
&self.fixtures_dir
}
fn fixture_path(&self, request: &DecisionRequest) -> PathBuf {
self.fixtures_dir
.join(format!("{}.json", hash_request(request)))
}
fn load_fixture(&self, path: &Path) -> Option<DecisionOutput> {
let content = fs::read_to_string(path).ok()?;
let fixture: Fixture = from_str(&content).ok()?;
Some(fixture.output)
}
fn save_fixture(&self, path: &Path, request: &DecisionRequest, output: &DecisionOutput) {
let fixture = Fixture {
request: request.clone(),
output: output.clone(),
};
if let Ok(json) = to_string_pretty(&fixture)
&& let Err(e) = fs::write(path, json)
{
warn!(path = %path.display(), error = %e, "failed to save decision fixture");
}
}
}
impl DecisionProvider for RecordReplayDecisionProvider {
fn decide<'a>(&'a self, request: &'a DecisionRequest) -> DecideFuture<'a> {
Box::pin(async move {
let path = self.fixture_path(request);
if !self.recording
&& let Some(output) = self.load_fixture(&path)
{
info!(fixture = %path.display(), "replaying decision from fixture");
return Ok(output);
}
let Some(inner) = self.inner.as_ref() else {
return Err(AgentError::HttpProvider {
provider: PROVIDER_NAME.to_string(),
status_code: 0,
message: format!(
"no fixture at {} and no inner decision provider to fall through to",
path.display()
),
});
};
let output = inner.decide(request).await?;
if self.recording {
self.save_fixture(&path, request, &output);
info!(fixture = %path.display(), "recorded decision fixture");
}
Ok(output)
})
}
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use std::process;
use std::sync::atomic::{AtomicU64, Ordering};
use serde_json::json;
use super::*;
use crate::decision::{DecisionAnswer, DecisionUsage, NoulAnswer};
struct TempDirGuard(String);
impl Drop for TempDirGuard {
fn drop(&mut self) {
fs::remove_dir_all(&self.0).ok();
}
}
fn temp_dir() -> (String, TempDirGuard) {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let dir = format!(
"/tmp/ironflow-test-rrd-{}-{}",
process::id(),
COUNTER.fetch_add(1, Ordering::Relaxed)
);
fs::create_dir_all(&dir).unwrap();
let guard = TempDirGuard(dir.clone());
(dir, guard)
}
fn sample_request() -> DecisionRequest {
DecisionRequest {
state: json!("payouts failing"),
model: "jev-latest".into(),
questions: BTreeMap::new(),
}
}
fn write_fixture(dir: &str, request: &DecisionRequest, output: &DecisionOutput) {
let fixture = Fixture {
request: request.clone(),
output: output.clone(),
};
let path = PathBuf::from(dir).join(format!("{}.json", hash_request(request)));
fs::write(path, to_string_pretty(&fixture).unwrap()).unwrap();
}
#[tokio::test]
async fn replay_returns_fixture() {
let (dir, _guard) = temp_dir();
let request = sample_request();
let output = DecisionOutput {
model: Some("jev-latest".into()),
answers: BTreeMap::from([(
"q".to_string(),
DecisionAnswer::Noul(NoulAnswer { noul: 0.9 }),
)]),
usage: DecisionUsage {
input_tokens: 100,
output_tokens: 0,
},
};
write_fixture(&dir, &request, &output);
let provider = RecordReplayDecisionProvider::replay(&dir);
let got = provider.decide(&request).await.unwrap();
assert_eq!(got.noul("q").unwrap(), 0.9);
assert_eq!(got.usage.input_tokens, 100);
}
#[tokio::test]
async fn replay_without_fixture_and_without_inner_errors() {
let (dir, _guard) = temp_dir();
let provider = RecordReplayDecisionProvider::replay(&dir);
let err = provider.decide(&sample_request()).await.unwrap_err();
assert!(matches!(
err,
AgentError::HttpProvider { status_code: 0, .. }
));
}
#[test]
fn hash_is_stable_and_distinct() {
let a = sample_request();
let mut b = sample_request();
b.state = json!("something else");
assert_eq!(hash_request(&a), hash_request(&a));
assert_ne!(hash_request(&a), hash_request(&b));
}
}