use async_trait::async_trait;
use harness_core::{
Block, Context, Execution, Guide, GuideError, GuideId, GuideScope, Turn, TurnRole, World,
};
use harness_tools::shell::JobEvent;
use std::sync::{Arc, Mutex};
use std::time::Duration;
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum HostEvent {
Input(String),
JobExited {
id: u64,
program: String,
exit_status: i32,
running_for: Duration,
tail: String,
},
JobOverdue {
id: u64,
program: String,
running_for: Duration,
tail: String,
},
}
impl From<JobEvent> for HostEvent {
fn from(ev: JobEvent) -> Self {
match ev {
JobEvent::Exited {
id,
program,
exit_status,
running_for,
tail,
} => HostEvent::JobExited {
id,
program,
exit_status,
running_for,
tail,
},
JobEvent::Overdue {
id,
program,
running_for,
tail,
} => HostEvent::JobOverdue {
id,
program,
running_for,
tail,
},
other => HostEvent::Input(format!("[unrecognised job event: {other:?}]")),
}
}
}
impl HostEvent {
pub fn headline(&self) -> Option<String> {
match self {
HostEvent::JobExited {
id,
program,
exit_status,
running_for,
..
} => Some(format!(
"job {id} ({program}) exited {exit_status} after {}",
fmt_elapsed(*running_for)
)),
HostEvent::JobOverdue {
id,
program,
running_for,
..
} => Some(format!(
"job {id} ({program}) still running after {}",
fmt_elapsed(*running_for)
)),
_ => None,
}
}
pub fn notice(&self) -> Option<String> {
let (head, tail, ask) = match self {
HostEvent::JobExited {
tail, exit_status, ..
} => (
self.headline()?,
tail,
if *exit_status == 0 {
"Tell the user the result.".to_string()
} else {
"Tell the user it failed and why, from the output.".to_string()
},
),
HostEvent::JobOverdue { id, tail, .. } => (
self.headline()?,
tail,
format!(
"Check it with shell_job_status (id {id}); tell the user whether it is \
progressing, and kill it with shell_job_kill if it is stuck."
),
),
_ => return None,
};
let mut s = format!("[background job notice] {head}.");
if !tail.trim().is_empty() {
s.push_str(&format!("\nLast output:\n{}", tail.trim_end()));
}
s.push('\n');
s.push_str(&ask);
Some(s)
}
}
pub fn fmt_elapsed(d: Duration) -> String {
let s = d.as_secs();
match s {
0..=59 => format!("{s}s"),
60..=3599 => format!("{}m", s / 60),
_ => format!("{}h{:02}m", s / 3600, (s % 3600) / 60),
}
}
pub struct NoticeGuide {
id: GuideId,
scope: GuideScope,
state: Mutex<NoticeState>,
}
#[derive(Default)]
struct NoticeState {
pending: Vec<String>,
delivered: Vec<String>,
}
impl NoticeGuide {
pub fn new() -> Arc<Self> {
Arc::new(Self {
id: "host-notices".into(),
scope: GuideScope::Always,
state: Mutex::new(NoticeState::default()),
})
}
pub fn push(&self, text: impl Into<String>) {
self.state.lock().unwrap().pending.push(text.into());
}
pub fn has_pending(&self) -> bool {
!self.state.lock().unwrap().pending.is_empty()
}
pub fn take_pending(&self) -> Vec<String> {
std::mem::take(&mut self.state.lock().unwrap().pending)
}
pub fn take_delivered(&self) -> Vec<String> {
std::mem::take(&mut self.state.lock().unwrap().delivered)
}
}
#[async_trait]
impl Guide for NoticeGuide {
fn id(&self) -> &GuideId {
&self.id
}
fn kind(&self) -> Execution {
Execution::Computational
}
fn scope(&self) -> &GuideScope {
&self.scope
}
async fn apply(&self, _ctx: &mut Context, _w: &World) -> Result<(), GuideError> {
Ok(())
}
async fn apply_before_iter(&self, ctx: &mut Context, _w: &World) -> Result<(), GuideError> {
let mut st = self.state.lock().unwrap();
if st.pending.is_empty() {
return Ok(());
}
let notices = std::mem::take(&mut st.pending);
ctx.history.push(Turn {
role: TurnRole::User,
blocks: vec![Block::Text(notices.join("\n\n"))],
});
st.delivered.extend(notices);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use harness_core::Task;
fn ctx() -> Context {
Context::new(Task {
description: "t".into(),
source: None,
deadline: None,
})
}
fn exited(code: i32) -> HostEvent {
HostEvent::JobExited {
id: 3,
program: "gh".into(),
exit_status: code,
running_for: Duration::from_secs(21 * 60 + 5),
tail: "all checks passed\n".into(),
}
}
#[test]
fn headline_is_one_short_line() {
assert_eq!(
exited(0).headline().unwrap(),
"job 3 (gh) exited 0 after 21m"
);
assert_eq!(HostEvent::Input("hi".into()).headline(), None);
assert_eq!(fmt_elapsed(Duration::from_secs(45)), "45s");
assert_eq!(fmt_elapsed(Duration::from_secs(3900)), "1h05m");
}
#[test]
fn notice_carries_the_tail_and_what_to_do() {
let n = exited(1).notice().unwrap();
assert!(n.contains("job 3 (gh) exited 1"));
assert!(n.contains("all checks passed"));
assert!(n.contains("failed"));
let overdue = HostEvent::JobOverdue {
id: 7,
program: "make".into(),
running_for: Duration::from_secs(600),
tail: String::new(),
};
let n = overdue.notice().unwrap();
assert!(n.contains("shell_job_status (id 7)"));
assert!(!n.contains("Last output"));
assert!(HostEvent::Input("x".into()).notice().is_none());
}
#[tokio::test]
async fn guide_appends_one_user_turn_and_only_when_pending() {
let g = NoticeGuide::new();
let w = harness_context::default_world(".");
let mut c = ctx();
c.history.push(Turn {
role: TurnRole::User,
blocks: vec![Block::Text("earlier".into())],
});
g.apply_before_iter(&mut c, &w).await.unwrap();
assert_eq!(c.history.len(), 1, "nothing pending, nothing appended");
g.push("one");
g.push("two");
assert!(g.has_pending());
g.apply_before_iter(&mut c, &w).await.unwrap();
assert_eq!(c.history.len(), 2);
assert_eq!(c.history[0].blocks.len(), 1, "earlier history untouched");
assert!(matches!(c.history[1].role, TurnRole::User));
let Block::Text(t) = &c.history[1].blocks[0] else {
panic!("expected text")
};
assert!(t.contains("one") && t.contains("two"));
assert!(!g.has_pending());
assert_eq!(g.take_delivered(), vec!["one", "two"]);
assert!(g.take_delivered().is_empty());
}
}