Skip to main content

ostraka_runtime/
progress.rs

1//! Watching a run while it is still running.
2//!
3//! The run log is the record and is written either way. This is the
4//! shoulder-read: somewhere to send what is happening as it happens, so a
5//! screen can show a run in progress instead of a blank pane for the minute it
6//! takes.
7//!
8//! A watcher is handed a [`Step`] and returns nothing. That is the whole
9//! guarantee, and it is deliberate: watching cannot become steering, and a
10//! watcher that panics or blocks cannot change what the run decides. Sending is
11//! the watcher's problem — the shipped one drops what it cannot deliver,
12//! because a run must not stall on a screen that has gone away.
13
14use ostraka_core::gate::CheckRecord;
15use ostraka_core::record::Event;
16
17/// Which part of the pipeline a run is in.
18///
19/// Named for what is happening rather than numbered, because the numbers in
20/// the orchestrator's comments have already changed once.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum Phase {
23    /// Making the worktree the change will be written in.
24    Isolating,
25    /// Linking in what git ignores, and running the project's setup.
26    Preparing,
27    /// The agent that writes the change.
28    Authoring,
29    /// The project's own checks.
30    Gating,
31    /// A different vendor, reading the diff.
32    Reviewing,
33}
34
35impl Phase {
36    pub const ALL: [Phase; 5] = [
37        Phase::Isolating,
38        Phase::Preparing,
39        Phase::Authoring,
40        Phase::Gating,
41        Phase::Reviewing,
42    ];
43
44    pub fn title(self) -> &'static str {
45        match self {
46            Phase::Isolating => "isolate",
47            Phase::Preparing => "prepare",
48            Phase::Authoring => "author",
49            Phase::Gating => "gate",
50            Phase::Reviewing => "review",
51        }
52    }
53}
54
55/// One thing that happened, as it happened.
56#[derive(Debug, Clone)]
57pub enum Step {
58    /// A phase began. Everything after this belongs to it, until the next one.
59    Entered(Phase),
60    /// An agent said something, or the run recorded something about itself.
61    Said { phase: Phase, event: Event },
62    /// One gate check finished. Carried whole, because a failing check's output
63    /// is the thing worth reading and waiting for the record to be written
64    /// would mean waiting for the rest of the gate first.
65    Checked(CheckRecord),
66}
67
68/// Somewhere to send a run's steps while it is still running.
69pub trait Watcher: Send {
70    fn saw(&mut self, step: Step);
71}
72
73/// A watcher over a channel, which is the only kind anything here needs.
74///
75/// A failed send is dropped rather than reported. The receiver going away means
76/// whoever was watching has stopped watching; the run is still writing its log
77/// and still has to finish, and stalling it on a closed channel would make a
78/// closed window able to break a run.
79pub struct Channel(pub std::sync::mpsc::Sender<Step>);
80
81impl Watcher for Channel {
82    fn saw(&mut self, step: Step) {
83        let _ = self.0.send(step);
84    }
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90    use std::sync::mpsc;
91
92    #[test]
93    fn a_channel_watcher_passes_steps_through_in_order() {
94        let (tx, rx) = mpsc::channel();
95        let mut watcher = Channel(tx);
96        watcher.saw(Step::Entered(Phase::Authoring));
97        watcher.saw(Step::Entered(Phase::Gating));
98
99        let seen: Vec<Phase> = rx
100            .try_iter()
101            .filter_map(|s| match s {
102                Step::Entered(p) => Some(p),
103                _ => None,
104            })
105            .collect();
106        assert_eq!(seen, [Phase::Authoring, Phase::Gating]);
107    }
108
109    #[test]
110    fn a_watcher_whose_receiver_is_gone_does_not_fail_the_run() {
111        // A closed window must not be able to break a run that is still
112        // writing its log.
113        let (tx, rx) = mpsc::channel();
114        drop(rx);
115        let mut watcher = Channel(tx);
116        watcher.saw(Step::Entered(Phase::Isolating));
117    }
118
119    #[test]
120    fn every_phase_is_named() {
121        for phase in Phase::ALL {
122            assert!(!phase.title().is_empty(), "{phase:?}");
123        }
124    }
125}