use std::fmt;
use std::future::Future;
use amberfork_align::DiffParams;
use amberfork_attrib::{AgentDriver, AgentError, ReexecError};
use amberfork_model::{Attribution, DiffResult};
use amberfork_record::Cassette;
use amberfork_replay::LiveUpstream;
#[derive(Debug)]
pub struct VerifyConfig {
upstream: String,
base_url_env: Vec<String>,
command: Vec<String>,
runs: u32,
}
impl VerifyConfig {
pub fn resolve(
verify: bool,
upstream: Option<&str>,
base_url_env: &[String],
command: &[String],
runs: u32,
) -> Result<Option<Self>, String> {
let has_companions = upstream.is_some() || !base_url_env.is_empty() || !command.is_empty();
if !verify {
if has_companions {
return Err(
"--upstream, --base-url-env, and a `-- <cmd>` re-drive only apply with --verify"
.to_string(),
);
}
return Ok(None);
}
let upstream = upstream
.ok_or(
"--verify requires --upstream <URL> — the real provider to relay post-branch \
cache-misses to",
)?
.to_string();
if base_url_env.is_empty() {
return Err(
"--verify requires at least one --base-url-env <NAME> to point the \
re-driven agent at the replay listener"
.to_string(),
);
}
if command.is_empty() {
return Err(
"--verify requires an agent command after `--`, e.g. `-- python agent.py`"
.to_string(),
);
}
Ok(Some(Self {
upstream,
base_url_env: base_url_env.to_vec(),
command: command.to_vec(),
runs,
}))
}
}
struct SubprocessDriver {
command: Vec<String>,
base_url_env: Vec<String>,
}
impl AgentDriver for SubprocessDriver {
fn drive(&self, base_url: &str) -> impl Future<Output = Result<(), AgentError>> + Send {
let base_url = base_url.to_owned();
let command = self.command.clone();
let base_url_env = self.base_url_env.clone();
async move {
let (program, program_args) = command
.split_first()
.expect("verify config guarantees a non-empty agent command");
let mut child = tokio::process::Command::new(program);
child.args(program_args);
for name in &base_url_env {
child.env(name, &base_url);
}
child.status().await.map_err(AgentError::Spawn)?;
Ok(())
}
}
}
#[derive(Debug)]
pub enum VerifyError {
Runtime(std::io::Error),
HttpClient(reqwest::Error),
Reexec(ReexecError),
}
impl fmt::Display for VerifyError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Runtime(source) => write!(f, "cannot start the async runtime: {source}"),
Self::HttpClient(source) => write!(
f,
"cannot build the HTTP client for the live provider: {source}"
),
Self::Reexec(source) => write!(f, "{source}"),
}
}
}
impl std::error::Error for VerifyError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Runtime(source) => Some(source),
Self::HttpClient(source) => Some(source),
Self::Reexec(source) => Some(source),
}
}
}
pub fn verify_attribution(
diff: &DiffResult,
good: &Cassette,
bad: &Cassette,
config: &VerifyConfig,
params: &DiffParams,
) -> Result<Option<Attribution>, VerifyError> {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_io()
.build()
.map_err(VerifyError::Runtime)?;
let client = reqwest::Client::builder()
.build()
.map_err(VerifyError::HttpClient)?;
let driver = SubprocessDriver {
command: config.command.clone(),
base_url_env: config.base_url_env.clone(),
};
runtime.block_on(async {
amberfork_attrib::verify(
diff,
good,
bad,
&driver,
|| LiveUpstream::new(client.clone(), config.upstream.clone()),
config.runs,
params,
)
.await
.map_err(VerifyError::Reexec)
})
}
#[cfg(test)]
mod tests {
use super::*;
fn strings(items: &[&str]) -> Vec<String> {
items.iter().map(|s| (*s).to_string()).collect()
}
#[test]
fn no_verify_and_no_companions_is_the_untouched_default() {
assert!(
VerifyConfig::resolve(false, None, &[], &[], 3)
.expect("no flags is valid")
.is_none()
);
}
#[test]
fn a_companion_flag_without_verify_is_rejected() {
for (upstream, env, command) in [
(Some("http://api"), &[][..], &[][..]),
(None, &["OPENAI_BASE_URL".to_string()][..], &[][..]),
(None, &[][..], &["python".to_string()][..]),
] {
let err = VerifyConfig::resolve(false, upstream, env, command, 3)
.expect_err("a companion without --verify is an error");
assert!(err.contains("--verify"), "message names the gate: {err}");
}
}
#[test]
fn verify_missing_a_companion_names_the_one_it_needs() {
let no_upstream =
VerifyConfig::resolve(true, None, &strings(&["ENV"]), &strings(&["cmd"]), 3)
.expect_err("--verify needs --upstream");
assert!(no_upstream.contains("--upstream"));
let no_env = VerifyConfig::resolve(true, Some("http://api"), &[], &strings(&["cmd"]), 3)
.expect_err("--verify needs --base-url-env");
assert!(no_env.contains("--base-url-env"));
let no_command =
VerifyConfig::resolve(true, Some("http://api"), &strings(&["ENV"]), &[], 3)
.expect_err("--verify needs a command");
assert!(no_command.contains("`--`"));
}
#[test]
fn a_complete_verify_request_resolves_to_a_config() {
let config = VerifyConfig::resolve(
true,
Some("http://api"),
&strings(&["OPENAI_BASE_URL"]),
&strings(&["python", "agent.py"]),
4,
)
.expect("a complete request is valid")
.expect("and yields a config");
assert_eq!(config.upstream, "http://api");
assert_eq!(config.base_url_env, ["OPENAI_BASE_URL"]);
assert_eq!(config.command, ["python", "agent.py"]);
assert_eq!(config.runs, 4);
}
}