imp-core 0.2.0

Agent engine for imp: loop, tools, sessions, hooks, context, and SDK
Documentation
use serde::{Deserialize, Serialize};

const FINISHED_RUN_TTL_MS: u128 = 24 * 60 * 60 * 1000;
const INTERRUPTED_RUN_STALE_MS: u128 = 6 * 60 * 60 * 1000;

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ManaRunAgentSummary {
    pub unit_id: String,
    pub title: String,
    pub action: String,
    pub status: String,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ManaRunSummary {
    pub run_id: String,
    pub scope: String,
    pub status: String,
    pub total_units: usize,
    pub total_closed: usize,
    pub total_failed: usize,
    pub total_awaiting_verify: usize,
    pub latest: Option<String>,
    pub logs: Vec<String>,
    pub agents: Vec<ManaRunAgentSummary>,
}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
struct PersistedRunStore {
    #[allow(dead_code)]
    next_id: u64,
    #[serde(default)]
    runs: Vec<PersistedRunState>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
struct PersistedRunState {
    run_id: String,
    scope: String,
    status: String,
    error: Option<String>,
    finished_at_ms: Option<u128>,
    #[serde(default)]
    last_event_at_ms: u128,
    summary: PersistedRunSummary,
    #[serde(default)]
    log_lines: Vec<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
struct PersistedRunSummary {
    #[serde(default)]
    total_units: usize,
    #[serde(default)]
    total_closed: usize,
    #[serde(default)]
    total_failed: usize,
    #[serde(default)]
    total_awaiting_verify: usize,
}

pub fn mana_run_summary(run_id: &str) -> Result<Option<ManaRunSummary>, String> {
    let store = load_run_store()?;
    Ok(store
        .runs
        .into_iter()
        .find(|run| run.run_id == run_id)
        .map(PersistedRunState::into_summary))
}

pub fn stop_mana_run(run_id: &str) -> Result<Option<ManaRunSummary>, String> {
    let mut store = load_run_store()?;
    let Some(run) = store.runs.iter_mut().find(|run| run.run_id == run_id) else {
        return Ok(None);
    };

    let now = unix_time_ms();
    if run.finished_at_ms.is_none() {
        run.status = "interrupted".to_string();
        run.error =
            Some("Stopped from imp /stop; external workers may need manual cleanup".to_string());
        run.finished_at_ms = Some(now);
        run.last_event_at_ms = now;
        run.log_lines.push("Run stopped from imp /stop".to_string());
        save_run_store(&store)?;
    }

    Ok(load_run_store()?
        .runs
        .into_iter()
        .find(|run| run.run_id == run_id)
        .map(PersistedRunState::into_summary))
}

fn load_run_store() -> Result<PersistedRunStore, String> {
    let path = run_state_file();
    if !path.exists() {
        return Ok(PersistedRunStore::default());
    }
    let contents =
        std::fs::read_to_string(&path).map_err(|err| format!("read {}: {err}", path.display()))?;
    if contents.trim().is_empty() {
        return Ok(PersistedRunStore::default());
    }
    let mut store: PersistedRunStore = serde_json::from_str(&contents)
        .map_err(|err| format!("parse {}: {err}", path.display()))?;
    classify_stale_unfinished_runs(&mut store);
    Ok(store)
}

fn save_run_store(store: &PersistedRunStore) -> Result<(), String> {
    let path = run_state_file();
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)
            .map_err(|err| format!("create {}: {err}", parent.display()))?;
    }
    let json = serde_json::to_string_pretty(store)
        .map_err(|err| format!("serialize {}: {err}", path.display()))?;
    std::fs::write(&path, json).map_err(|err| format!("write {}: {err}", path.display()))
}

fn classify_stale_unfinished_runs(store: &mut PersistedRunStore) {
    let cutoff = unix_time_ms().saturating_sub(INTERRUPTED_RUN_STALE_MS);
    for run in &mut store.runs {
        if (run.status == "starting" || run.status == "running")
            && run.finished_at_ms.is_none()
            && run.last_event_at_ms > 0
            && run.last_event_at_ms < cutoff
        {
            run.status = "interrupted".to_string();
            run.error = Some(
                "Run state is stale after process restart or lost background worker; inspect logs before rerun"
                    .to_string(),
            );
            run.finished_at_ms = Some(run.last_event_at_ms);
        }
    }

    let cutoff = unix_time_ms().saturating_sub(FINISHED_RUN_TTL_MS);
    store.runs.retain(|run| match run.finished_at_ms {
        Some(finished_at_ms) => finished_at_ms >= cutoff,
        None => true,
    });
}

impl PersistedRunState {
    fn into_summary(self) -> ManaRunSummary {
        let logs = self
            .log_lines
            .into_iter()
            .filter(|line| !line.trim().is_empty())
            .collect::<Vec<_>>();
        let latest = logs.last().cloned();
        let agents = load_agent_summaries().unwrap_or_default();
        ManaRunSummary {
            run_id: self.run_id,
            scope: self.scope,
            status: self.status,
            total_units: self.summary.total_units,
            total_closed: self.summary.total_closed,
            total_failed: self.summary.total_failed,
            total_awaiting_verify: self.summary.total_awaiting_verify,
            latest,
            logs,
            agents,
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
struct PersistedAgentEntry {
    title: String,
    action: String,
    #[serde(default)]
    finished_at: Option<i64>,
    #[serde(default)]
    exit_code: Option<i32>,
}

fn load_agent_summaries() -> Result<Vec<ManaRunAgentSummary>, String> {
    let path = agents_file();
    if !path.exists() {
        return Ok(Vec::new());
    }
    let contents =
        std::fs::read_to_string(&path).map_err(|err| format!("read {}: {err}", path.display()))?;
    if contents.trim().is_empty() {
        return Ok(Vec::new());
    }
    let agents: std::collections::HashMap<String, PersistedAgentEntry> =
        serde_json::from_str(&contents)
            .map_err(|err| format!("parse {}: {err}", path.display()))?;
    let mut summaries = agents
        .into_iter()
        .map(|(unit_id, entry)| {
            let status = agent_status(&entry);
            ManaRunAgentSummary {
                unit_id,
                title: entry.title,
                action: entry.action,
                status,
            }
        })
        .collect::<Vec<_>>();
    summaries.sort_by(|a, b| a.unit_id.cmp(&b.unit_id));
    Ok(summaries)
}

fn agent_status(entry: &PersistedAgentEntry) -> String {
    match (entry.finished_at, entry.exit_code) {
        (None, _) => "running".to_string(),
        (Some(_), Some(0)) => "done".to_string(),
        (Some(_), Some(code)) => format!("failed({code})"),
        (Some(_), None) => "done".to_string(),
    }
}

fn agents_file() -> std::path::PathBuf {
    if let Ok(path) = mana::commands::agents::agents_file_path() {
        return path;
    }
    let dir = std::env::var("HOME")
        .map(|home| {
            std::path::PathBuf::from(home)
                .join(".local")
                .join("share")
                .join("units")
        })
        .unwrap_or_else(|_| std::path::PathBuf::from("/tmp").join("mana"));
    std::fs::create_dir_all(&dir).ok();
    dir.join("agents.json")
}

fn run_state_file() -> std::path::PathBuf {
    if let Ok(path) = mana::commands::agents::agents_file_path() {
        if let Some(dir) = path.parent() {
            std::fs::create_dir_all(dir).ok();
            return dir.join("run_state.json");
        }
    }

    let dir = std::env::var("HOME")
        .map(|home| {
            std::path::PathBuf::from(home)
                .join(".local")
                .join("share")
                .join("units")
        })
        .unwrap_or_else(|_| std::path::PathBuf::from("/tmp").join("mana"));
    std::fs::create_dir_all(&dir).ok();
    dir.join("run_state.json")
}

fn unix_time_ms() -> u128 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap_or_default()
        .as_millis()
}