use ostraka_core::gate::{Approval, Check, CheckRecord, GateSpec};
use ostraka_core::identity::ActorId;
use std::io::Read;
use std::path::Path;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
#[derive(Debug)]
pub struct AllChecksPassed {
records: Vec<CheckRecord>,
}
impl AllChecksPassed {
pub fn records(&self) -> &[CheckRecord] {
&self.records
}
}
#[derive(Debug)]
pub struct MergeToken {
author: ActorId,
reviewer: ActorId,
checks: Vec<CheckRecord>,
}
impl MergeToken {
pub fn author(&self) -> &ActorId {
&self.author
}
pub fn reviewer(&self) -> &ActorId {
&self.reviewer
}
pub fn checks(&self) -> &[CheckRecord] {
&self.checks
}
}
#[derive(Debug, PartialEq, Eq)]
pub enum Refusal {
ChecksFailed {
failed: Vec<String>,
records: Vec<CheckRecord>,
},
Rejected { reason: String },
AuthorFailed {
code: String,
diagnostics: Option<String>,
},
PolicyViolation { reason: String },
SetupFailed { step: String, reason: String },
Interrupted,
TimedOut { after_secs: u64 },
NoChange,
SelfApproval { actor: ActorId },
}
pub fn run_checks(
spec: &GateSpec,
worktree: &Path,
finished: &mut dyn FnMut(&CheckRecord),
) -> std::result::Result<AllChecksPassed, Refusal> {
let mut records = Vec::with_capacity(spec.checks.len());
let mut failed = Vec::new();
let ceiling = spec.timeout_secs.map(Duration::from_secs);
for check in &spec.checks {
let record = run_one(check, worktree, ceiling);
finished(&record);
if check.required && !record.passed() {
failed.push(check.name.clone());
}
records.push(record);
}
if failed.is_empty() {
Ok(AllChecksPassed { records })
} else {
Err(Refusal::ChecksFailed { failed, records })
}
}
fn run_one(check: &Check, worktree: &Path, ceiling: Option<Duration>) -> CheckRecord {
let mut record = run_command(&check.cmd, worktree, ceiling);
record.name = check.name.clone();
record
}
pub fn run_command(cmd: &str, worktree: &Path, ceiling: Option<Duration>) -> CheckRecord {
let started = Instant::now();
let check = Check {
name: String::new(),
cmd: cmd.to_string(),
required: true,
};
let mut command = Command::new("sh");
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
command.process_group(0);
}
let spawned = command
.arg("-c")
.arg(&check.cmd)
.current_dir(worktree)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn();
let (exit_code, stdout, stderr) = match spawned {
Ok(child) => wait_for(child, ceiling),
Err(e) => (None, String::new(), e.to_string()),
};
CheckRecord {
name: check.name.clone(),
cmd: check.cmd.clone(),
exit_code,
stdout,
stderr,
duration_ms: started.elapsed().as_millis() as u64,
}
}
fn stop(child: &mut std::process::Child) {
#[cfg(unix)]
unsafe {
libc::killpg(child.id() as libc::pid_t, libc::SIGTERM);
}
let _ = child.kill();
}
fn wait_for(
mut child: std::process::Child,
ceiling: Option<Duration>,
) -> (Option<i32>, String, String) {
fn reader(stream: Option<impl Read + Send + 'static>) -> std::sync::mpsc::Receiver<String> {
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut text = String::new();
if let Some(mut stream) = stream {
let _ = stream.read_to_string(&mut text);
}
let _ = tx.send(text);
});
rx
}
let out = reader(child.stdout.take());
let err = reader(child.stderr.take());
let killed = match ceiling {
None => false,
Some(ceiling) => {
let deadline = Instant::now() + ceiling;
loop {
match child.try_wait() {
Ok(Some(_)) => break false,
Err(_) => break false,
Ok(None) => {}
}
if Instant::now() >= deadline || ostraka_adapter::interrupt::requested() {
stop(&mut child);
break true;
}
std::thread::sleep(Duration::from_millis(25));
}
}
};
let status = child.wait().ok();
let collect = |rx: std::sync::mpsc::Receiver<String>| -> String {
if killed {
rx.recv_timeout(Duration::from_millis(200))
.unwrap_or_default()
} else {
rx.recv().unwrap_or_default()
}
};
let stdout = collect(out);
let mut stderr = collect(err);
if killed {
stderr.push_str(&format!(
"\nostraka: killed after {}s — the gate's timeout_secs\n",
ceiling.map(|c| c.as_secs()).unwrap_or_default()
));
}
(status.and_then(|s| s.code()), stdout, stderr)
}
pub fn evaluate(
checks: AllChecksPassed,
author: &ActorId,
approval: &Approval,
must_differ_from_author: bool,
) -> std::result::Result<MergeToken, Refusal> {
if must_differ_from_author && &approval.reviewer == author {
return Err(Refusal::SelfApproval {
actor: author.clone(),
});
}
match &approval.verdict {
ostraka_core::gate::Verdict::Reject { reason } => Err(Refusal::Rejected {
reason: reason.clone(),
}),
ostraka_core::gate::Verdict::Approve => Ok(MergeToken {
author: author.clone(),
reviewer: approval.reviewer.clone(),
checks: checks.records,
}),
}
}
pub fn reaffirm(
spec: &GateSpec,
record: &ostraka_core::record::RunRecord,
must_differ_from_author: bool,
) -> std::result::Result<MergeToken, Refusal> {
let mut records = Vec::with_capacity(spec.checks.len());
let mut failed = Vec::new();
for check in &spec.checks {
match record.checks.iter().find(|c| c.name == check.name) {
Some(evidence) => {
if check.required && !evidence.passed() {
failed.push(check.name.clone());
}
records.push(evidence.clone());
}
None if check.required => failed.push(check.name.clone()),
None => {}
}
}
if !failed.is_empty() {
return Err(Refusal::ChecksFailed { failed, records });
}
let Some(approval) = &record.approval else {
return Err(Refusal::Rejected {
reason: "the run record carries no approval, so nothing reviewed this change"
.to_string(),
});
};
evaluate(
AllChecksPassed { records },
&record.author,
approval,
must_differ_from_author,
)
}
#[cfg(test)]
mod tests {
use super::*;
use ostraka_core::gate::{ReviewPolicy, Verdict};
fn spec(cmds: &[(&str, &str, bool)]) -> GateSpec {
GateSpec {
timeout_secs: None,
checks: cmds
.iter()
.map(|(name, cmd, required)| Check {
name: (*name).to_string(),
cmd: (*cmd).to_string(),
required: *required,
})
.collect(),
review: ReviewPolicy::default(),
}
}
#[test]
fn a_check_that_hangs_is_killed_and_says_so() {
let mut spec = spec(&[("hang", "sleep 120", true)]);
spec.timeout_secs = Some(1);
let started = Instant::now();
let refusal = run_checks(&spec, Path::new("."), &mut ignore).expect_err("must refuse");
assert!(
started.elapsed() < Duration::from_secs(20),
"the ceiling was not enforced"
);
match refusal {
Refusal::ChecksFailed { failed, records } => {
assert_eq!(failed, ["hang"]);
assert!(
records[0].stderr.contains("killed after"),
"a killed check read as a crash: {:?}",
records[0].stderr
);
}
other => panic!("wrong refusal: {other:?}"),
}
}
#[test]
fn the_workers_a_hung_check_started_are_stopped_with_it() {
let marker = std::env::temp_dir().join(format!("ostraka-gate-pg-{}", std::process::id()));
let _ = std::fs::remove_file(&marker);
let mut spec = spec(&[(
"forks",
&format!("(sleep 4; touch {}) & sleep 120", marker.display()),
true,
)]);
spec.timeout_secs = Some(1);
run_checks(&spec, Path::new("."), &mut ignore).expect_err("must refuse");
std::thread::sleep(Duration::from_secs(6));
assert!(
!marker.exists(),
"a worker outlived the check that started it"
);
let _ = std::fs::remove_file(&marker);
}
#[test]
fn a_check_that_finishes_inside_the_ceiling_is_untouched() {
let mut spec = spec(&[("quick", "echo fine", true)]);
spec.timeout_secs = Some(30);
let passed = run_checks(&spec, Path::new("."), &mut ignore).expect("passes");
assert!(passed.records()[0].passed());
assert!(!passed.records()[0].stderr.contains("killed"));
}
#[test]
fn checks_actually_run_and_their_output_is_captured() {
let passed = run_checks(
&spec(&[("echo", "echo hello", true)]),
Path::new("."),
&mut ignore,
)
.expect("check passes");
let record = &passed.records()[0];
assert!(record.passed());
assert!(record.stdout.contains("hello"));
}
#[test]
fn a_failing_required_check_refuses_the_gate() {
let refusal = run_checks(
&spec(&[("fail", "exit 1", true)]),
Path::new("."),
&mut ignore,
)
.expect_err("must refuse");
match refusal {
Refusal::ChecksFailed { failed, records } => {
assert_eq!(failed, ["fail"]);
assert_eq!(records.len(), 1);
assert_eq!(records[0].exit_code, Some(1));
}
other => panic!("wrong refusal: {other:?}"),
}
}
#[test]
fn an_optional_check_is_recorded_but_does_not_block() {
let passed = run_checks(
&spec(&[("advisory", "exit 3", false), ("real", "true", true)]),
Path::new("."),
&mut ignore,
)
.expect("optional failure does not block");
assert_eq!(passed.records().len(), 2);
assert!(!passed.records()[0].passed());
}
#[test]
fn a_command_that_cannot_run_is_a_failure_not_a_pass() {
let refusal = run_checks(
&spec(&[("missing", "definitely-not-a-real-binary-xyz", true)]),
Path::new("."),
&mut ignore,
)
.expect_err("must refuse");
assert!(matches!(refusal, Refusal::ChecksFailed { .. }));
}
fn ignore(_: &CheckRecord) {}
#[test]
fn each_check_is_reported_as_it_finishes_rather_than_all_at_the_end() {
let mut seen: Vec<String> = Vec::new();
let refusal = run_checks(
&spec(&[
("first", "true", true),
("second", "exit 1", true),
("third", "true", true),
]),
Path::new("."),
&mut |record| seen.push(format!("{} {}", record.name, record.passed())),
)
.expect_err("the second check fails");
assert_eq!(seen, ["first true", "second false", "third true"]);
assert!(matches!(refusal, Refusal::ChecksFailed { .. }));
}
fn passing_checks() -> AllChecksPassed {
run_checks(&spec(&[("ok", "true", true)]), Path::new("."), &mut ignore).expect("passes")
}
#[test]
fn the_author_cannot_approve_their_own_change() {
let archon = ActorId::new("archon");
let refusal = evaluate(
passing_checks(),
&archon,
&Approval {
reviewer: archon.clone(),
verdict: Verdict::Approve,
},
true,
)
.expect_err("self-approval must be refused");
assert_eq!(refusal, Refusal::SelfApproval { actor: archon });
}
#[test]
fn an_independent_approval_mints_a_token() {
let token = evaluate(
passing_checks(),
&ActorId::new("archon"),
&Approval {
reviewer: ActorId::new("ephor"),
verdict: Verdict::Approve,
},
true,
)
.expect("independent approval");
assert_eq!(token.author().as_str(), "archon");
assert_eq!(token.reviewer().as_str(), "ephor");
assert_eq!(token.checks().len(), 1);
}
#[test]
fn a_rejection_mints_nothing() {
let refusal = evaluate(
passing_checks(),
&ActorId::new("archon"),
&Approval {
reviewer: ActorId::new("ephor"),
verdict: Verdict::Reject {
reason: "no tests".into(),
},
},
true,
)
.expect_err("rejection");
assert_eq!(
refusal,
Refusal::Rejected {
reason: "no tests".to_string()
}
);
}
}