supercode-interchange 0.4.15

Canonical, provider-neutral session interchange primitives for Supercode
Documentation
//! A Hermes home written from the world (`hermes.mjs::toHermes`). Tiers per
//! file: byte for `cron/jobs.json`, `cron/executions.db`,
//! `webhook_subscriptions.json` (inline secrets re-inlined from the vault),
//! `config.yaml` when unchanged, every unmodeled file, and `state.db` when
//! bindings/obligations are UNCHANGED since import (copied). `state.db` with
//! changed rows is REFUSED: the Hermes session-store write path is behind
//! UNI-22. Semantic for `SOUL.md` ⇄ `AGENTS.md` and a re-rendered
//! `config.yaml` (the O-blocks ride as top-level keys — ORC-1 F5).

use std::fs;
use std::path::Path;

use serde_json::Value;

use super::canonical::canonical_json;
use super::folder::{
    config_record, copy_unmodeled, encode_config, encode_jobs_file, encode_subscriptions_file,
    write_executions, Flavor, LoadedHome, ProfileIo,
};
use crate::ontology::{ArtifactFidelity, Fidelity};
use crate::world::Profile;
use crate::Result;

/// A write the codec would not guess at.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Refusal {
    /// The file, relative to the destination home.
    pub file: String,
    /// Why, naming the gate.
    pub reason: String,
}

/// What a Hermes decompile did.
#[derive(Debug, Clone, Default)]
pub struct HermesReport {
    /// Every artifact written, with its tier.
    pub written: Vec<ArtifactFidelity>,
    /// Every write refused.
    pub refused: Vec<Refusal>,
}

fn write_atomic(path: &Path, text: &str) -> Result<()> {
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)?;
    }
    let tmp = path.with_file_name(format!(
        "{}.tmp-{}",
        path.file_name().unwrap().to_string_lossy(),
        std::process::id()
    ));
    fs::write(&tmp, text)?;
    fs::rename(&tmp, path)?;
    Ok(())
}

/// Load a Hermes home into the world.
pub fn from_hermes(home: &Path) -> Result<LoadedHome> {
    super::folder::load_home(home, Flavor::Hermes)
}

/// Write a Hermes home. Byte-tier files whose records are unchanged since a
/// Hermes import are copied from the import source; everything else is
/// emitted canonically.
pub fn to_hermes(loaded: &LoadedHome, dest: &Path, only: Option<&str>) -> Result<HermesReport> {
    let mut report = HermesReport::default();
    let empty = ProfileIo {
        raw: Default::default(),
        snapshot: Default::default(),
        source_dir: None,
        flavor: Flavor::Orchestrator,
        jobs_form: None,
        routes_at_top: false,
        borrowed_from: None,
        lenders: Vec::new(),
    };
    for (name, profile) in &loaded.world.profiles {
        if only.is_some_and(|o| o != name) {
            continue;
        }
        let dir = if name == "default" {
            dest.to_path_buf()
        } else {
            dest.join("profiles").join(name)
        };
        fs::create_dir_all(dir.join("cron"))?;
        let meta = loaded.io.get(name).unwrap_or(&empty);
        let rel = |p: &str| {
            if name == "default" {
                p.to_string()
            } else {
                format!("profiles/{name}/{p}")
            }
        };
        let unchanged =
            |file: &str, record: &Value| meta.snapshot.get(file) == Some(&canonical_json(record));
        fn emit_file(
            written: &mut Vec<ArtifactFidelity>,
            dir: &Path,
            path: String,
            file: &str,
            text: &str,
            tier: Fidelity,
        ) -> Result<()> {
            write_atomic(&dir.join(file), text)?;
            written.push(ArtifactFidelity {
                path,
                fidelity: tier,
                loss: Vec::new(),
            });
            Ok(())
        }
        macro_rules! emit {
            ($file:expr, $text:expr, $tier:expr) => {
                emit_file(&mut report.written, &dir, rel($file), $file, $text, $tier)?
            };
        }

        // config.yaml (only when the profile has one, or has something to say)
        let cfg_record = config_record(profile);
        let cfg_empty = profile.routes.is_empty()
            && profile.channels.is_empty()
            && profile.residue.config.is_empty()
            && profile.worker.is_none()
            && profile.home.is_none()
            && profile.expiry == Default::default();
        // Only bytes read FROM A HERMES HOME may be reused for the two files
        // that carry credentials: our folder's config.yaml holds `{dotenv}`
        // refs where Hermes reads values.
        let hermes_bytes = meta.flavor == Flavor::Hermes;
        if hermes_bytes
            && unchanged("config.yaml", &cfg_record)
            && meta.raw.contains_key("config.yaml")
        {
            emit!(
                "config.yaml",
                &meta.raw["config.yaml"],
                Fidelity::ByteLossless
            );
        } else if !cfg_empty || meta.raw.contains_key("config.yaml") {
            emit!(
                "config.yaml",
                &encode_config(profile, Some(meta), Some(&loaded.vault), Flavor::Hermes),
                Fidelity::Semantic
            );
        }

        // persona: AGENTS.md -> SOUL.md
        if let Some(persona) = &profile.persona {
            let src_name = if meta.raw.contains_key("SOUL.md") {
                "SOUL.md"
            } else {
                "AGENTS.md"
            };
            let record = serde_json::to_value(&profile.persona).unwrap();
            if unchanged(src_name, &record) && meta.raw.contains_key(src_name) {
                emit!(
                    "SOUL.md",
                    &meta.raw[src_name],
                    if src_name == "SOUL.md" {
                        Fidelity::ByteLossless
                    } else {
                        Fidelity::Semantic
                    }
                );
            } else {
                emit!(
                    "SOUL.md",
                    persona.text.as_deref().unwrap_or(""),
                    Fidelity::Semantic
                );
            }
        }

        // jobs — Hermes requires an ABSOLUTE workdir; ours is relative to the
        // profile folder, so the emitted copy resolves it against the destination
        let jobs_record: Vec<Value> = profile
            .jobs
            .values()
            .map(|j| serde_json::to_value(j).unwrap())
            .collect();
        if !profile.jobs.is_empty() || meta.raw.contains_key("cron/jobs.json") {
            if unchanged("cron/jobs.json", &Value::Array(jobs_record))
                && meta.raw.contains_key("cron/jobs.json")
            {
                emit!(
                    "cron/jobs.json",
                    &meta.raw["cron/jobs.json"],
                    Fidelity::ByteLossless
                );
            } else {
                let mut view: Profile = profile.clone();
                for job in view.jobs.values_mut() {
                    if let Some(w) = &job.workdir {
                        if !w.starts_with('/') {
                            job.workdir = Some(dir.join(w).display().to_string());
                        }
                    }
                }
                emit!(
                    "cron/jobs.json",
                    &encode_jobs_file(&view, Some(meta)),
                    Fidelity::ByteLossless
                );
            }
        }

        // fires
        let src_exec = meta
            .source_dir
            .as_ref()
            .map(|d| d.join("cron/executions.db"));
        if !profile.fires.is_empty() || src_exec.as_ref().is_some_and(|p| p.exists()) {
            let target = dir.join("cron/executions.db");
            let fires_record = serde_json::to_value(&profile.fires).unwrap();
            if unchanged("cron/executions.db", &fires_record)
                && src_exec.as_ref().is_some_and(|p| p.exists())
            {
                fs::copy(src_exec.as_ref().unwrap(), &target)?;
            } else {
                let tmp =
                    target.with_file_name(format!("executions.db.tmp-{}", std::process::id()));
                let _ = fs::remove_file(&tmp);
                write_executions(&tmp, &profile.fires)?;
                fs::rename(&tmp, &target)?;
            }
            report
                .written
                .push(ArtifactFidelity::byte(rel("cron/executions.db")));
        }

        // subscriptions (secrets re-inlined: Hermes reads them from this file)
        let subs_record: Vec<Value> = profile
            .subscriptions
            .values()
            .map(|s| serde_json::to_value(s).unwrap())
            .collect();
        if !profile.subscriptions.is_empty() || meta.raw.contains_key("webhook_subscriptions.json")
        {
            if hermes_bytes
                && unchanged("webhook_subscriptions.json", &Value::Array(subs_record))
                && meta.raw.contains_key("webhook_subscriptions.json")
            {
                emit!(
                    "webhook_subscriptions.json",
                    &meta.raw["webhook_subscriptions.json"],
                    Fidelity::ByteLossless
                );
            } else {
                emit!(
                    "webhook_subscriptions.json",
                    &encode_subscriptions_file(profile, Some(&loaded.vault)),
                    Fidelity::ByteLossless
                );
            }
        }

        // state.db: copy when untouched, refuse otherwise (UNI-22)
        if meta.borrowed_from.is_none() {
            let state_record = serde_json::json!({ "bindings": profile.bindings, "obligations": profile.obligations });
            let src_state = meta.source_dir.as_ref().map(|d| d.join("state.db"));
            let lenders_unchanged = meta.lenders.iter().all(|n| {
                let p = &loaded.world.profiles[n];
                loaded.io.get(n).and_then(|m| m.snapshot.get("state.db")) == Some(&canonical_json(&serde_json::json!({ "bindings": p.bindings, "obligations": p.obligations })))
            });
            if src_state.as_ref().is_some_and(|p| p.exists()) && meta.flavor == Flavor::Hermes {
                if unchanged("state.db", &state_record) && lenders_unchanged {
                    fs::copy(src_state.as_ref().unwrap(), dir.join("state.db"))?;
                    report.written.push(ArtifactFidelity::byte(rel("state.db")));
                } else {
                    report.refused.push(Refusal { file: rel("state.db"), reason: "bindings/obligations changed since import; writing Hermes sessions is behind the UNI-22 stability gate".into() });
                }
            } else if !profile.bindings.is_empty() || !profile.obligations.is_empty() {
                report.refused.push(Refusal { file: rel("state.db"), reason: "no Hermes session store to carry these rows into; writing Hermes sessions is behind the UNI-22 stability gate".into() });
            }
        }

        // everything Hermes has that we do not model
        copy_unmodeled(profile, meta, &dir)?;
        for f in &profile.residue.files {
            report.written.push(ArtifactFidelity::byte(rel(f)));
        }
    }
    Ok(report)
}