use anyhow::Result;
use serde::{Deserialize, Serialize};
use crate::db;
use crate::paths::state::StateLayout;
const WARNING_THRESHOLD: u32 = 2;
const CRITICAL_THRESHOLD: u32 = 3;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum AttemptOutcome {
Progress,
NoProgress,
Neutral,
}
impl AttemptOutcome {
pub(crate) fn as_str(self) -> &'static str {
match self {
Self::Progress => "progress",
Self::NoProgress => "no_progress",
Self::Neutral => "neutral",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct WorkStreamDecayState {
pub(crate) session_id: String,
pub(crate) consecutive_no_progress: u32,
pub(crate) last_outcome: String,
pub(crate) updated_at_epoch_s: u64,
pub(crate) last_progress_at_epoch_s: Option<u64>,
}
#[derive(Debug, Clone, Serialize)]
pub(crate) struct WorkStreamDecayView {
pub(crate) status: &'static str,
pub(crate) consecutive_no_progress: u32,
pub(crate) warning_threshold: u32,
pub(crate) critical_threshold: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) last_outcome: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) updated_at_epoch_s: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) last_progress_at_epoch_s: Option<u64>,
pub(crate) recommendation: &'static str,
}
impl WorkStreamDecayView {
pub(crate) fn missing() -> Self {
Self {
status: "missing",
consecutive_no_progress: 0,
warning_threshold: WARNING_THRESHOLD,
critical_threshold: CRITICAL_THRESHOLD,
last_outcome: None,
updated_at_epoch_s: None,
last_progress_at_epoch_s: None,
recommendation: "continue",
}
}
}
pub(crate) fn load_view_from_db(
db: Option<&db::StateDb>,
active_session_id: Option<&str>,
) -> Result<WorkStreamDecayView> {
let Some(db) = db else {
return Ok(WorkStreamDecayView::missing());
};
let Some(state) = db::work_stream_decay::read(db.conn())? else {
return Ok(WorkStreamDecayView::missing());
};
if active_session_id.is_none() {
return Ok(view_for_state("stale", &state));
}
if Some(state.session_id.as_str()) != active_session_id {
return Ok(view_for_state("stale", &state));
}
Ok(view_for_state(
classify_status(state.consecutive_no_progress),
&state,
))
}
pub(crate) fn record_attempt_outcome(
layout: &StateLayout,
session_id: &str,
outcome: AttemptOutcome,
observed_at_epoch_s: u64,
) -> Result<WorkStreamDecayView> {
let pods_root = layout.pods_root();
let db = db::StateDb::open(&layout.state_db_path(), Some(&pods_root))?;
let existing = db::work_stream_decay::read(db.conn())?;
let mut next = match existing {
Some(state) if state.session_id == session_id => state,
_ => WorkStreamDecayState {
session_id: session_id.to_owned(),
consecutive_no_progress: 0,
last_outcome: AttemptOutcome::Neutral.as_str().to_owned(),
updated_at_epoch_s: observed_at_epoch_s,
last_progress_at_epoch_s: None,
},
};
match outcome {
AttemptOutcome::Progress => {
next.consecutive_no_progress = 0;
next.last_progress_at_epoch_s = Some(observed_at_epoch_s);
}
AttemptOutcome::NoProgress => {
next.consecutive_no_progress = next.consecutive_no_progress.saturating_add(1);
}
AttemptOutcome::Neutral => {}
}
next.last_outcome = outcome.as_str().to_owned();
next.updated_at_epoch_s = observed_at_epoch_s;
db::work_stream_decay::write(db.conn(), &next)?;
Ok(view_for_state(
classify_status(next.consecutive_no_progress),
&next,
))
}
fn classify_status(consecutive_no_progress: u32) -> &'static str {
if consecutive_no_progress >= CRITICAL_THRESHOLD {
"critical"
} else if consecutive_no_progress >= WARNING_THRESHOLD {
"warning"
} else {
"clear"
}
}
fn view_for_state(status: &'static str, state: &WorkStreamDecayState) -> WorkStreamDecayView {
let recommendation = match status {
"critical" => "wrap_up_and_clear",
"warning" => "wrap_up_soon",
_ => "continue",
};
WorkStreamDecayView {
status,
consecutive_no_progress: state.consecutive_no_progress,
warning_threshold: WARNING_THRESHOLD,
critical_threshold: CRITICAL_THRESHOLD,
last_outcome: Some(state.last_outcome.clone()),
updated_at_epoch_s: Some(state.updated_at_epoch_s),
last_progress_at_epoch_s: state.last_progress_at_epoch_s,
recommendation,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::profile::ProfileName;
use tempfile::tempdir;
fn test_layout() -> StateLayout {
let temp = tempdir().unwrap();
let root = temp.keep();
StateLayout::new(
root.join(".ccd"),
root.join("repo/.git/ccd"),
ProfileName::new("main").unwrap(),
)
}
#[test]
fn no_progress_warns_after_two_attempts() {
let layout = test_layout();
let first =
record_attempt_outcome(&layout, "ses_1", AttemptOutcome::NoProgress, 10).unwrap();
assert_eq!(first.status, "clear");
let second =
record_attempt_outcome(&layout, "ses_1", AttemptOutcome::NoProgress, 20).unwrap();
assert_eq!(second.status, "warning");
assert_eq!(second.recommendation, "wrap_up_soon");
}
#[test]
fn progress_resets_counter() {
let layout = test_layout();
record_attempt_outcome(&layout, "ses_1", AttemptOutcome::NoProgress, 10).unwrap();
record_attempt_outcome(&layout, "ses_1", AttemptOutcome::NoProgress, 20).unwrap();
let reset = record_attempt_outcome(&layout, "ses_1", AttemptOutcome::Progress, 30).unwrap();
assert_eq!(reset.status, "clear");
assert_eq!(reset.consecutive_no_progress, 0);
}
}