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}