use chrono::{DateTime, Utc};
use octl_core::{Status, WorkerExit};
#[must_use]
pub fn is_attention_required(node_status: Status, worker_exit: Option<&WorkerExit>) -> bool {
matches!(node_status, Status::Pending | Status::Running)
&& worker_exit.is_some_and(|e| e.is_clean())
}
#[must_use]
pub fn resume_hint(run_id: &str, _worktree_path: Option<&str>) -> String {
let q_run = shell_single_quote(run_id);
format!(
"worker finished but skipped `run merge`; finish it with \
`orchestratectl run salvage {q_run}` (verifies the prior worker is gone — or fences \
it with `--fence` — then merges from its worktree), or `run cancel {q_run}` to lay it \
to rest"
)
}
fn shell_single_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', "'\\''"))
}
#[derive(serde::Serialize, Debug, Clone, PartialEq, Eq)]
pub struct AttentionView {
pub reason: &'static str,
pub pending_age_secs: i64,
pub exited_at: DateTime<Utc>,
pub worker_pid: Option<i32>,
pub worktree_path: Option<String>,
pub source_branch: Option<String>,
pub resume_hint: String,
}
pub const ATTENTION_REASON: &str = "worker exited cleanly without running `run merge`";
impl AttentionView {
#[must_use]
pub fn build(
run_id: &str,
now: DateTime<Utc>,
worker_exit: &WorkerExit,
worker_pid: Option<i32>,
worktree_path: Option<String>,
source_branch: Option<String>,
) -> Self {
let exited_at = worker_exit.at;
let pending_age_secs = now.signed_duration_since(exited_at).num_seconds().max(0);
let resume_hint = resume_hint(run_id, worktree_path.as_deref());
Self {
reason: ATTENTION_REASON,
pending_age_secs,
exited_at,
worker_pid,
worktree_path,
source_branch,
resume_hint,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn exit(code: Option<i32>, signal: Option<i32>) -> WorkerExit {
exit_at(code, signal, Utc::now())
}
fn exit_at(code: Option<i32>, signal: Option<i32>, at: DateTime<Utc>) -> WorkerExit {
WorkerExit { code, signal, at }
}
#[test]
fn clean_exit_working_node_is_attention() {
for status in [Status::Pending, Status::Running] {
assert!(
is_attention_required(status, Some(&exit(Some(0), None))),
"status {status:?} with a clean exit must be attention-required"
);
}
}
#[test]
fn terminal_node_is_not_attention() {
for status in [Status::Done, Status::Failed, Status::Cancelled] {
assert!(
!is_attention_required(status, Some(&exit(Some(0), None))),
"terminal status {status:?} must not be attention-required"
);
}
}
#[test]
fn blocked_node_is_not_attention() {
assert!(!is_attention_required(
Status::Blocked,
Some(&exit(Some(0), None))
));
}
#[test]
fn failing_exit_is_not_attention() {
assert!(!is_attention_required(
Status::Running,
Some(&exit(Some(2), None))
));
assert!(!is_attention_required(
Status::Running,
Some(&exit(None, Some(9)))
));
assert!(!is_attention_required(
Status::Running,
Some(&exit(Some(0), Some(15)))
));
}
#[test]
fn no_exit_is_not_attention() {
assert!(!is_attention_required(Status::Running, None));
assert!(!is_attention_required(Status::Pending, None));
}
#[test]
fn pending_age_anchors_on_worker_exit_and_clamps() {
let now: DateTime<Utc> = "2026-08-15T12:00:00Z".parse().unwrap();
let exited = exit_at(Some(0), None, "2026-08-15T11:30:00Z".parse().unwrap());
let v = AttentionView::build("r", now, &exited, None, None, None);
assert_eq!(v.pending_age_secs, 30 * 60);
assert_eq!(v.exited_at, exited.at);
let future = exit_at(Some(0), None, "2026-08-15T13:00:00Z".parse().unwrap());
let v = AttentionView::build("r", now, &future, None, None, None);
assert_eq!(v.pending_age_secs, 0);
}
#[test]
fn resume_hint_mentions_both_actions() {
for wt in [Some("/tmp/wt/foo"), None] {
let hint = resume_hint("01run", wt);
assert!(hint.contains("run salvage '01run'"), "got: {hint}");
assert!(hint.contains("run cancel '01run'"), "got: {hint}");
}
}
#[test]
fn resume_hint_quotes_hostile_ids() {
let hint = resume_hint("01'; rm -rf /", None);
assert!(hint.contains("'01'\\''; rm -rf /'"), "got: {hint}");
}
}