Skip to main content

supercode_interchange/world/codec/
hermes.rs

1//! A Hermes home written from the world (`hermes.mjs::toHermes`). Tiers per
2//! file: byte for `cron/jobs.json`, `cron/executions.db`,
3//! `webhook_subscriptions.json` (inline secrets re-inlined from the vault),
4//! `config.yaml` when unchanged, every unmodeled file, and `state.db` when
5//! bindings/obligations are UNCHANGED since import (copied). `state.db` with
6//! changed rows is REFUSED: the Hermes session-store write path is behind
7//! UNI-22. Semantic for `SOUL.md` ⇄ `AGENTS.md` and a re-rendered
8//! `config.yaml` (the O-blocks ride as top-level keys — ORC-1 F5).
9
10use std::fs;
11use std::path::Path;
12
13use serde_json::Value;
14
15use super::canonical::canonical_json;
16use super::folder::{
17    config_record, copy_unmodeled, encode_config, encode_jobs_file, encode_subscriptions_file,
18    write_executions, Flavor, LoadedHome, ProfileIo,
19};
20use crate::ontology::{ArtifactFidelity, Fidelity};
21use crate::world::Profile;
22use crate::Result;
23
24/// A write the codec would not guess at.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct Refusal {
27    /// The file, relative to the destination home.
28    pub file: String,
29    /// Why, naming the gate.
30    pub reason: String,
31}
32
33/// What a Hermes decompile did.
34#[derive(Debug, Clone, Default)]
35pub struct HermesReport {
36    /// Every artifact written, with its tier.
37    pub written: Vec<ArtifactFidelity>,
38    /// Every write refused.
39    pub refused: Vec<Refusal>,
40}
41
42fn write_atomic(path: &Path, text: &str) -> Result<()> {
43    if let Some(parent) = path.parent() {
44        fs::create_dir_all(parent)?;
45    }
46    let tmp = path.with_file_name(format!(
47        "{}.tmp-{}",
48        path.file_name().unwrap().to_string_lossy(),
49        std::process::id()
50    ));
51    fs::write(&tmp, text)?;
52    fs::rename(&tmp, path)?;
53    Ok(())
54}
55
56/// Load a Hermes home into the world.
57pub fn from_hermes(home: &Path) -> Result<LoadedHome> {
58    super::folder::load_home(home, Flavor::Hermes)
59}
60
61/// Write a Hermes home. Byte-tier files whose records are unchanged since a
62/// Hermes import are copied from the import source; everything else is
63/// emitted canonically.
64pub fn to_hermes(loaded: &LoadedHome, dest: &Path, only: Option<&str>) -> Result<HermesReport> {
65    let mut report = HermesReport::default();
66    let empty = ProfileIo {
67        raw: Default::default(),
68        snapshot: Default::default(),
69        source_dir: None,
70        flavor: Flavor::Orchestrator,
71        jobs_form: None,
72        routes_at_top: false,
73        borrowed_from: None,
74        lenders: Vec::new(),
75    };
76    for (name, profile) in &loaded.world.profiles {
77        if only.is_some_and(|o| o != name) {
78            continue;
79        }
80        let dir = if name == "default" {
81            dest.to_path_buf()
82        } else {
83            dest.join("profiles").join(name)
84        };
85        fs::create_dir_all(dir.join("cron"))?;
86        let meta = loaded.io.get(name).unwrap_or(&empty);
87        let rel = |p: &str| {
88            if name == "default" {
89                p.to_string()
90            } else {
91                format!("profiles/{name}/{p}")
92            }
93        };
94        let unchanged =
95            |file: &str, record: &Value| meta.snapshot.get(file) == Some(&canonical_json(record));
96        fn emit_file(
97            written: &mut Vec<ArtifactFidelity>,
98            dir: &Path,
99            path: String,
100            file: &str,
101            text: &str,
102            tier: Fidelity,
103        ) -> Result<()> {
104            write_atomic(&dir.join(file), text)?;
105            written.push(ArtifactFidelity {
106                path,
107                fidelity: tier,
108                loss: Vec::new(),
109            });
110            Ok(())
111        }
112        macro_rules! emit {
113            ($file:expr, $text:expr, $tier:expr) => {
114                emit_file(&mut report.written, &dir, rel($file), $file, $text, $tier)?
115            };
116        }
117
118        // config.yaml (only when the profile has one, or has something to say)
119        let cfg_record = config_record(profile);
120        let cfg_empty = profile.routes.is_empty()
121            && profile.channels.is_empty()
122            && profile.residue.config.is_empty()
123            && profile.worker.is_none()
124            && profile.home.is_none()
125            && profile.expiry == Default::default();
126        // Only bytes read FROM A HERMES HOME may be reused for the two files
127        // that carry credentials: our folder's config.yaml holds `{dotenv}`
128        // refs where Hermes reads values.
129        let hermes_bytes = meta.flavor == Flavor::Hermes;
130        if hermes_bytes
131            && unchanged("config.yaml", &cfg_record)
132            && meta.raw.contains_key("config.yaml")
133        {
134            emit!(
135                "config.yaml",
136                &meta.raw["config.yaml"],
137                Fidelity::ByteLossless
138            );
139        } else if !cfg_empty || meta.raw.contains_key("config.yaml") {
140            emit!(
141                "config.yaml",
142                &encode_config(profile, Some(meta), Some(&loaded.vault), Flavor::Hermes),
143                Fidelity::Semantic
144            );
145        }
146
147        // persona: AGENTS.md -> SOUL.md
148        if let Some(persona) = &profile.persona {
149            let src_name = if meta.raw.contains_key("SOUL.md") {
150                "SOUL.md"
151            } else {
152                "AGENTS.md"
153            };
154            let record = serde_json::to_value(&profile.persona).unwrap();
155            if unchanged(src_name, &record) && meta.raw.contains_key(src_name) {
156                emit!(
157                    "SOUL.md",
158                    &meta.raw[src_name],
159                    if src_name == "SOUL.md" {
160                        Fidelity::ByteLossless
161                    } else {
162                        Fidelity::Semantic
163                    }
164                );
165            } else {
166                emit!(
167                    "SOUL.md",
168                    persona.text.as_deref().unwrap_or(""),
169                    Fidelity::Semantic
170                );
171            }
172        }
173
174        // jobs — Hermes requires an ABSOLUTE workdir; ours is relative to the
175        // profile folder, so the emitted copy resolves it against the destination
176        let jobs_record: Vec<Value> = profile
177            .jobs
178            .values()
179            .map(|j| serde_json::to_value(j).unwrap())
180            .collect();
181        if !profile.jobs.is_empty() || meta.raw.contains_key("cron/jobs.json") {
182            if unchanged("cron/jobs.json", &Value::Array(jobs_record))
183                && meta.raw.contains_key("cron/jobs.json")
184            {
185                emit!(
186                    "cron/jobs.json",
187                    &meta.raw["cron/jobs.json"],
188                    Fidelity::ByteLossless
189                );
190            } else {
191                let mut view: Profile = profile.clone();
192                for job in view.jobs.values_mut() {
193                    if let Some(w) = &job.workdir {
194                        if !w.starts_with('/') {
195                            job.workdir = Some(dir.join(w).display().to_string());
196                        }
197                    }
198                }
199                emit!(
200                    "cron/jobs.json",
201                    &encode_jobs_file(&view, Some(meta)),
202                    Fidelity::ByteLossless
203                );
204            }
205        }
206
207        // fires
208        let src_exec = meta
209            .source_dir
210            .as_ref()
211            .map(|d| d.join("cron/executions.db"));
212        if !profile.fires.is_empty() || src_exec.as_ref().is_some_and(|p| p.exists()) {
213            let target = dir.join("cron/executions.db");
214            let fires_record = serde_json::to_value(&profile.fires).unwrap();
215            if unchanged("cron/executions.db", &fires_record)
216                && src_exec.as_ref().is_some_and(|p| p.exists())
217            {
218                fs::copy(src_exec.as_ref().unwrap(), &target)?;
219            } else {
220                let tmp =
221                    target.with_file_name(format!("executions.db.tmp-{}", std::process::id()));
222                let _ = fs::remove_file(&tmp);
223                write_executions(&tmp, &profile.fires)?;
224                fs::rename(&tmp, &target)?;
225            }
226            report
227                .written
228                .push(ArtifactFidelity::byte(rel("cron/executions.db")));
229        }
230
231        // subscriptions (secrets re-inlined: Hermes reads them from this file)
232        let subs_record: Vec<Value> = profile
233            .subscriptions
234            .values()
235            .map(|s| serde_json::to_value(s).unwrap())
236            .collect();
237        if !profile.subscriptions.is_empty() || meta.raw.contains_key("webhook_subscriptions.json")
238        {
239            if hermes_bytes
240                && unchanged("webhook_subscriptions.json", &Value::Array(subs_record))
241                && meta.raw.contains_key("webhook_subscriptions.json")
242            {
243                emit!(
244                    "webhook_subscriptions.json",
245                    &meta.raw["webhook_subscriptions.json"],
246                    Fidelity::ByteLossless
247                );
248            } else {
249                emit!(
250                    "webhook_subscriptions.json",
251                    &encode_subscriptions_file(profile, Some(&loaded.vault)),
252                    Fidelity::ByteLossless
253                );
254            }
255        }
256
257        // state.db: copy when untouched, refuse otherwise (UNI-22)
258        if meta.borrowed_from.is_none() {
259            let state_record = serde_json::json!({ "bindings": profile.bindings, "obligations": profile.obligations });
260            let src_state = meta.source_dir.as_ref().map(|d| d.join("state.db"));
261            let lenders_unchanged = meta.lenders.iter().all(|n| {
262                let p = &loaded.world.profiles[n];
263                loaded.io.get(n).and_then(|m| m.snapshot.get("state.db")) == Some(&canonical_json(&serde_json::json!({ "bindings": p.bindings, "obligations": p.obligations })))
264            });
265            if src_state.as_ref().is_some_and(|p| p.exists()) && meta.flavor == Flavor::Hermes {
266                if unchanged("state.db", &state_record) && lenders_unchanged {
267                    fs::copy(src_state.as_ref().unwrap(), dir.join("state.db"))?;
268                    report.written.push(ArtifactFidelity::byte(rel("state.db")));
269                } else {
270                    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() });
271                }
272            } else if !profile.bindings.is_empty() || !profile.obligations.is_empty() {
273                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() });
274            }
275        }
276
277        // everything Hermes has that we do not model
278        copy_unmodeled(profile, meta, &dir)?;
279        for f in &profile.residue.files {
280            report.written.push(ArtifactFidelity::byte(rel(f)));
281        }
282    }
283    Ok(report)
284}