harn-serve 0.10.121

Shared outbound workflow server core for Harn adapters
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};

use harn_vm::ModulePhaseStats;

use super::AcpBridge;

const PHASE: &str = "module_preparation";
const SCHEMA: &str = "harn.module_preparation.v1";
const MIN_ADVANCEMENT_INTERVAL: Duration = Duration::from_secs(1);

pub(super) struct ModuleProgressProjector {
    bridge: Arc<AcpBridge>,
    started: Instant,
    state: Mutex<ProjectionState>,
}

impl ModuleProgressProjector {
    pub(super) fn start(bridge: Arc<AcpBridge>) -> Arc<Self> {
        let projector = Arc::new(Self {
            bridge,
            started: Instant::now(),
            state: Mutex::new(ProjectionState::default()),
        });
        projector.send("started", ModulePhaseStats::default());
        projector
    }

    pub(super) fn advance(&self, stats: ModulePhaseStats) {
        let elapsed = self.started.elapsed();
        let should_emit = self
            .state
            .lock()
            .unwrap_or_else(|error| error.into_inner())
            .advance(elapsed, stats);
        if should_emit {
            self.send("advancing", stats);
        }
    }

    pub(super) fn finish(&self, stats: ModulePhaseStats) {
        self.send("completed", stats);
    }

    fn send(&self, state: &str, stats: ModulePhaseStats) {
        let progress = stats
            .modules_compiled
            .saturating_add(stats.modules_loaded)
            .min(i64::MAX as u64) as i64;
        self.bridge.send_progress(
            PHASE,
            match state {
                "started" => "Preparing modules",
                "completed" => "Modules prepared",
                _ => "Preparing modules",
            },
            Some(progress),
            None,
            Some(serde_json::json!({
                "schema": SCHEMA,
                "state": state,
                "module_compile_ms": stats.module_compile_ms,
                "module_load_ms": stats.module_load_ms,
                "modules_compiled": stats.modules_compiled,
                "modules_loaded": stats.modules_loaded,
            })),
        );
    }
}

#[derive(Default)]
struct ProjectionState {
    last_emitted_at: Duration,
    last_emitted: ModulePhaseStats,
    emitted_advancement: bool,
}

impl ProjectionState {
    fn advance(&mut self, now: Duration, stats: ModulePhaseStats) -> bool {
        let advanced = stats.modules_compiled > self.last_emitted.modules_compiled
            || stats.modules_loaded > self.last_emitted.modules_loaded;
        if !advanced {
            return false;
        }
        if self.emitted_advancement
            && now.saturating_sub(self.last_emitted_at) < MIN_ADVANCEMENT_INTERVAL
        {
            return false;
        }
        self.last_emitted_at = now;
        self.last_emitted = stats;
        self.emitted_advancement = true;
        true
    }

    #[cfg(test)]
    fn frame_age(&self, now: Duration) -> Duration {
        now.saturating_sub(self.last_emitted_at)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn stats(compiled: u64, loaded: u64) -> ModulePhaseStats {
        let mut stats = ModulePhaseStats::default();
        stats.modules_compiled = compiled;
        stats.modules_loaded = loaded;
        stats
    }

    #[test]
    fn successful_transitions_advance_monotonically_at_a_time_bound() {
        let mut state = ProjectionState::default();
        assert!(state.advance(Duration::from_millis(10), stats(1, 0)));
        assert!(!state.advance(Duration::from_millis(20), stats(1, 1)));
        assert!(!state.advance(Duration::from_millis(30), stats(1, 1)));
        assert!(state.advance(Duration::from_millis(1_010), stats(1, 2)));
        assert_eq!(state.last_emitted, stats(1, 2));
    }

    #[test]
    fn stuck_preparation_cannot_refresh_the_live_frame() {
        let state = ProjectionState::default();
        assert!(
            state.frame_age(Duration::from_secs(31)) > Duration::from_secs(30),
            "without a successful recorder transition the start frame must age out"
        );
    }
}