tandem-server 0.6.5

HTTP server for Tandem engine APIs
use serde::Serialize;
use serde_json::Value;
use sha2::{Digest, Sha256};

use crate::automation_v2::types::{AutomationV2RunRecord, AutomationV2Spec};

pub fn automation_definition_snapshot_hash(snapshot: &AutomationV2Spec) -> String {
    stable_definition_snapshot_hash(snapshot)
}

pub fn automation_definition_version(snapshot: &AutomationV2Spec, snapshot_hash: &str) -> String {
    metadata_definition_version(snapshot.metadata.as_ref()).unwrap_or_else(|| {
        format!(
            "automation:{}@{}",
            snapshot.automation_id,
            short_hash(snapshot_hash)
        )
    })
}

pub fn automation_run_definition_metadata(snapshot: &AutomationV2Spec) -> (String, String) {
    let snapshot_hash = automation_definition_snapshot_hash(snapshot);
    let version = automation_definition_version(snapshot, &snapshot_hash);
    (version, snapshot_hash)
}

pub fn automation_run_definition_fields(
    run: &AutomationV2RunRecord,
) -> (Option<String>, Option<String>) {
    let snapshot_metadata = run
        .automation_snapshot
        .as_ref()
        .map(automation_run_definition_metadata);
    let version = run.workflow_definition_version.clone().or_else(|| {
        snapshot_metadata
            .as_ref()
            .map(|(version, _)| version.clone())
    });
    let snapshot_hash = run
        .workflow_definition_snapshot_hash
        .clone()
        .or_else(|| snapshot_metadata.map(|(_, snapshot_hash)| snapshot_hash));
    (version, snapshot_hash)
}

pub fn ensure_automation_run_definition_metadata(run: &mut AutomationV2RunRecord) {
    let Some(snapshot) = run.automation_snapshot.as_ref() else {
        return;
    };
    let (version, snapshot_hash) = automation_run_definition_metadata(snapshot);
    if run.workflow_definition_version.is_none() {
        run.workflow_definition_version = Some(version);
    }
    if run.workflow_definition_snapshot_hash.is_none() {
        run.workflow_definition_snapshot_hash = Some(snapshot_hash);
    }
}

pub fn stamp_automation_run_definition_metadata(run: &mut AutomationV2RunRecord) {
    let Some(snapshot) = run.automation_snapshot.as_ref() else {
        return;
    };
    let (version, snapshot_hash) = automation_run_definition_metadata(snapshot);
    run.workflow_definition_version = Some(version);
    run.workflow_definition_snapshot_hash = Some(snapshot_hash);
}

pub fn automation_run_definition_snapshot_hash_mismatch(
    run: &AutomationV2RunRecord,
) -> Option<(String, String)> {
    let recorded = run.workflow_definition_snapshot_hash.as_ref()?;
    let snapshot = run.automation_snapshot.as_ref()?;
    let actual = automation_definition_snapshot_hash(snapshot);
    (recorded != &actual).then(|| (recorded.clone(), actual))
}

pub fn stable_definition_snapshot_hash<T: Serialize>(snapshot: &T) -> String {
    let canonical = serde_json::to_vec(snapshot).unwrap_or_default();
    let mut hasher = Sha256::new();
    hasher.update(canonical);
    format!("sha256:{:x}", hasher.finalize())
}

fn metadata_definition_version(metadata: Option<&Value>) -> Option<String> {
    let metadata = metadata?;
    for key in [
        "definition_version",
        "workflow_definition_version",
        "automation_definition_version",
        "automation_version",
        "source_pack_version",
        "version",
    ] {
        if let Some(value) = metadata.get(key).and_then(value_string) {
            return Some(value);
        }
    }

    plan_revision_version(metadata, &["plan_package_bundle", "plan"])
        .or_else(|| plan_revision_version(metadata, &["approved_plan_materialization"]))
}

fn plan_revision_version(metadata: &Value, path: &[&str]) -> Option<String> {
    let value = value_at_path(metadata, path)?;
    let plan_id = value_string(value.get("plan_id")?)?;
    let plan_revision = value.get("plan_revision").and_then(Value::as_u64)?;
    Some(format!("plan:{plan_id}:rev:{plan_revision}"))
}

fn value_at_path<'a>(mut value: &'a Value, path: &[&str]) -> Option<&'a Value> {
    for segment in path {
        value = value.get(*segment)?;
    }
    Some(value)
}

fn value_string(value: &Value) -> Option<String> {
    let raw = match value {
        Value::String(value) => value.clone(),
        Value::Number(value) => value.to_string(),
        Value::Bool(value) => value.to_string(),
        _ => return None,
    };
    let trimmed = raw.trim();
    (!trimmed.is_empty()).then(|| trimmed.to_string())
}

fn short_hash(snapshot_hash: &str) -> String {
    snapshot_hash
        .strip_prefix("sha256:")
        .unwrap_or(snapshot_hash)
        .chars()
        .take(16)
        .collect()
}

#[cfg(test)]
mod tests {
    use serde_json::json;

    use super::*;

    #[test]
    fn metadata_definition_version_prefers_explicit_version() {
        let version = metadata_definition_version(Some(&json!({
            "definition_version": "release-17",
            "plan_package_bundle": {
                "plan": {
                    "plan_id": "plan-a",
                    "plan_revision": 4
                }
            }
        })));

        assert_eq!(version.as_deref(), Some("release-17"));
    }

    #[test]
    fn metadata_definition_version_uses_plan_revision_when_available() {
        let version = metadata_definition_version(Some(&json!({
            "plan_package_bundle": {
                "plan": {
                    "plan_id": "plan-a",
                    "plan_revision": 4
                }
            }
        })));

        assert_eq!(version.as_deref(), Some("plan:plan-a:rev:4"));
    }

    #[test]
    fn stable_definition_snapshot_hash_is_prefixed_and_deterministic() {
        let left = stable_definition_snapshot_hash(&json!({ "a": 1 }));
        let right = stable_definition_snapshot_hash(&json!({ "a": 1 }));
        let changed = stable_definition_snapshot_hash(&json!({ "a": 2 }));

        assert!(left.starts_with("sha256:"));
        assert_eq!(left, right);
        assert_ne!(left, changed);
    }
}