use chrono::{DateTime, Duration, Utc};
use serde::Serialize;
use octl_core::AwaitingInput;
pub const GRACE_SECS: i64 = 180;
pub const GRACE_ENV: &str = "OCTL_AWAITING_INPUT_GRACE_SECS";
pub const AWAITING_INPUT_REASON: &str = "worker is awaiting a human decision";
#[must_use]
pub fn grace() -> Duration {
let secs = std::env::var(GRACE_ENV)
.ok()
.and_then(|v| v.trim().parse::<i64>().ok())
.filter(|v| *v >= 0)
.unwrap_or(GRACE_SECS);
Duration::seconds(secs)
}
#[must_use]
pub fn is_escalated(opened_at: DateTime<Utc>, now: DateTime<Utc>) -> bool {
now.signed_duration_since(opened_at) > grace()
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct AwaitingInputView {
pub event_seq: u64,
pub opened_at: DateTime<Utc>,
pub pending_age_secs: i64,
pub escalated: bool,
pub open_discussion_count: usize,
pub discussion_items: Vec<serde_json::Value>,
}
impl AwaitingInputView {
#[must_use]
pub fn build(open: &AwaitingInput, now: DateTime<Utc>) -> Self {
Self {
event_seq: open.event_seq,
opened_at: open.opened_at,
pending_age_secs: now
.signed_duration_since(open.opened_at)
.num_seconds()
.max(0),
escalated: is_escalated(open.opened_at, now),
open_discussion_count: open.discussion_items.len(),
discussion_items: open.discussion_items.clone(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn grace_boundary_is_strict() {
let opened: DateTime<Utc> = "2026-08-16T12:00:00Z".parse().unwrap();
let boundary = opened + Duration::seconds(GRACE_SECS);
assert!(!is_escalated(opened, boundary));
assert!(is_escalated(opened, boundary + Duration::seconds(1)));
}
#[test]
fn view_preserves_discussion_and_clamps_clock_skew() {
let opened: DateTime<Utc> = "2026-08-16T12:00:00Z".parse().unwrap();
let open = AwaitingInput {
opened_at: opened,
event_seq: 7,
discussion_items: vec![json!({
"topic": "Which API?",
"options": ["small", "large"],
"recommended_default": "small"
})],
};
let view = AwaitingInputView::build(&open, opened - Duration::seconds(1));
assert_eq!(view.pending_age_secs, 0);
assert_eq!(view.open_discussion_count, 1);
assert_eq!(view.discussion_items, open.discussion_items);
}
}