supercode_interchange/world/codec/
hermes.rs1use 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#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct Refusal {
27 pub file: String,
29 pub reason: String,
31}
32
33#[derive(Debug, Clone, Default)]
35pub struct HermesReport {
36 pub written: Vec<ArtifactFidelity>,
38 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
56pub fn from_hermes(home: &Path) -> Result<LoadedHome> {
58 super::folder::load_home(home, Flavor::Hermes)
59}
60
61pub 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 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 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 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 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 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 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 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 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}