1use async_trait::async_trait;
19use harness_core::{
20 Block, Context, Execution, Guide, GuideError, GuideId, GuideScope, Turn, TurnRole, World,
21};
22use harness_tools::shell::JobEvent;
23use std::sync::{Arc, Mutex};
24use std::time::Duration;
25
26#[derive(Debug, Clone)]
29#[non_exhaustive]
30pub enum HostEvent {
31 Input(String),
33 JobExited {
35 id: u64,
36 program: String,
37 exit_status: i32,
38 running_for: Duration,
39 tail: String,
40 },
41 JobOverdue {
43 id: u64,
44 program: String,
45 running_for: Duration,
46 tail: String,
47 },
48}
49
50impl From<JobEvent> for HostEvent {
51 fn from(ev: JobEvent) -> Self {
52 match ev {
53 JobEvent::Exited {
54 id,
55 program,
56 exit_status,
57 running_for,
58 tail,
59 } => HostEvent::JobExited {
60 id,
61 program,
62 exit_status,
63 running_for,
64 tail,
65 },
66 JobEvent::Overdue {
67 id,
68 program,
69 running_for,
70 tail,
71 } => HostEvent::JobOverdue {
72 id,
73 program,
74 running_for,
75 tail,
76 },
77 other => HostEvent::Input(format!("[unrecognised job event: {other:?}]")),
80 }
81 }
82}
83
84impl HostEvent {
85 pub fn headline(&self) -> Option<String> {
88 match self {
89 HostEvent::JobExited {
90 id,
91 program,
92 exit_status,
93 running_for,
94 ..
95 } => Some(format!(
96 "job {id} ({program}) exited {exit_status} after {}",
97 fmt_elapsed(*running_for)
98 )),
99 HostEvent::JobOverdue {
100 id,
101 program,
102 running_for,
103 ..
104 } => Some(format!(
105 "job {id} ({program}) still running after {}",
106 fmt_elapsed(*running_for)
107 )),
108 _ => None,
109 }
110 }
111
112 pub fn notice(&self) -> Option<String> {
116 let (head, tail, ask) = match self {
117 HostEvent::JobExited {
118 tail, exit_status, ..
119 } => (
120 self.headline()?,
121 tail,
122 if *exit_status == 0 {
123 "Tell the user the result.".to_string()
124 } else {
125 "Tell the user it failed and why, from the output.".to_string()
126 },
127 ),
128 HostEvent::JobOverdue { id, tail, .. } => (
129 self.headline()?,
130 tail,
131 format!(
133 "Check it with shell_job_status (id {id}); tell the user whether it is \
134 progressing, and kill it with shell_job_kill if it is stuck."
135 ),
136 ),
137 _ => return None,
138 };
139 let mut s = format!("[background job notice] {head}.");
140 if !tail.trim().is_empty() {
141 s.push_str(&format!("\nLast output:\n{}", tail.trim_end()));
142 }
143 s.push('\n');
144 s.push_str(&ask);
145 Some(s)
146 }
147}
148
149pub fn fmt_elapsed(d: Duration) -> String {
151 let s = d.as_secs();
152 match s {
153 0..=59 => format!("{s}s"),
154 60..=3599 => format!("{}m", s / 60),
155 _ => format!("{}h{:02}m", s / 3600, (s % 3600) / 60),
156 }
157}
158
159pub struct NoticeGuide {
165 id: GuideId,
166 scope: GuideScope,
167 state: Mutex<NoticeState>,
168}
169
170#[derive(Default)]
171struct NoticeState {
172 pending: Vec<String>,
173 delivered: Vec<String>,
177}
178
179impl NoticeGuide {
180 pub fn new() -> Arc<Self> {
181 Arc::new(Self {
182 id: "host-notices".into(),
183 scope: GuideScope::Always,
184 state: Mutex::new(NoticeState::default()),
185 })
186 }
187
188 pub fn push(&self, text: impl Into<String>) {
190 self.state.lock().unwrap().pending.push(text.into());
191 }
192
193 pub fn has_pending(&self) -> bool {
196 !self.state.lock().unwrap().pending.is_empty()
197 }
198
199 pub fn take_pending(&self) -> Vec<String> {
201 std::mem::take(&mut self.state.lock().unwrap().pending)
202 }
203
204 pub fn take_delivered(&self) -> Vec<String> {
206 std::mem::take(&mut self.state.lock().unwrap().delivered)
207 }
208}
209
210#[async_trait]
211impl Guide for NoticeGuide {
212 fn id(&self) -> &GuideId {
213 &self.id
214 }
215 fn kind(&self) -> Execution {
216 Execution::Computational
217 }
218 fn scope(&self) -> &GuideScope {
219 &self.scope
220 }
221 async fn apply(&self, _ctx: &mut Context, _w: &World) -> Result<(), GuideError> {
222 Ok(())
223 }
224 async fn apply_before_iter(&self, ctx: &mut Context, _w: &World) -> Result<(), GuideError> {
225 let mut st = self.state.lock().unwrap();
226 if st.pending.is_empty() {
227 return Ok(());
228 }
229 let notices = std::mem::take(&mut st.pending);
230 ctx.history.push(Turn {
231 role: TurnRole::User,
232 blocks: vec![Block::Text(notices.join("\n\n"))],
233 });
234 st.delivered.extend(notices);
235 Ok(())
236 }
237}
238
239#[cfg(test)]
240mod tests {
241 use super::*;
242 use harness_core::Task;
243
244 fn ctx() -> Context {
245 Context::new(Task {
246 description: "t".into(),
247 source: None,
248 deadline: None,
249 })
250 }
251
252 fn exited(code: i32) -> HostEvent {
253 HostEvent::JobExited {
254 id: 3,
255 program: "gh".into(),
256 exit_status: code,
257 running_for: Duration::from_secs(21 * 60 + 5),
258 tail: "all checks passed\n".into(),
259 }
260 }
261
262 #[test]
263 fn headline_is_one_short_line() {
264 assert_eq!(
265 exited(0).headline().unwrap(),
266 "job 3 (gh) exited 0 after 21m"
267 );
268 assert_eq!(HostEvent::Input("hi".into()).headline(), None);
269 assert_eq!(fmt_elapsed(Duration::from_secs(45)), "45s");
270 assert_eq!(fmt_elapsed(Duration::from_secs(3900)), "1h05m");
271 }
272
273 #[test]
274 fn notice_carries_the_tail_and_what_to_do() {
275 let n = exited(1).notice().unwrap();
276 assert!(n.contains("job 3 (gh) exited 1"));
277 assert!(n.contains("all checks passed"));
278 assert!(n.contains("failed"));
279 let overdue = HostEvent::JobOverdue {
280 id: 7,
281 program: "make".into(),
282 running_for: Duration::from_secs(600),
283 tail: String::new(),
284 };
285 let n = overdue.notice().unwrap();
286 assert!(n.contains("shell_job_status (id 7)"));
287 assert!(!n.contains("Last output"));
288 assert!(HostEvent::Input("x".into()).notice().is_none());
289 }
290
291 #[tokio::test]
292 async fn guide_appends_one_user_turn_and_only_when_pending() {
293 let g = NoticeGuide::new();
294 let w = harness_context::default_world(".");
295 let mut c = ctx();
296 c.history.push(Turn {
297 role: TurnRole::User,
298 blocks: vec![Block::Text("earlier".into())],
299 });
300
301 g.apply_before_iter(&mut c, &w).await.unwrap();
302 assert_eq!(c.history.len(), 1, "nothing pending, nothing appended");
303
304 g.push("one");
305 g.push("two");
306 assert!(g.has_pending());
307 g.apply_before_iter(&mut c, &w).await.unwrap();
308 assert_eq!(c.history.len(), 2);
309 assert_eq!(c.history[0].blocks.len(), 1, "earlier history untouched");
310 assert!(matches!(c.history[1].role, TurnRole::User));
311 let Block::Text(t) = &c.history[1].blocks[0] else {
312 panic!("expected text")
313 };
314 assert!(t.contains("one") && t.contains("two"));
315 assert!(!g.has_pending());
316 assert_eq!(g.take_delivered(), vec!["one", "two"]);
317 assert!(g.take_delivered().is_empty());
318 }
319}