omk 0.5.0

A Rust runtime for Kimi CLI. Turns prompts into proof-backed engineering runs with gates, worktrees, and replay.
Documentation
use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};

use crate::runtime::goal::state::GOAL_TASK_GRAPH_FILE;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum GoalTaskStatus {
    Pending,
    Blocked,
    Done,
}

impl std::fmt::Display for GoalTaskStatus {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let value = match self {
            GoalTaskStatus::Pending => "pending",
            GoalTaskStatus::Blocked => "blocked",
            GoalTaskStatus::Done => "done",
        };
        write!(f, "{value}")
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GoalTaskEvidence {
    pub kind: String,
    pub path: PathBuf,
    pub summary: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GoalTask {
    pub id: String,
    pub title: String,
    pub description: String,
    pub status: GoalTaskStatus,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub owner_role: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub completed_at: Option<DateTime<Utc>>,
    #[serde(default)]
    pub evidence: Vec<GoalTaskEvidence>,
    #[serde(default)]
    pub retry_count: u32,
    #[serde(default)]
    pub max_retries: u32,
    #[serde(default)]
    pub lease_expires_at: Option<DateTime<Utc>>,
    pub dependencies: Vec<String>,
    pub read_set: Vec<String>,
    pub write_set: Vec<String>,
    pub risk: String,
    pub acceptance: Vec<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GoalTaskGraph {
    pub version: u32,
    pub goal_id: String,
    pub generated_at: DateTime<Utc>,
    pub tasks: Vec<GoalTask>,
}

impl GoalTaskGraph {
    pub async fn load(goal_dir: &Path) -> Result<Self> {
        if let Some(db) = crate::runtime::db::global_db() {
            if let Some(goal_id) = goal_dir.file_name().and_then(|n| n.to_str()) {
                if let Some(graph) =
                    crate::runtime::goal::state::db_store::load_task_graph_from_db(&db, goal_id)
                        .await?
                {
                    graph.validate()?;
                    return Ok(graph);
                }
            }
        }

        let path = goal_dir.join(GOAL_TASK_GRAPH_FILE);
        let json = tokio::fs::read_to_string(&path)
            .await
            .with_context(|| format!("Failed to read goal task graph: {}", path.display()))?;
        let graph: Self = serde_json::from_str(&json)
            .with_context(|| format!("Failed to parse goal task graph: {}", path.display()))?;
        graph
            .validate()
            .with_context(|| format!("Invalid goal task graph: {}", path.display()))?;
        Ok(graph)
    }

    /// Persist to DB (primary) and JSON (backup).
    ///
    /// JSON is written through the artifact pipeline so that sidecar metadata
    /// (e.g. `delivery` on task nodes) is preserved even though it is not
    /// represented in the typed `GoalTask` struct.
    pub async fn save(&self, goal_dir: &Path) -> Result<()> {
        let path = goal_dir.join(GOAL_TASK_GRAPH_FILE);
        crate::runtime::goal::proof::write_json_artifact(&path, self).await?;

        if let Some(db) = crate::runtime::db::global_db() {
            if let Err(e) =
                crate::runtime::goal::state::db_store::save_task_graph_to_db(&db, self).await
            {
                tracing::warn!(
                    goal_id = %self.goal_id,
                    error = %e,
                    "DB task graph save failed; JSON backup written"
                );
            }
        }
        Ok(())
    }

    pub fn validate(&self) -> Result<()> {
        let mut errors = Vec::new();

        if self.version == 0 {
            errors.push("task graph version must be greater than zero".to_string());
        }
        if self.goal_id.trim().is_empty() {
            errors.push("task graph goal_id must not be empty".to_string());
        }
        if self.tasks.is_empty() {
            errors.push("task graph must contain at least one task".to_string());
        }

        let mut task_ids = HashSet::new();
        for task in &self.tasks {
            let task_id = task.id.trim();
            if task_id.is_empty() {
                errors.push("task id must not be empty".to_string());
                continue;
            }
            if !task_ids.insert(task.id.as_str()) {
                errors.push(format!("duplicate task id: {}", task.id));
            }
            if task.title.trim().is_empty() {
                errors.push(format!("task {} title must not be empty", task.id));
            }
            if task.description.trim().is_empty() {
                errors.push(format!("task {} description must not be empty", task.id));
            }
            if task.acceptance.is_empty() {
                errors.push(format!(
                    "task {} must define at least one acceptance criterion",
                    task.id
                ));
            }
        }

        for task in &self.tasks {
            for dependency in &task.dependencies {
                if dependency == &task.id {
                    errors.push(format!("task {} cannot depend on itself", task.id));
                } else if !task_ids.contains(dependency.as_str()) {
                    errors.push(format!(
                        "task {} depends on missing task {}",
                        task.id, dependency
                    ));
                }
            }
        }

        if self.contains_dependency_cycle() {
            errors.push("task graph contains a dependency cycle".to_string());
        }

        if errors.is_empty() {
            Ok(())
        } else {
            anyhow::bail!(errors.join("; "))
        }
    }

    fn contains_dependency_cycle(&self) -> bool {
        let tasks_by_id: HashMap<&str, &GoalTask> = self
            .tasks
            .iter()
            .map(|task| (task.id.as_str(), task))
            .collect();
        let mut visiting = HashSet::new();
        let mut visited = HashSet::new();

        self.tasks.iter().any(|task| {
            dependency_cycle_from(task.id.as_str(), &tasks_by_id, &mut visiting, &mut visited)
        })
    }
}

fn dependency_cycle_from<'a>(
    task_id: &'a str,
    tasks_by_id: &HashMap<&'a str, &'a GoalTask>,
    visiting: &mut HashSet<&'a str>,
    visited: &mut HashSet<&'a str>,
) -> bool {
    if visited.contains(task_id) {
        return false;
    }
    if !visiting.insert(task_id) {
        return true;
    }

    if let Some(task) = tasks_by_id.get(task_id) {
        for dependency in &task.dependencies {
            if tasks_by_id.contains_key(dependency.as_str())
                && dependency_cycle_from(dependency.as_str(), tasks_by_id, visiting, visited)
            {
                return true;
            }
        }
    }

    visiting.remove(task_id);
    visited.insert(task_id);
    false
}

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

    fn task(id: &str, dependencies: &[&str]) -> GoalTask {
        GoalTask {
            id: id.to_string(),
            title: format!("Task {id}"),
            description: format!("Task {id} description"),
            status: GoalTaskStatus::Pending,
            owner_role: None,
            completed_at: None,
            evidence: Vec::new(),
            retry_count: 0,
            max_retries: 0,
            lease_expires_at: None,
            dependencies: dependencies
                .iter()
                .map(|dependency| dependency.to_string())
                .collect(),
            read_set: Vec::new(),
            write_set: Vec::new(),
            risk: "low".to_string(),
            acceptance: vec![format!("Task {id} acceptance")],
        }
    }

    fn graph(tasks: Vec<GoalTask>) -> GoalTaskGraph {
        GoalTaskGraph {
            version: 1,
            goal_id: "goal-test".to_string(),
            generated_at: Utc::now(),
            tasks,
        }
    }

    #[test]
    fn validate_accepts_dependency_dag() {
        let graph = graph(vec![
            task("goal-intake", &[]),
            task("goal-plan", &["goal-intake"]),
            task("goal-verify", &["goal-plan"]),
        ]);

        graph.validate().expect("valid graph should pass");
    }

    #[test]
    fn validate_rejects_duplicate_task_ids() {
        let graph = graph(vec![task("goal-intake", &[]), task("goal-intake", &[])]);

        let err = graph.validate().expect_err("duplicate ids must fail");

        assert!(
            err.to_string().contains("duplicate task id: goal-intake"),
            "{err}"
        );
    }

    #[test]
    fn validate_rejects_unknown_dependencies() {
        let graph = graph(vec![task("goal-verify", &["goal-plan"])]);

        let err = graph
            .validate()
            .expect_err("unknown dependencies must fail");

        assert!(
            err.to_string()
                .contains("task goal-verify depends on missing task goal-plan"),
            "{err}"
        );
    }

    #[test]
    fn validate_rejects_dependency_cycles() {
        let graph = graph(vec![
            task("goal-a", &["goal-c"]),
            task("goal-b", &["goal-a"]),
            task("goal-c", &["goal-b"]),
        ]);

        let err = graph.validate().expect_err("dependency cycles must fail");

        assert!(
            err.to_string()
                .contains("task graph contains a dependency cycle"),
            "{err}"
        );
    }

    #[tokio::test]
    async fn load_defaults_retry_and_lease_metadata_for_legacy_graph() {
        let tmp = tempfile::tempdir().expect("tempdir");
        let graph_json = serde_json::json!({
            "version": 1,
            "goal_id": "goal-test",
            "generated_at": Utc::now(),
            "tasks": [
                {
                    "id": "goal-intake",
                    "title": "Task goal-intake",
                    "description": "Task goal-intake description",
                    "status": "pending",
                    "dependencies": [],
                    "read_set": [],
                    "write_set": [],
                    "risk": "low",
                    "acceptance": ["Task goal-intake acceptance"]
                }
            ]
        });
        tokio::fs::write(
            tmp.path().join(GOAL_TASK_GRAPH_FILE),
            serde_json::to_vec_pretty(&graph_json).expect("json"),
        )
        .await
        .expect("write legacy graph");

        let graph = GoalTaskGraph::load(tmp.path())
            .await
            .expect("legacy graph should load");

        assert_eq!(graph.tasks[0].retry_count, 0);
        assert_eq!(graph.tasks[0].max_retries, 0);
        assert!(graph.tasks[0].lease_expires_at.is_none());
    }
}