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 let hermes_bytes = meta.flavor == Flavor::Hermes;
268 if hermes_bytes
269 && unchanged("config.yaml", &cfg_record)
270 && meta.raw.contains_key("config.yaml")
271 {
272 emit!(
273 "config.yaml",
274 &meta.raw["config.yaml"],
275 Fidelity::ByteLossless
276 );
277 } else if !cfg_empty || meta.raw.contains_key("config.yaml") {
278 emit!(
279 "config.yaml",
280 &encode_config(profile, Some(meta), Some(&loaded.vault), Flavor::Hermes),
281 Fidelity::Semantic
282 );
283 }
284
285 if let Some(persona) = &profile.persona {
287 let src_name = if meta.raw.contains_key("SOUL.md") {
288 "SOUL.md"
289 } else {
290 "AGENTS.md"
291 };
292 let record = serde_json::to_value(&profile.persona).unwrap();
293 if unchanged(src_name, &record) && meta.raw.contains_key(src_name) {
294 emit!(
295 "SOUL.md",
296 &meta.raw[src_name],
297 if src_name == "SOUL.md" {
298 Fidelity::ByteLossless
299 } else {
300 Fidelity::Semantic
301 }
302 );
303 } else {
304 emit!(
305 "SOUL.md",
306 persona.text.as_deref().unwrap_or(""),
307 Fidelity::Semantic
308 );
309 }
310 }
311
312 let jobs_record: Vec<Value> = profile
315 .jobs
316 .values()
317 .map(|j| serde_json::to_value(j).unwrap())
318 .collect();
319 if !profile.jobs.is_empty() || meta.raw.contains_key("cron/jobs.json") {
320 if unchanged("cron/jobs.json", &Value::Array(jobs_record))
321 && meta.raw.contains_key("cron/jobs.json")
322 {
323 emit!(
324 "cron/jobs.json",
325 &meta.raw["cron/jobs.json"],
326 Fidelity::ByteLossless
327 );
328 } else {
329 let mut view: Profile = profile.clone();
330 for job in view.jobs.values_mut() {
331 if let Some(w) = &job.workdir {
332 if !w.starts_with('/') {
333 job.workdir = Some(dir.join(w).display().to_string());
334 }
335 }
336 }
337 emit!(
338 "cron/jobs.json",
339 &encode_jobs_file(&view, Some(meta)),
340 Fidelity::ByteLossless
341 );
342 }
343 }
344
345 let src_exec = meta
347 .source_dir
348 .as_ref()
349 .map(|d| d.join("cron/executions.db"));
350 if !profile.fires.is_empty() || src_exec.as_ref().is_some_and(|p| p.exists()) {
351 let target = dir.join("cron/executions.db");
352 let fires_record = serde_json::to_value(&profile.fires).unwrap();
353 if unchanged("cron/executions.db", &fires_record)
354 && src_exec.as_ref().is_some_and(|p| p.exists())
355 {
356 fs::copy(src_exec.as_ref().unwrap(), &target)?;
357 } else {
358 let tmp =
359 target.with_file_name(format!("executions.db.tmp-{}", std::process::id()));
360 let _ = fs::remove_file(&tmp);
361 write_executions(&tmp, &profile.fires)?;
362 fs::rename(&tmp, &target)?;
363 }
364 report
365 .written
366 .push(ArtifactFidelity::byte(rel("cron/executions.db")));
367 }
368
369 let subs_record: Vec<Value> = profile
371 .subscriptions
372 .values()
373 .map(|s| serde_json::to_value(s).unwrap())
374 .collect();
375 if !profile.subscriptions.is_empty() || meta.raw.contains_key("webhook_subscriptions.json")
376 {
377 if hermes_bytes
378 && unchanged("webhook_subscriptions.json", &Value::Array(subs_record))
379 && meta.raw.contains_key("webhook_subscriptions.json")
380 {
381 emit!(
382 "webhook_subscriptions.json",
383 &meta.raw["webhook_subscriptions.json"],
384 Fidelity::ByteLossless
385 );
386 } else {
387 emit!(
388 "webhook_subscriptions.json",
389 &encode_subscriptions_file(profile, Some(&loaded.vault)),
390 Fidelity::ByteLossless
391 );
392 }
393 }
394
395 if meta.borrowed_from.is_none() {
397 let state_record = serde_json::json!({ "bindings": profile.bindings, "obligations": profile.obligations });
398 let src_state = meta.source_dir.as_ref().map(|d| d.join("state.db"));
399 let lenders_unchanged = meta.lenders.iter().all(|n| {
400 let p = &loaded.orchestration.profiles[n];
401 loaded.io.get(n).and_then(|m| m.snapshot.get("state.db")) == Some(&canonical_json(&serde_json::json!({ "bindings": p.bindings, "obligations": p.obligations })))
402 });
403 let dest_store = dir.join("state.db");
404 if src_state.as_ref().is_some_and(|p| p.exists()) && meta.flavor == Flavor::Hermes {
405 if unchanged("state.db", &state_record) && lenders_unchanged {
406 fs::copy(src_state.as_ref().unwrap(), &dest_store)?;
407 report.written.push(ArtifactFidelity::byte(rel("state.db")));
408 } else {
409 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() });
413 }
414 } else if !profile.bindings.is_empty() || !profile.obligations.is_empty() {
415 let _ = dest_store;
418 fresh_rows.push((name.as_str(), profile));
419 }
420 }
421
422 copy_unmodeled(profile, meta, &dir)?;
424 for f in &profile.residue.files {
425 report.written.push(ArtifactFidelity::byte(rel(f)));
426 }
427 }
428 if !fresh_rows.is_empty() {
429 let root_store = dest.join("state.db");
430 if root_store.exists() {
431 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() });
432 } else {
433 write_fresh_store(&fresh_rows, &root_store)?;
434 let bindings: usize = fresh_rows.iter().map(|(_, p)| p.bindings.len()).sum();
435 let obligations: usize = fresh_rows.iter().map(|(_, p)| p.obligations.len()).sum();
436 report.written.push(ArtifactFidelity::semantic(
437 "state.db",
438 vec![format!(
439 "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",
440 fresh_rows.len()
441 )],
442 ));
443 }
444 }
445 Ok(report)
446}