1use ostraka_core::gate::{Approval, Check, CheckRecord, GateSpec};
18use ostraka_core::identity::ActorId;
19use std::io::Read;
20use std::path::Path;
21use std::process::{Command, Stdio};
22use std::time::{Duration, Instant};
23
24#[derive(Debug)]
29pub struct AllChecksPassed {
30 records: Vec<CheckRecord>,
31}
32
33impl AllChecksPassed {
34 pub fn records(&self) -> &[CheckRecord] {
35 &self.records
36 }
37}
38
39#[derive(Debug)]
41pub struct MergeToken {
42 author: ActorId,
43 reviewer: ActorId,
44 checks: Vec<CheckRecord>,
45}
46
47impl MergeToken {
48 pub fn author(&self) -> &ActorId {
49 &self.author
50 }
51
52 pub fn reviewer(&self) -> &ActorId {
53 &self.reviewer
54 }
55
56 pub fn checks(&self) -> &[CheckRecord] {
57 &self.checks
58 }
59}
60
61#[derive(Debug, PartialEq, Eq)]
67pub enum Refusal {
68 ChecksFailed {
70 failed: Vec<String>,
71 records: Vec<CheckRecord>,
72 },
73 Rejected { reason: String },
75 AuthorFailed {
82 code: String,
83 diagnostics: Option<String>,
84 },
85 PolicyViolation { reason: String },
87 SetupFailed { step: String, reason: String },
93 Interrupted,
97 TimedOut { after_secs: u64 },
103 NoChange,
109 SelfApproval { actor: ActorId },
111}
112
113pub fn run_checks(
119 spec: &GateSpec,
120 worktree: &Path,
121 finished: &mut dyn FnMut(&CheckRecord),
122) -> std::result::Result<AllChecksPassed, Refusal> {
123 run_checks_until(
124 spec,
125 worktree,
126 &ostraka_adapter::interrupt::Stop::new(),
127 finished,
128 )
129}
130
131pub fn run_checks_until(
136 spec: &GateSpec,
137 worktree: &Path,
138 stop: &ostraka_adapter::interrupt::Stop,
139 finished: &mut dyn FnMut(&CheckRecord),
140) -> std::result::Result<AllChecksPassed, Refusal> {
141 let mut records = Vec::with_capacity(spec.checks.len());
142 let mut failed = Vec::new();
143
144 let ceiling = spec.timeout_secs.map(Duration::from_secs);
145 for check in &spec.checks {
146 let record = run_one(check, worktree, ceiling, stop);
147 finished(&record);
152 if check.required && !record.passed() {
153 failed.push(check.name.clone());
154 }
155 records.push(record);
156 }
157
158 if failed.is_empty() {
159 Ok(AllChecksPassed { records })
160 } else {
161 Err(Refusal::ChecksFailed { failed, records })
162 }
163}
164
165fn run_one(
166 check: &Check,
167 worktree: &Path,
168 ceiling: Option<Duration>,
169 stop: &ostraka_adapter::interrupt::Stop,
170) -> CheckRecord {
171 let mut record = run_command_until(&check.cmd, worktree, ceiling, stop);
172 record.name = check.name.clone();
173 record
174}
175
176pub fn run_command(cmd: &str, worktree: &Path, ceiling: Option<Duration>) -> CheckRecord {
181 run_command_until(
182 cmd,
183 worktree,
184 ceiling,
185 &ostraka_adapter::interrupt::Stop::new(),
186 )
187}
188
189pub fn run_command_until(
191 cmd: &str,
192 worktree: &Path,
193 ceiling: Option<Duration>,
194 until: &ostraka_adapter::interrupt::Stop,
195) -> CheckRecord {
196 let started = Instant::now();
197 let check = Check {
198 name: String::new(),
199 cmd: cmd.to_string(),
200 required: true,
201 };
202
203 let mut command = Command::new("sh");
204 #[cfg(unix)]
207 {
208 use std::os::unix::process::CommandExt;
209 command.process_group(0);
210 }
211 let spawned = command
212 .arg("-c")
213 .arg(&check.cmd)
214 .current_dir(worktree)
215 .stdin(Stdio::null())
216 .stdout(Stdio::piped())
217 .stderr(Stdio::piped())
218 .spawn();
219
220 let (exit_code, stdout, stderr) = match spawned {
221 Ok(child) => wait_for(child, ceiling, until),
222 Err(e) => (None, String::new(), e.to_string()),
225 };
226
227 CheckRecord {
228 name: check.name.clone(),
229 cmd: check.cmd.clone(),
230 exit_code,
231 stdout,
232 stderr,
233 duration_ms: started.elapsed().as_millis() as u64,
234 }
235}
236
237fn stop(child: &mut std::process::Child) {
244 #[cfg(unix)]
245 unsafe {
248 libc::killpg(child.id() as libc::pid_t, libc::SIGTERM);
249 }
250 let _ = child.kill();
252}
253
254fn wait_for(
260 mut child: std::process::Child,
261 ceiling: Option<Duration>,
262 until: &ostraka_adapter::interrupt::Stop,
263) -> (Option<i32>, String, String) {
264 fn reader(stream: Option<impl Read + Send + 'static>) -> std::sync::mpsc::Receiver<String> {
265 let (tx, rx) = std::sync::mpsc::channel();
266 std::thread::spawn(move || {
267 let mut text = String::new();
268 if let Some(mut stream) = stream {
269 let _ = stream.read_to_string(&mut text);
270 }
271 let _ = tx.send(text);
272 });
273 rx
274 }
275
276 let out = reader(child.stdout.take());
277 let err = reader(child.stderr.take());
278
279 let deadline = ceiling.map(|ceiling| Instant::now() + ceiling);
284 let killed_by = loop {
287 match child.try_wait() {
288 Ok(Some(_)) => break None,
289 Err(_) => break None,
290 Ok(None) => {}
291 }
292 let expired = deadline.is_some_and(|deadline| Instant::now() >= deadline);
293 if expired || until.requested() {
294 stop(&mut child);
295 break Some(expired);
296 }
297 std::thread::sleep(Duration::from_millis(25));
298 };
299 let killed = killed_by.is_some();
300
301 let status = child.wait().ok();
302 let collect = |rx: std::sync::mpsc::Receiver<String>| -> String {
307 if killed {
308 rx.recv_timeout(Duration::from_millis(200))
309 .unwrap_or_default()
310 } else {
311 rx.recv().unwrap_or_default()
312 }
313 };
314 let stdout = collect(out);
315 let mut stderr = collect(err);
316 if killed {
317 stderr.push_str(&match killed_by {
320 Some(false) => {
325 "\nostraka: stopped — the run was asked to stop before this check finished\n"
326 .to_string()
327 }
328 _ => format!(
329 "\nostraka: killed after {}s — the gate's timeout_secs\n",
330 ceiling.map(|c| c.as_secs()).unwrap_or_default()
331 ),
332 });
333 }
334 (status.and_then(|s| s.code()), stdout, stderr)
337}
338
339pub fn evaluate(
341 checks: AllChecksPassed,
342 author: &ActorId,
343 approval: &Approval,
344 must_differ_from_author: bool,
345) -> std::result::Result<MergeToken, Refusal> {
346 if must_differ_from_author && &approval.reviewer == author {
347 return Err(Refusal::SelfApproval {
348 actor: author.clone(),
349 });
350 }
351
352 match &approval.verdict {
353 ostraka_core::gate::Verdict::Reject { reason } => Err(Refusal::Rejected {
354 reason: reason.clone(),
355 }),
356 ostraka_core::gate::Verdict::Approve => Ok(MergeToken {
357 author: author.clone(),
358 reviewer: approval.reviewer.clone(),
359 checks: checks.records,
360 }),
361 }
362}
363
364pub fn reaffirm(
382 spec: &GateSpec,
383 record: &ostraka_core::record::RunRecord,
384 must_differ_from_author: bool,
385) -> std::result::Result<MergeToken, Refusal> {
386 let mut records = Vec::with_capacity(spec.checks.len());
387 let mut failed = Vec::new();
388
389 for check in &spec.checks {
390 match record.checks.iter().find(|c| c.name == check.name) {
391 Some(evidence) => {
392 if check.required && !evidence.passed() {
393 failed.push(check.name.clone());
394 }
395 records.push(evidence.clone());
396 }
397 None if check.required => failed.push(check.name.clone()),
398 None => {}
399 }
400 }
401
402 if !failed.is_empty() {
403 return Err(Refusal::ChecksFailed { failed, records });
404 }
405
406 let Some(approval) = &record.approval else {
407 return Err(Refusal::Rejected {
408 reason: "the run record carries no approval, so nothing reviewed this change"
409 .to_string(),
410 });
411 };
412
413 evaluate(
414 AllChecksPassed { records },
415 &record.author,
416 approval,
417 must_differ_from_author,
418 )
419}
420
421#[cfg(test)]
422mod tests {
423 use super::*;
424
425 #[test]
426 fn a_check_with_no_ceiling_can_still_be_stopped() {
427 let stop = ostraka_adapter::interrupt::Stop::new();
430 let started = Instant::now();
431 let record = std::thread::scope(|scope| {
432 scope.spawn(|| {
433 std::thread::sleep(Duration::from_millis(300));
434 stop.request();
435 });
436 run_command_until("sleep 120", Path::new("."), None, &stop)
437 });
438 assert!(
439 started.elapsed() < Duration::from_secs(20),
440 "an unbounded check ignored its stop for {:?}",
441 started.elapsed()
442 );
443 assert!(!record.passed(), "a stopped check passed");
444 assert!(record.stderr.contains("asked to stop"), "{}", record.stderr);
446 assert!(
447 !record.stderr.contains("timeout_secs"),
448 "a stop was recorded as a timeout: {}",
449 record.stderr
450 );
451 }
452 use ostraka_core::gate::{ReviewPolicy, Verdict};
453
454 fn spec(cmds: &[(&str, &str, bool)]) -> GateSpec {
455 GateSpec {
456 timeout_secs: None,
457 checks: cmds
458 .iter()
459 .map(|(name, cmd, required)| Check {
460 name: (*name).to_string(),
461 cmd: (*cmd).to_string(),
462 required: *required,
463 })
464 .collect(),
465 review: ReviewPolicy::default(),
466 }
467 }
468
469 #[test]
470 fn a_check_that_hangs_is_killed_and_says_so() {
471 let mut spec = spec(&[("hang", "sleep 120", true)]);
474 spec.timeout_secs = Some(1);
475 let started = Instant::now();
476 let refusal = run_checks(&spec, Path::new("."), &mut ignore).expect_err("must refuse");
477 assert!(
478 started.elapsed() < Duration::from_secs(20),
479 "the ceiling was not enforced"
480 );
481 match refusal {
482 Refusal::ChecksFailed { failed, records } => {
483 assert_eq!(failed, ["hang"]);
484 assert!(
485 records[0].stderr.contains("killed after"),
486 "a killed check read as a crash: {:?}",
487 records[0].stderr
488 );
489 }
490 other => panic!("wrong refusal: {other:?}"),
491 }
492 }
493
494 #[test]
495 fn the_workers_a_hung_check_started_are_stopped_with_it() {
496 let marker = std::env::temp_dir().join(format!("ostraka-gate-pg-{}", std::process::id()));
501 let _ = std::fs::remove_file(&marker);
502 let mut spec = spec(&[(
503 "forks",
504 &format!("(sleep 4; touch {}) & sleep 120", marker.display()),
505 true,
506 )]);
507 spec.timeout_secs = Some(1);
508 run_checks(&spec, Path::new("."), &mut ignore).expect_err("must refuse");
509
510 std::thread::sleep(Duration::from_secs(6));
511 assert!(
512 !marker.exists(),
513 "a worker outlived the check that started it"
514 );
515 let _ = std::fs::remove_file(&marker);
516 }
517
518 #[test]
519 fn a_check_that_finishes_inside_the_ceiling_is_untouched() {
520 let mut spec = spec(&[("quick", "echo fine", true)]);
521 spec.timeout_secs = Some(30);
522 let passed = run_checks(&spec, Path::new("."), &mut ignore).expect("passes");
523 assert!(passed.records()[0].passed());
524 assert!(!passed.records()[0].stderr.contains("killed"));
525 }
526
527 #[test]
528 fn checks_actually_run_and_their_output_is_captured() {
529 let passed = run_checks(
530 &spec(&[("echo", "echo hello", true)]),
531 Path::new("."),
532 &mut ignore,
533 )
534 .expect("check passes");
535 let record = &passed.records()[0];
536 assert!(record.passed());
537 assert!(record.stdout.contains("hello"));
538 }
539
540 #[test]
541 fn a_failing_required_check_refuses_the_gate() {
542 let refusal = run_checks(
543 &spec(&[("fail", "exit 1", true)]),
544 Path::new("."),
545 &mut ignore,
546 )
547 .expect_err("must refuse");
548 match refusal {
549 Refusal::ChecksFailed { failed, records } => {
550 assert_eq!(failed, ["fail"]);
551 assert_eq!(records.len(), 1);
553 assert_eq!(records[0].exit_code, Some(1));
554 }
555 other => panic!("wrong refusal: {other:?}"),
556 }
557 }
558
559 #[test]
560 fn an_optional_check_is_recorded_but_does_not_block() {
561 let passed = run_checks(
562 &spec(&[("advisory", "exit 3", false), ("real", "true", true)]),
563 Path::new("."),
564 &mut ignore,
565 )
566 .expect("optional failure does not block");
567 assert_eq!(passed.records().len(), 2);
568 assert!(!passed.records()[0].passed());
569 }
570
571 #[test]
572 fn a_command_that_cannot_run_is_a_failure_not_a_pass() {
573 let refusal = run_checks(
574 &spec(&[("missing", "definitely-not-a-real-binary-xyz", true)]),
575 Path::new("."),
576 &mut ignore,
577 )
578 .expect_err("must refuse");
579 assert!(matches!(refusal, Refusal::ChecksFailed { .. }));
580 }
581
582 fn ignore(_: &CheckRecord) {}
584
585 #[test]
586 fn each_check_is_reported_as_it_finishes_rather_than_all_at_the_end() {
587 let mut seen: Vec<String> = Vec::new();
590 let refusal = run_checks(
591 &spec(&[
592 ("first", "true", true),
593 ("second", "exit 1", true),
594 ("third", "true", true),
595 ]),
596 Path::new("."),
597 &mut |record| seen.push(format!("{} {}", record.name, record.passed())),
598 )
599 .expect_err("the second check fails");
600
601 assert_eq!(seen, ["first true", "second false", "third true"]);
602 assert!(matches!(refusal, Refusal::ChecksFailed { .. }));
605 }
606
607 fn passing_checks() -> AllChecksPassed {
608 run_checks(&spec(&[("ok", "true", true)]), Path::new("."), &mut ignore).expect("passes")
609 }
610
611 #[test]
612 fn the_author_cannot_approve_their_own_change() {
613 let archon = ActorId::new("archon");
614 let refusal = evaluate(
615 passing_checks(),
616 &archon,
617 &Approval {
618 reviewer: archon.clone(),
619 verdict: Verdict::Approve,
620 },
621 true,
622 )
623 .expect_err("self-approval must be refused");
624 assert_eq!(refusal, Refusal::SelfApproval { actor: archon });
625 }
626
627 #[test]
628 fn an_independent_approval_mints_a_token() {
629 let token = evaluate(
630 passing_checks(),
631 &ActorId::new("archon"),
632 &Approval {
633 reviewer: ActorId::new("ephor"),
634 verdict: Verdict::Approve,
635 },
636 true,
637 )
638 .expect("independent approval");
639 assert_eq!(token.author().as_str(), "archon");
640 assert_eq!(token.reviewer().as_str(), "ephor");
641 assert_eq!(token.checks().len(), 1);
642 }
643
644 #[test]
645 fn a_rejection_mints_nothing() {
646 let refusal = evaluate(
647 passing_checks(),
648 &ActorId::new("archon"),
649 &Approval {
650 reviewer: ActorId::new("ephor"),
651 verdict: Verdict::Reject {
652 reason: "no tests".into(),
653 },
654 },
655 true,
656 )
657 .expect_err("rejection");
658 assert_eq!(
659 refusal,
660 Refusal::Rejected {
661 reason: "no tests".to_string()
662 }
663 );
664 }
665}