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 let mut records = Vec::with_capacity(spec.checks.len());
124 let mut failed = Vec::new();
125
126 let ceiling = spec.timeout_secs.map(Duration::from_secs);
127 for check in &spec.checks {
128 let record = run_one(check, worktree, ceiling);
129 finished(&record);
134 if check.required && !record.passed() {
135 failed.push(check.name.clone());
136 }
137 records.push(record);
138 }
139
140 if failed.is_empty() {
141 Ok(AllChecksPassed { records })
142 } else {
143 Err(Refusal::ChecksFailed { failed, records })
144 }
145}
146
147fn run_one(check: &Check, worktree: &Path, ceiling: Option<Duration>) -> CheckRecord {
148 let mut record = run_command(&check.cmd, worktree, ceiling);
149 record.name = check.name.clone();
150 record
151}
152
153pub fn run_command(cmd: &str, worktree: &Path, ceiling: Option<Duration>) -> CheckRecord {
158 let started = Instant::now();
159 let check = Check {
160 name: String::new(),
161 cmd: cmd.to_string(),
162 required: true,
163 };
164
165 let mut command = Command::new("sh");
166 #[cfg(unix)]
169 {
170 use std::os::unix::process::CommandExt;
171 command.process_group(0);
172 }
173 let spawned = command
174 .arg("-c")
175 .arg(&check.cmd)
176 .current_dir(worktree)
177 .stdin(Stdio::null())
178 .stdout(Stdio::piped())
179 .stderr(Stdio::piped())
180 .spawn();
181
182 let (exit_code, stdout, stderr) = match spawned {
183 Ok(child) => wait_for(child, ceiling),
184 Err(e) => (None, String::new(), e.to_string()),
187 };
188
189 CheckRecord {
190 name: check.name.clone(),
191 cmd: check.cmd.clone(),
192 exit_code,
193 stdout,
194 stderr,
195 duration_ms: started.elapsed().as_millis() as u64,
196 }
197}
198
199fn stop(child: &mut std::process::Child) {
206 #[cfg(unix)]
207 unsafe {
210 libc::killpg(child.id() as libc::pid_t, libc::SIGTERM);
211 }
212 let _ = child.kill();
214}
215
216fn wait_for(
222 mut child: std::process::Child,
223 ceiling: Option<Duration>,
224) -> (Option<i32>, String, String) {
225 fn reader(stream: Option<impl Read + Send + 'static>) -> std::sync::mpsc::Receiver<String> {
226 let (tx, rx) = std::sync::mpsc::channel();
227 std::thread::spawn(move || {
228 let mut text = String::new();
229 if let Some(mut stream) = stream {
230 let _ = stream.read_to_string(&mut text);
231 }
232 let _ = tx.send(text);
233 });
234 rx
235 }
236
237 let out = reader(child.stdout.take());
238 let err = reader(child.stderr.take());
239
240 let killed = match ceiling {
241 None => false,
242 Some(ceiling) => {
243 let deadline = Instant::now() + ceiling;
244 loop {
245 match child.try_wait() {
246 Ok(Some(_)) => break false,
247 Err(_) => break false,
248 Ok(None) => {}
249 }
250 if Instant::now() >= deadline || ostraka_adapter::interrupt::requested() {
251 stop(&mut child);
252 break true;
253 }
254 std::thread::sleep(Duration::from_millis(25));
255 }
256 }
257 };
258
259 let status = child.wait().ok();
260 let collect = |rx: std::sync::mpsc::Receiver<String>| -> String {
265 if killed {
266 rx.recv_timeout(Duration::from_millis(200))
267 .unwrap_or_default()
268 } else {
269 rx.recv().unwrap_or_default()
270 }
271 };
272 let stdout = collect(out);
273 let mut stderr = collect(err);
274 if killed {
275 stderr.push_str(&format!(
278 "\nostraka: killed after {}s — the gate's timeout_secs\n",
279 ceiling.map(|c| c.as_secs()).unwrap_or_default()
280 ));
281 }
282 (status.and_then(|s| s.code()), stdout, stderr)
285}
286
287pub fn evaluate(
289 checks: AllChecksPassed,
290 author: &ActorId,
291 approval: &Approval,
292 must_differ_from_author: bool,
293) -> std::result::Result<MergeToken, Refusal> {
294 if must_differ_from_author && &approval.reviewer == author {
295 return Err(Refusal::SelfApproval {
296 actor: author.clone(),
297 });
298 }
299
300 match &approval.verdict {
301 ostraka_core::gate::Verdict::Reject { reason } => Err(Refusal::Rejected {
302 reason: reason.clone(),
303 }),
304 ostraka_core::gate::Verdict::Approve => Ok(MergeToken {
305 author: author.clone(),
306 reviewer: approval.reviewer.clone(),
307 checks: checks.records,
308 }),
309 }
310}
311
312pub fn reaffirm(
330 spec: &GateSpec,
331 record: &ostraka_core::record::RunRecord,
332 must_differ_from_author: bool,
333) -> std::result::Result<MergeToken, Refusal> {
334 let mut records = Vec::with_capacity(spec.checks.len());
335 let mut failed = Vec::new();
336
337 for check in &spec.checks {
338 match record.checks.iter().find(|c| c.name == check.name) {
339 Some(evidence) => {
340 if check.required && !evidence.passed() {
341 failed.push(check.name.clone());
342 }
343 records.push(evidence.clone());
344 }
345 None if check.required => failed.push(check.name.clone()),
346 None => {}
347 }
348 }
349
350 if !failed.is_empty() {
351 return Err(Refusal::ChecksFailed { failed, records });
352 }
353
354 let Some(approval) = &record.approval else {
355 return Err(Refusal::Rejected {
356 reason: "the run record carries no approval, so nothing reviewed this change"
357 .to_string(),
358 });
359 };
360
361 evaluate(
362 AllChecksPassed { records },
363 &record.author,
364 approval,
365 must_differ_from_author,
366 )
367}
368
369#[cfg(test)]
370mod tests {
371 use super::*;
372 use ostraka_core::gate::{ReviewPolicy, Verdict};
373
374 fn spec(cmds: &[(&str, &str, bool)]) -> GateSpec {
375 GateSpec {
376 timeout_secs: None,
377 checks: cmds
378 .iter()
379 .map(|(name, cmd, required)| Check {
380 name: (*name).to_string(),
381 cmd: (*cmd).to_string(),
382 required: *required,
383 })
384 .collect(),
385 review: ReviewPolicy::default(),
386 }
387 }
388
389 #[test]
390 fn a_check_that_hangs_is_killed_and_says_so() {
391 let mut spec = spec(&[("hang", "sleep 120", true)]);
394 spec.timeout_secs = Some(1);
395 let started = Instant::now();
396 let refusal = run_checks(&spec, Path::new("."), &mut ignore).expect_err("must refuse");
397 assert!(
398 started.elapsed() < Duration::from_secs(20),
399 "the ceiling was not enforced"
400 );
401 match refusal {
402 Refusal::ChecksFailed { failed, records } => {
403 assert_eq!(failed, ["hang"]);
404 assert!(
405 records[0].stderr.contains("killed after"),
406 "a killed check read as a crash: {:?}",
407 records[0].stderr
408 );
409 }
410 other => panic!("wrong refusal: {other:?}"),
411 }
412 }
413
414 #[test]
415 fn the_workers_a_hung_check_started_are_stopped_with_it() {
416 let marker = std::env::temp_dir().join(format!("ostraka-gate-pg-{}", std::process::id()));
421 let _ = std::fs::remove_file(&marker);
422 let mut spec = spec(&[(
423 "forks",
424 &format!("(sleep 4; touch {}) & sleep 120", marker.display()),
425 true,
426 )]);
427 spec.timeout_secs = Some(1);
428 run_checks(&spec, Path::new("."), &mut ignore).expect_err("must refuse");
429
430 std::thread::sleep(Duration::from_secs(6));
431 assert!(
432 !marker.exists(),
433 "a worker outlived the check that started it"
434 );
435 let _ = std::fs::remove_file(&marker);
436 }
437
438 #[test]
439 fn a_check_that_finishes_inside_the_ceiling_is_untouched() {
440 let mut spec = spec(&[("quick", "echo fine", true)]);
441 spec.timeout_secs = Some(30);
442 let passed = run_checks(&spec, Path::new("."), &mut ignore).expect("passes");
443 assert!(passed.records()[0].passed());
444 assert!(!passed.records()[0].stderr.contains("killed"));
445 }
446
447 #[test]
448 fn checks_actually_run_and_their_output_is_captured() {
449 let passed = run_checks(
450 &spec(&[("echo", "echo hello", true)]),
451 Path::new("."),
452 &mut ignore,
453 )
454 .expect("check passes");
455 let record = &passed.records()[0];
456 assert!(record.passed());
457 assert!(record.stdout.contains("hello"));
458 }
459
460 #[test]
461 fn a_failing_required_check_refuses_the_gate() {
462 let refusal = run_checks(
463 &spec(&[("fail", "exit 1", true)]),
464 Path::new("."),
465 &mut ignore,
466 )
467 .expect_err("must refuse");
468 match refusal {
469 Refusal::ChecksFailed { failed, records } => {
470 assert_eq!(failed, ["fail"]);
471 assert_eq!(records.len(), 1);
473 assert_eq!(records[0].exit_code, Some(1));
474 }
475 other => panic!("wrong refusal: {other:?}"),
476 }
477 }
478
479 #[test]
480 fn an_optional_check_is_recorded_but_does_not_block() {
481 let passed = run_checks(
482 &spec(&[("advisory", "exit 3", false), ("real", "true", true)]),
483 Path::new("."),
484 &mut ignore,
485 )
486 .expect("optional failure does not block");
487 assert_eq!(passed.records().len(), 2);
488 assert!(!passed.records()[0].passed());
489 }
490
491 #[test]
492 fn a_command_that_cannot_run_is_a_failure_not_a_pass() {
493 let refusal = run_checks(
494 &spec(&[("missing", "definitely-not-a-real-binary-xyz", true)]),
495 Path::new("."),
496 &mut ignore,
497 )
498 .expect_err("must refuse");
499 assert!(matches!(refusal, Refusal::ChecksFailed { .. }));
500 }
501
502 fn ignore(_: &CheckRecord) {}
504
505 #[test]
506 fn each_check_is_reported_as_it_finishes_rather_than_all_at_the_end() {
507 let mut seen: Vec<String> = Vec::new();
510 let refusal = run_checks(
511 &spec(&[
512 ("first", "true", true),
513 ("second", "exit 1", true),
514 ("third", "true", true),
515 ]),
516 Path::new("."),
517 &mut |record| seen.push(format!("{} {}", record.name, record.passed())),
518 )
519 .expect_err("the second check fails");
520
521 assert_eq!(seen, ["first true", "second false", "third true"]);
522 assert!(matches!(refusal, Refusal::ChecksFailed { .. }));
525 }
526
527 fn passing_checks() -> AllChecksPassed {
528 run_checks(&spec(&[("ok", "true", true)]), Path::new("."), &mut ignore).expect("passes")
529 }
530
531 #[test]
532 fn the_author_cannot_approve_their_own_change() {
533 let archon = ActorId::new("archon");
534 let refusal = evaluate(
535 passing_checks(),
536 &archon,
537 &Approval {
538 reviewer: archon.clone(),
539 verdict: Verdict::Approve,
540 },
541 true,
542 )
543 .expect_err("self-approval must be refused");
544 assert_eq!(refusal, Refusal::SelfApproval { actor: archon });
545 }
546
547 #[test]
548 fn an_independent_approval_mints_a_token() {
549 let token = evaluate(
550 passing_checks(),
551 &ActorId::new("archon"),
552 &Approval {
553 reviewer: ActorId::new("ephor"),
554 verdict: Verdict::Approve,
555 },
556 true,
557 )
558 .expect("independent approval");
559 assert_eq!(token.author().as_str(), "archon");
560 assert_eq!(token.reviewer().as_str(), "ephor");
561 assert_eq!(token.checks().len(), 1);
562 }
563
564 #[test]
565 fn a_rejection_mints_nothing() {
566 let refusal = evaluate(
567 passing_checks(),
568 &ActorId::new("archon"),
569 &Approval {
570 reviewer: ActorId::new("ephor"),
571 verdict: Verdict::Reject {
572 reason: "no tests".into(),
573 },
574 },
575 true,
576 )
577 .expect_err("rejection");
578 assert_eq!(
579 refusal,
580 Refusal::Rejected {
581 reason: "no tests".to_string()
582 }
583 );
584 }
585}