1use std::fs;
17use std::path::Path;
18
19use serde_json::Value;
20
21use super::canonical::canonical_json;
22use super::decode::{encode_obligation_row, OBLIGATION_COLUMNS};
23use super::folder::{
24 config_record, copy_unmodeled, encode_config, encode_jobs_file, encode_subscriptions_file,
25 write_executions, Flavor, LoadedHome, ProfileIo,
26};
27use super::openclaw::ms_from_iso;
28use super::sqlite::{write_table, Param};
29use crate::ontology::{
30 hermes_source_for_binding, render_hermes_session_key, ArtifactFidelity, Binding, Fidelity,
31};
32use crate::orchestration::Profile;
33
34const HERMES_STATE_V22: &str = include_str!("hermes_state_v22.sql");
40const HERMES_STATE_V22_VERSION: i64 = 22;
41
42const SESSION_COLUMNS: &[&str] = &[
45 "id",
46 "source",
47 "user_id",
48 "session_key",
49 "chat_id",
50 "chat_type",
51 "thread_id",
52 "expiry_finalized",
53 "started_at",
54 "ended_at",
55 "end_reason",
56 "handoff_state",
57 "handoff_platform",
58 "handoff_error",
59 "profile_name",
60];
61
62fn epoch_seconds(iso: Option<&str>) -> Option<f64> {
63 ms_from_iso(iso).map(|ms| ms as f64 / 1000.0)
64}
65
66fn hermes_session_row(profile: &str, slot: &str, b: &Binding) -> Vec<Param> {
68 let text = |v: &Option<String>| v.clone().map(Param::Text).unwrap_or(Param::Null);
69 let hermes_profile = if profile == "default" {
70 "main"
71 } else {
72 profile
73 };
74 let started = epoch_seconds(b.started_at.as_deref())
75 .or_else(|| epoch_seconds(b.last_activity_at.as_deref()))
76 .unwrap_or(0.0);
77 vec![
78 Param::Text(
79 b.worker
80 .session_id
81 .clone()
82 .unwrap_or_else(|| slot.to_string()),
83 ),
84 Param::Text(hermes_source_for_binding(b)),
85 text(&b.key.participant_id),
86 Param::Text(
87 b.key
88 .key
89 .clone()
90 .unwrap_or_else(|| render_hermes_session_key(hermes_profile, &b.key)),
91 ),
92 text(&b.key.chat_id),
93 text(&b.key.kind),
94 text(&b.key.thread_id),
95 Param::Int(i64::from(b.ended_at.is_some())),
96 Param::Real(started),
97 epoch_seconds(b.ended_at.as_deref())
98 .map(Param::Real)
99 .unwrap_or(Param::Null),
100 b.end_reason
101 .map(|r| Param::Text(r.as_str().into()))
102 .unwrap_or(Param::Null),
103 text(&b.handoff.as_ref().map(|h| h.state.clone())),
104 text(&b.handoff.as_ref().and_then(|h| h.to.clone())),
105 text(&b.handoff.as_ref().and_then(|h| h.error.clone())),
106 if profile == "default" {
107 Param::Null
108 } else {
109 Param::Text(profile.into())
110 },
111 ]
112}
113
114fn write_fresh_store(profiles: &[(&str, &Profile)], path: &Path) -> Result<()> {
119 let placeholders = |n: usize| std::iter::repeat_n("?", n).collect::<Vec<_>>().join(",");
120 write_table(
121 path,
122 HERMES_STATE_V22,
123 "insert into schema_version (version) values (?)",
124 &[vec![Param::Int(HERMES_STATE_V22_VERSION)]],
125 )?;
126 let sessions: Vec<Vec<Param>> = profiles
127 .iter()
128 .flat_map(|(name, profile)| {
129 profile
130 .bindings
131 .iter()
132 .map(move |(slot, b)| hermes_session_row(name, slot, b))
133 })
134 .collect();
135 write_table(
136 path,
137 "",
138 &format!(
139 "insert into sessions ({}) values ({})",
140 SESSION_COLUMNS.join(", "),
141 placeholders(SESSION_COLUMNS.len())
142 ),
143 &sessions,
144 )?;
145 let obligations: Vec<Vec<Param>> = profiles
146 .iter()
147 .flat_map(|(_, profile)| profile.obligations.iter())
148 .map(|o| encode_obligation_row(o).iter().map(Param::from).collect())
149 .collect();
150 write_table(
151 path,
152 "",
153 &format!(
154 "insert into delivery_obligations ({}) values ({})",
155 OBLIGATION_COLUMNS.join(", "),
156 placeholders(OBLIGATION_COLUMNS.len())
157 ),
158 &obligations,
159 )
160}
161use crate::Result;
162
163#[derive(Debug, Clone, PartialEq, Eq)]
165pub struct Refusal {
166 pub file: String,
168 pub reason: String,
170}
171
172#[derive(Debug, Clone, Default)]
174pub struct HermesReport {
175 pub written: Vec<ArtifactFidelity>,
177 pub refused: Vec<Refusal>,
179}
180
181fn write_atomic(path: &Path, text: &str) -> Result<()> {
182 if let Some(parent) = path.parent() {
183 fs::create_dir_all(parent)?;
184 }
185 let tmp = path.with_file_name(format!(
186 "{}.tmp-{}",
187 path.file_name().unwrap().to_string_lossy(),
188 std::process::id()
189 ));
190 fs::write(&tmp, text)?;
191 fs::rename(&tmp, path)?;
192 Ok(())
193}
194
195pub fn from_hermes(home: &Path) -> Result<LoadedHome> {
197 super::folder::load_home(home, Flavor::Hermes)
198}
199
200pub fn to_hermes(loaded: &LoadedHome, dest: &Path, only: Option<&str>) -> Result<HermesReport> {
204 let mut report = HermesReport::default();
205 let empty = ProfileIo {
206 raw: Default::default(),
207 snapshot: Default::default(),
208 source_dir: None,
209 flavor: Flavor::Orchestrator,
210 jobs_form: None,
211 routes_at_top: false,
212 borrowed_from: None,
213 lenders: Vec::new(),
214 };
215 let mut fresh_rows: Vec<(&str, &Profile)> = Vec::new();
216 for (name, profile) in &loaded.orchestration.profiles {
217 if only.is_some_and(|o| o != name) {
218 continue;
219 }
220 let dir = if name == "default" {
221 dest.to_path_buf()
222 } else {
223 dest.join("profiles").join(name)
224 };
225 fs::create_dir_all(dir.join("cron"))?;
226 let meta = loaded.io.get(name).unwrap_or(&empty);
227 let rel = |p: &str| {
228 if name == "default" {
229 p.to_string()
230 } else {
231 format!("profiles/{name}/{p}")
232 }
233 };
234 let unchanged =
235 |file: &str, record: &Value| meta.snapshot.get(file) == Some(&canonical_json(record));
236 fn emit_file(
237 written: &mut Vec<ArtifactFidelity>,
238 dir: &Path,
239 path: String,
240 file: &str,
241 text: &str,
242 tier: Fidelity,
243 ) -> Result<()> {
244 write_atomic(&dir.join(file), text)?;
245 written.push(ArtifactFidelity {
246 path,
247 fidelity: tier,
248 loss: Vec::new(),
249 });
250 Ok(())
251 }
252 macro_rules! emit {
253 ($file:expr, $text:expr, $tier:expr) => {
254 emit_file(&mut report.written, &dir, rel($file), $file, $text, $tier)?
255 };
256 }
257
258 let cfg_record = config_record(profile);
260 let cfg_empty = profile.routes.is_empty()
261 && profile.channels.is_empty()
262 && profile.residue.config.is_empty()
263 && profile.worker.is_none()
264 && profile.home.is_none()
265 && profile.expiry == Default::default();
266 let hermes_bytes = meta.flavor == Flavor::Hermes;
270 if hermes_bytes
271 && unchanged("config.yaml", &cfg_record)
272 && meta.raw.contains_key("config.yaml")
273 {
274 emit!(
275 "config.yaml",
276 &meta.raw["config.yaml"],
277 Fidelity::ByteLossless
278 );
279 } else if !cfg_empty || meta.raw.contains_key("config.yaml") {
280 emit!(
281 "config.yaml",
282 &encode_config(profile, Some(meta), Some(&loaded.vault), Flavor::Hermes),
283 Fidelity::Semantic
284 );
285 }
286
287 if let Some(persona) = &profile.persona {
289 let src_name = if meta.raw.contains_key("SOUL.md") {
290 "SOUL.md"
291 } else {
292 "AGENTS.md"
293 };
294 let record = serde_json::to_value(&profile.persona).unwrap();
295 if unchanged(src_name, &record) && meta.raw.contains_key(src_name) {
296 emit!(
297 "SOUL.md",
298 &meta.raw[src_name],
299 if src_name == "SOUL.md" {
300 Fidelity::ByteLossless
301 } else {
302 Fidelity::Semantic
303 }
304 );
305 } else {
306 emit!(
307 "SOUL.md",
308 persona.text.as_deref().unwrap_or(""),
309 Fidelity::Semantic
310 );
311 }
312 }
313
314 let jobs_record: Vec<Value> = profile
317 .jobs
318 .values()
319 .map(|j| serde_json::to_value(j).unwrap())
320 .collect();
321 if !profile.jobs.is_empty() || meta.raw.contains_key("cron/jobs.json") {
322 if unchanged("cron/jobs.json", &Value::Array(jobs_record))
323 && meta.raw.contains_key("cron/jobs.json")
324 {
325 emit!(
326 "cron/jobs.json",
327 &meta.raw["cron/jobs.json"],
328 Fidelity::ByteLossless
329 );
330 } else {
331 let mut view: Profile = profile.clone();
332 for job in view.jobs.values_mut() {
333 if let Some(w) = &job.workdir {
334 if !w.starts_with('/') {
335 job.workdir = Some(dir.join(w).display().to_string());
336 }
337 }
338 }
339 emit!(
340 "cron/jobs.json",
341 &encode_jobs_file(&view, Some(meta)),
342 Fidelity::ByteLossless
343 );
344 }
345 }
346
347 let src_exec = meta
349 .source_dir
350 .as_ref()
351 .map(|d| d.join("cron/executions.db"));
352 if !profile.fires.is_empty() || src_exec.as_ref().is_some_and(|p| p.exists()) {
353 let target = dir.join("cron/executions.db");
354 let fires_record = serde_json::to_value(&profile.fires).unwrap();
355 if unchanged("cron/executions.db", &fires_record)
356 && src_exec.as_ref().is_some_and(|p| p.exists())
357 {
358 fs::copy(src_exec.as_ref().unwrap(), &target)?;
359 } else {
360 let tmp =
361 target.with_file_name(format!("executions.db.tmp-{}", std::process::id()));
362 let _ = fs::remove_file(&tmp);
363 write_executions(&tmp, &profile.fires)?;
364 fs::rename(&tmp, &target)?;
365 }
366 report
367 .written
368 .push(ArtifactFidelity::byte(rel("cron/executions.db")));
369 }
370
371 let subs_record: Vec<Value> = profile
373 .subscriptions
374 .values()
375 .map(|s| serde_json::to_value(s).unwrap())
376 .collect();
377 if !profile.subscriptions.is_empty() || meta.raw.contains_key("webhook_subscriptions.json")
378 {
379 if hermes_bytes
380 && unchanged("webhook_subscriptions.json", &Value::Array(subs_record))
381 && meta.raw.contains_key("webhook_subscriptions.json")
382 {
383 emit!(
384 "webhook_subscriptions.json",
385 &meta.raw["webhook_subscriptions.json"],
386 Fidelity::ByteLossless
387 );
388 } else {
389 emit!(
390 "webhook_subscriptions.json",
391 &encode_subscriptions_file(profile, Some(&loaded.vault)),
392 Fidelity::ByteLossless
393 );
394 }
395 }
396
397 if meta.borrowed_from.is_none() {
399 let state_record = serde_json::json!({ "bindings": profile.bindings, "obligations": profile.obligations });
400 let src_state = meta.source_dir.as_ref().map(|d| d.join("state.db"));
401 let lenders_unchanged = meta.lenders.iter().all(|n| {
402 let p = &loaded.orchestration.profiles[n];
403 loaded.io.get(n).and_then(|m| m.snapshot.get("state.db")) == Some(&canonical_json(&serde_json::json!({ "bindings": p.bindings, "obligations": p.obligations })))
404 });
405 let dest_store = dir.join("state.db");
406 if src_state.as_ref().is_some_and(|p| p.exists()) && meta.flavor == Flavor::Hermes {
407 if unchanged("state.db", &state_record) && lenders_unchanged {
408 fs::copy(src_state.as_ref().unwrap(), &dest_store)?;
409 report.written.push(ArtifactFidelity::byte(rel("state.db")));
410 } else {
411 report.refused.push(Refusal { file: rel("state.db"), reason: "bindings/obligations changed since import; writing the change into a Hermes session store is UNI-18 (the first shared-WAL write), not this codec's".into() });
415 }
416 } else if !profile.bindings.is_empty() || !profile.obligations.is_empty() {
417 let _ = dest_store;
420 fresh_rows.push((name.as_str(), profile));
421 }
422 }
423
424 copy_unmodeled(profile, meta, &dir)?;
426 for f in &profile.residue.files {
427 report.written.push(ArtifactFidelity::byte(rel(f)));
428 }
429 }
430 if !fresh_rows.is_empty() {
431 let root_store = dest.join("state.db");
432 if root_store.exists() {
433 report.refused.push(Refusal { file: "state.db".into(), reason: "the destination already holds a Hermes session store; writing into a live store is UNI-18 (the first shared-WAL write), not this codec's".into() });
434 } else {
435 write_fresh_store(&fresh_rows, &root_store)?;
436 let bindings: usize = fresh_rows.iter().map(|(_, p)| p.bindings.len()).sum();
437 let obligations: usize = fresh_rows.iter().map(|(_, p)| p.obligations.len()).sum();
438 report.written.push(ArtifactFidelity::semantic(
439 "state.db",
440 vec![format!(
441 "a fresh store at schema {HERMES_STATE_V22_VERSION} (Hermes migrates it on open): {bindings} binding(s) as sessions rows across {} profile(s) partitioned by profile_name, {obligations} obligation(s) as delivery rows; the transcripts live in the worker's store and are not carried",
442 fresh_rows.len()
443 )],
444 ));
445 }
446 }
447 Ok(report)
448}