Skip to main content

ostraka_runtime/
gate.rs

1//! The gate, and the one type the orchestrator cannot forge.
2//!
3//! The structural rule is that whoever wrote a change cannot be the one who
4//! approves it. Stating that in documentation makes it a convention; stating it
5//! here makes it a fact about the program.
6//!
7//! [`MergeToken`] has private fields and no public constructor. The only way to
8//! obtain one is [`evaluate`], which requires both an [`AllChecksPassed`] —
9//! itself obtainable only by actually running the checks — and an approval whose
10//! reviewer differs from the change's author. An orchestrator holding every
11//! other type in this crate still cannot produce one.
12//!
13//! This is also why the gate is not a separate crate. Across a crate boundary it
14//! would have to be an injectable trait, and an injectable gate is a bypassable
15//! one — which is the failure the whole design exists to prevent.
16
17use 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/// Proof that every required check ran and passed.
25///
26/// Constructible only by [`run_checks`], in this module. It carries no data a
27/// caller could fabricate from elsewhere.
28#[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/// Permission to merge a change. Cannot be constructed outside this module.
40#[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/// Why a gate refused.
62///
63/// `ChecksFailed` carries the check records rather than only the names. A run
64/// that fails is exactly when the evidence matters most, so the refusal itself
65/// holds it and a caller cannot record the outcome without it.
66#[derive(Debug, PartialEq, Eq)]
67pub enum Refusal {
68    /// One or more required checks failed. Named so the reason is legible.
69    ChecksFailed {
70        failed: Vec<String>,
71        records: Vec<CheckRecord>,
72    },
73    /// The reviewer rejected the change.
74    Rejected { reason: String },
75    /// The authoring agent could not run, and left nothing behind.
76    ///
77    /// Distinct from a rejection: nothing was judged. A vendor that hit a rate
78    /// limit or an expired credential and a vendor that considered the task and
79    /// declined it are the same empty diff, and only one of them is about the
80    /// change. Its own words are carried so the difference is legible.
81    AuthorFailed {
82        code: String,
83        diagnostics: Option<String>,
84    },
85    /// The change touched paths the project's policy does not allow.
86    PolicyViolation { reason: String },
87    /// The worktree could not be made ready to work in.
88    ///
89    /// Nothing was judged and nothing was written: the environment was not
90    /// there. Reporting it as a failed check would say the change was rejected,
91    /// which is the confusion this variant exists to end.
92    SetupFailed { step: String, reason: String },
93    /// The operator stopped the run.
94    ///
95    /// Not a judgement on the change at all — nobody finished looking at it.
96    Interrupted,
97    /// The authoring agent outlived the ceiling and was killed.
98    ///
99    /// Not a rejection and not a crash: nothing was judged, and the change was
100    /// never finished. Reporting it as either would blame the work for the
101    /// clock.
102    TimedOut { after_secs: u64 },
103    /// The authoring agent ran cleanly and changed nothing.
104    ///
105    /// A legitimate answer to a task — the work was already done, or the agent
106    /// judged that nothing was needed — and not one a reviewer can rule on: it
107    /// would be handed an empty diff and asked what it thinks of it.
108    NoChange,
109    /// The reviewer and the author are the same identity.
110    SelfApproval { actor: ActorId },
111}
112
113/// Runs the project's checks in a worktree.
114///
115/// Returns the proof type only when every required check exited zero. Optional
116/// checks are recorded but do not block, so that a project can observe a check
117/// before it enforces one.
118pub 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        // Reported one at a time, as each finishes. The gate is usually the
130        // longest part of a run — four cargo checks on this repository take
131        // twelve seconds — and a caller that only learns the outcome at the end
132        // has nothing to show for those twelve seconds but a still screen.
133        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
153/// Runs one shell command in a worktree, bounded, capturing both streams.
154///
155/// Shared with worktree preparation, which needs exactly this and must not be
156/// reported as a gate check.
157pub 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    // A check that spawns helpers — a test runner forking workers, a build
167    // starting a server — leaves them behind when only the shell is killed.
168    #[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        // A check that could not be started has not passed. Recording the
185        // failure is the point; swallowing it would be the bug.
186        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
199/// Stops a check and everything it started.
200///
201/// The group's id is the shell's own pid, set by `process_group(0)` above.
202/// `libc` rather than a shelled-out `kill`: procps reads `-1234` as a pid, not
203/// a group, and signals the shell alone while reporting success — which leaves
204/// the workers behind that this is here to collect.
205fn stop(child: &mut std::process::Child) {
206    #[cfg(unix)]
207    // SAFETY: a signal call with no memory contract. A group that has already
208    // exited answers `ESRCH`, which is the outcome being asked for.
209    unsafe {
210        libc::killpg(child.id() as libc::pid_t, libc::SIGTERM);
211    }
212    // Always, and last: a check ignoring TERM still has to stop.
213    let _ = child.kill();
214}
215
216/// Runs a check to completion, or kills it for outlasting the ceiling.
217///
218/// Both streams are read on their own threads: a check that fills a pipe buffer
219/// while this thread waits on the other one deadlocks, and a build that prints
220/// a lot is the ordinary case rather than the exotic one.
221fn 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    // After a kill, take what already arrived rather than waiting for the
261    // streams to close: killing a shell does not kill what it spawned, and a
262    // surviving grandchild holds these pipes open. Waiting for EOF here would
263    // wait on exactly the process the ceiling just stopped waiting for.
264    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        // Said in the record rather than left as a bare signal death, which
276        // reads like the check crashed on its own.
277        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    // A killed process reports no code, which is already how "did not pass" is
283    // spelled everywhere else here.
284    (status.and_then(|s| s.code()), stdout, stderr)
285}
286
287/// The gate itself: the only path to a [`MergeToken`].
288pub 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
312/// The gate again, from a finished run's own evidence.
313///
314/// A run mints its token in memory and the token dies with the process, so
315/// promoting an approved change later has to ask the same question a second
316/// time. It is asked here, in the module that owns the answer: nothing outside
317/// gains a way to build a [`MergeToken`], and a run that was refused cannot be
318/// promoted by a caller that decides it disagrees.
319///
320/// The evidence is the run record, which is not inside the worktree. An agent
321/// works in a worktree; the records live above it, so a fleet cannot write its
322/// own approval. A person with a text editor can — and that same person can
323/// commit anything they like directly. This gate is between the fleet and the
324/// branch, not between the operator and their own repository.
325///
326/// Required checks are read from the project's current spec, not from the
327/// record: a check added since the run was made has never passed, and a record
328/// that predates it must not promote as though it had.
329pub 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        // A wedged test suite otherwise wedges the gate, and the gate is what
392        // every run waits on.
393        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        // Killing the shell does not kill what it forked. A test runner's
417        // workers and a build's servers outlive the check that started them,
418        // and go on holding the ports and the CPU the ceiling was meant to
419        // release.
420        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                // The evidence travels with the refusal.
472                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    /// For the tests that are not about who is watching.
503    fn ignore(_: &CheckRecord) {}
504
505    #[test]
506    fn each_check_is_reported_as_it_finishes_rather_than_all_at_the_end() {
507        // The gate is the longest part of a run. A caller that learns the
508        // outcome only at the end has nothing to show for that time.
509        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        // Reported even for the run that is about to be refused: the failing
523        // check's output is the thing someone is waiting to read.
524        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}