#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)]
pub enum Verdict {
Failed,
Blocked,
Done,
Running,
Context,
}
impl Verdict {
pub fn glyph(self) -> &'static str {
match self {
Verdict::Failed => "✗",
Verdict::Blocked => "⏸",
Verdict::Done => "✓",
Verdict::Running => "●",
Verdict::Context => "·",
}
}
pub fn rank(self) -> u8 {
match self {
Verdict::Blocked => 5, Verdict::Failed => 4,
Verdict::Running => 3,
Verdict::Done => 2,
Verdict::Context => 1,
}
}
}
pub struct ReportRow {
pub verdict: Verdict,
pub tab: String,
pub text: String,
pub ago_secs: Option<u64>,
pub dur_secs: Option<u64>,
pub term_id: Option<crate::terminal::TermId>,
pub cwd: Option<String>,
pub exit: Option<i32>,
pub error_excerpt: Option<String>,
pub settling: bool,
}
impl ReportRow {
pub fn evidence(&self) -> String {
let mut s = format!("{} {}", self.verdict.glyph(), self.text);
if !self.tab.is_empty() {
s = format!("[{}] {s}", self.tab);
}
if let Some(d) = self.dur_secs.filter(|d| *d > 0) {
s.push_str(&format!(" (ran {})", fmt_secs(d)));
}
if let Some(x) = self.exit {
s.push_str(&format!(" [exit {x}]"));
}
if let Some(e) = &self.error_excerpt {
let one = e.lines().next().unwrap_or("").trim();
if !one.is_empty() {
s.push_str(&format!(" — {}", one.chars().take(160).collect::<String>()));
}
}
s
}
}
pub struct ShiftReport {
pub away_secs: u64,
pub mission: Option<String>,
pub rows: Vec<ReportRow>,
pub suggestion: Option<String>,
pub narrative: String,
pub narrative_streaming: bool,
pub narrative_from_model: bool,
pub facts: String,
pub stream_started_at: Option<std::time::Instant>,
pub shown_at: std::time::Instant,
}
pub const BRIEF_LOADING: &[&str] = &["booting", "syncing", "triaging", "collating", "briefing"];
pub const LOADING_FLASH_MS: u128 = 200;
pub struct Triage {
pub verdict: Verdict,
pub text: String,
pub ambiguous: bool,
}
const FAIL_MARKS: &[&str] = &[
"error:", "error[", "failed", "failure", "panic", "traceback", "out of memory",
"oom", "killed", "segmentation fault", "command not found", "fatal:",
];
const BLOCKED_MARKS: &[&str] = &[
"[y/n]", "(y/n)", "[y/n]:", "continue?", "password:", "passphrase",
"are you sure", "waiting for", "press enter", "(yes/no)", "proceed?",
];
const PROGRESS_MARKS: &[&str] = &["it/s", "eta ", "step ", "epoch ", "%|", "██"];
pub fn triage(tail: &str, exit: Option<i32>, running: bool) -> Triage {
let low = tail.to_lowercase();
let last_lines: String = low.lines().rev().take(6).collect::<Vec<_>>().join("\n");
match exit {
Some(0) => {
return Triage {
verdict: Verdict::Done,
text: "exited clean".into(),
ambiguous: FAIL_MARKS.iter().any(|m| last_lines.contains(m)),
};
}
Some(code) => {
let named = match code {
130 => " (interrupted)".to_string(),
137 => " (killed — OOM?)".to_string(),
139 => " (segfault)".to_string(),
_ => String::new(),
};
return Triage {
verdict: Verdict::Failed,
text: format!("exited {code}{named}"),
ambiguous: true, };
}
None => {}
}
if BLOCKED_MARKS.iter().any(|m| last_lines.contains(m))
|| last_lines.trim_end().ends_with('?')
{
return Triage {
verdict: Verdict::Blocked,
text: "waiting on your input".into(),
ambiguous: false,
};
}
if FAIL_MARKS.iter().any(|m| last_lines.contains(m)) {
return Triage {
verdict: Verdict::Failed,
text: "failure in output".into(),
ambiguous: true,
};
}
if running && PROGRESS_MARKS.iter().any(|m| low.contains(m)) {
return Triage {
verdict: Verdict::Running,
text: "still running".into(),
ambiguous: false,
};
}
if running {
return Triage {
verdict: Verdict::Running,
text: "quiet".into(),
ambiguous: true,
};
}
Triage { verdict: Verdict::Done, text: "finished".into(), ambiguous: true }
}
pub fn classify(text: &str, default: Verdict) -> Verdict {
let low = text.trim().to_lowercase();
if low.starts_with("blocked") || low.contains("waiting on your input") {
Verdict::Blocked
} else if low.starts_with("failed") || low.starts_with("✗") {
Verdict::Failed
} else if low.starts_with("done") {
Verdict::Done
} else if low.contains("fail") || low.contains("error") {
Verdict::Failed
} else {
default
}
}
pub fn is_noteworthy(tail: &str, exit: Option<i32>) -> bool {
match exit {
Some(0) => false,
Some(_) => true,
None => {
let low = tail.to_lowercase();
let last6: String = low.lines().rev().take(6).collect::<Vec<_>>().join("\n");
FAIL_MARKS.iter().any(|m| last6.contains(m))
|| BLOCKED_MARKS.iter().any(|m| last6.contains(m))
|| last6.trim_end().ends_with('?')
}
}
}
pub const BOOT_TOTAL_MS: u128 = 520;
pub const GOODNEWS_SECS: u64 = 60;
pub struct Reveal {
pub rows: usize,
pub signoff: bool,
}
pub fn reveal_at(elapsed_ms: u128, n_rows: usize) -> Reveal {
let rows = if elapsed_ms >= 360 {
(((elapsed_ms - 360) / 40) as usize + 1).min(n_rows)
} else {
0
};
Reveal { rows, signoff: elapsed_ms >= BOOT_TOTAL_MS }
}
pub fn fmt_clock(s: u64) -> String {
let (d, h, m, sec) = (s / 86_400, (s % 86_400) / 3600, (s % 3600) / 60, s % 60);
if d > 0 {
format!("{d}:{h:02}:{m:02}:{sec:02}")
} else {
format!("{h:02}:{m:02}:{sec:02}")
}
}
pub fn fmt_secs(s: u64) -> String {
match s {
0..=59 => format!("{s}s"),
60..=3599 => format!("{}m{:02}s", s / 60, s % 60),
3600..=86_399 => format!("{}h{:02}m", s / 3600, (s % 3600) / 60),
_ => format!("{}d{}h", s / 86_400, (s % 86_400) / 3600),
}
}
impl ShiftReport {
pub fn sort_rows(&mut self) {
self.rows.sort_by_key(|r| r.verdict);
}
pub fn deterministic_narrative(&self) -> String {
let n = |v: Verdict| self.rows.iter().filter(|r| r.verdict == v).count();
let (failed, blocked, done, running) =
(n(Verdict::Failed), n(Verdict::Blocked), n(Verdict::Done), n(Verdict::Running));
let mut parts = Vec::new();
if failed > 0 {
parts.push(format!("{failed} failed"));
}
if blocked > 0 {
parts.push(format!("{blocked} waiting on you"));
}
if done > 0 {
parts.push(format!("{done} finished clean"));
}
if running > 0 {
parts.push(format!("{running} still running"));
}
let away = fmt_secs(self.away_secs);
if parts.is_empty() {
format!("Welcome back — nothing needs you after {away} away.")
} else if failed > 0 || blocked > 0 {
format!("Welcome back, captain. {} away — {}.", away, parts.join(", "))
} else {
format!("Welcome back. {} away — {}.", away, parts.join(", "))
}
}
}