Skip to main content

supercode_interchange/orchestration/codec/
folder.rs

1//! The folder codec: a home folder ⇄ [`Orchestration`]. The root is the
2//! `default` profile; `profiles/<name>/` are the named ones; a profile folder
3//! is a complete home (Hermes's rule). Two flavors of the same layout:
4//!
5//! * `Orchestrator` — ours: the O-blocks in `config.yaml` decode strictly,
6//!   `AGENTS.md` is the persona, `state.db` holds our `bindings` table.
7//! * `Hermes` — a Hermes home: `SOUL.md` is the persona, `state.db`'s
8//!   `sessions` rows become bindings (the multiplexed store partitioned by
9//!   `profile_name`), and the file's bytes may never be reused for our folder
10//!   (they may carry inline credentials).
11//!
12//! Files we own are re-emitted byte for byte when their record is unchanged
13//! and canonically when it changed; files we do not model are never touched.
14
15use std::collections::{BTreeMap, BTreeSet};
16use std::fs;
17use std::path::{Path, PathBuf};
18
19use serde_json::{Map, Value};
20use sha2::{Digest, Sha256};
21
22use super::canonical::canonical_json;
23use super::decode::{
24    decode_channel, decode_fire_row, decode_job, decode_obligation_row, decode_route,
25    decode_subscription, decode_worker, encode_channel, encode_fire_row, encode_job,
26    encode_obligation_folder_extras, encode_obligation_row, encode_route, encode_subscription,
27    load_error, surface_key_string, EXECUTION_COLUMNS, FOLDER_OBLIGATION_EXTRA_COLUMNS,
28    OBLIGATION_COLUMNS,
29};
30use super::dotenv::{parse_dotenv, render_dotenv};
31use super::sqlite::{read_rows, replace_rows, table_exists, write_table, Param};
32use crate::ontology::{
33    hermes_cron_job_id, parse_hermes_session_key, Binding, EndReason, Handoff, HarnessId,
34    HermesSessionRow, Recurrence, Residue, SurfaceKey, Trigger, Worker,
35};
36use crate::orchestration::{
37    Access, AccessPolicy, ChannelConfig, Orchestration, PersonaRef, Profile, ProfileResidue,
38};
39use crate::Result;
40
41/// The files our folder owns.
42pub const OWNED_FILES: &[&str] = &[
43    "config.yaml",
44    "AGENTS.md",
45    "CLAUDE.md",
46    ".env",
47    "cron/jobs.json",
48    "cron/executions.db",
49    "webhook_subscriptions.json",
50    "state.db",
51];
52
53const CONFIG_O_KEYS: &[&str] = &["worker"];
54
55/// Which harness's layout a folder was read as.
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum Flavor {
58    /// Our own folder.
59    Orchestrator,
60    /// A Hermes home.
61    Hermes,
62}
63
64/// The form `cron/jobs.json` was read in, remembered for the emit.
65#[derive(Debug, Clone, PartialEq)]
66pub struct JobsForm {
67    /// `{"jobs": [...], ...}` (true) or a bare array (false).
68    pub object: bool,
69    /// The envelope's other keys (`updated_at`, …), verbatim.
70    pub extras: Map<String, Value>,
71}
72
73/// Per-profile bookkeeping: what a decompile needs to reuse bytes and to know
74/// what "unchanged" means. Never part of the orchestration.
75#[derive(Debug, Clone)]
76pub struct ProfileIo {
77    /// Raw text of every owned file read.
78    pub raw: BTreeMap<String, String>,
79    /// Canonical JSON of each artifact's record as loaded.
80    pub snapshot: BTreeMap<String, String>,
81    /// Where the profile was read from.
82    pub source_dir: Option<PathBuf>,
83    /// The flavor it was read as; only our own bytes may be written back verbatim.
84    pub flavor: Flavor,
85    /// The jobs file's form.
86    pub jobs_form: Option<JobsForm>,
87    /// Whether `profile_routes` sat at the top level (else under `gateway`).
88    pub routes_at_top: bool,
89    /// A Hermes profile that borrowed its partition from the root store.
90    pub borrowed_from: Option<PathBuf>,
91    /// The named profiles that borrowed from this root store.
92    pub lenders: Vec<String>,
93}
94
95impl ProfileIo {
96    fn new(flavor: Flavor) -> Self {
97        Self {
98            raw: BTreeMap::new(),
99            snapshot: BTreeMap::new(),
100            source_dir: None,
101            flavor,
102            jobs_form: None,
103            routes_at_top: false,
104            borrowed_from: None,
105            lenders: Vec::new(),
106        }
107    }
108}
109
110/// A loaded home.
111#[derive(Debug, Clone)]
112pub struct LoadedHome {
113    /// The orchestration.
114    pub orchestration: Orchestration,
115    /// Secret values by `.env` key; never in the orchestration.
116    pub vault: BTreeMap<String, String>,
117    /// Per-profile bookkeeping.
118    pub io: BTreeMap<String, ProfileIo>,
119}
120
121fn sha256_hex(text: &str) -> String {
122    let mut h = Sha256::new();
123    h.update(text.as_bytes());
124    h.finalize().iter().map(|b| format!("{b:02x}")).collect()
125}
126
127/// A persona record for `text`: `AGENTS.md` at the profile root, content hash first.
128pub fn persona_ref(text: &str) -> PersonaRef {
129    PersonaRef {
130        path: "AGENTS.md".into(),
131        text: Some(text.to_string()),
132        sha256: sha256_hex(text),
133    }
134}
135
136fn read_text(dir: &Path, rel: &str) -> Result<Option<String>> {
137    let p = dir.join(rel);
138    if !p.is_file() {
139        return Ok(None);
140    }
141    Ok(Some(fs::read_to_string(&p)?))
142}
143
144fn yaml_to_json(file: &str, text: &str) -> Result<Value> {
145    let value: serde_yaml::Value =
146        serde_yaml::from_str(text).map_err(|e| load_error(file, "", format!("YAML: {e}")))?;
147    let json: Value =
148        serde_json::to_value(value).map_err(|e| load_error(file, "", format!("YAML: {e}")))?;
149    Ok(if json.is_null() {
150        Value::Object(Map::new())
151    } else {
152        json
153    })
154}
155
156/// The strings PyYAML (YAML 1.1, what Hermes reads with) resolves as booleans. `serde_yaml` (YAML 1.2)
157/// writes them bare, so a Hermes `approvals.mode: 'off'` came back as `off` and Hermes read `false`.
158const YAML11_BOOLS: [&str; 18] = [
159    "yes", "Yes", "YES", "no", "No", "NO", "true", "True", "TRUE", "false", "False", "FALSE", "on",
160    "On", "ON", "off", "Off", "OFF",
161];
162const YAML11_MARK: &str = "__supercode_yaml11_string__";
163
164fn mark_yaml11_strings(value: &Value) -> Value {
165    match value {
166        Value::String(s) if YAML11_BOOLS.contains(&s.as_str()) => {
167            Value::String(format!("{YAML11_MARK}{s}"))
168        }
169        Value::Array(items) => Value::Array(items.iter().map(mark_yaml11_strings).collect()),
170        Value::Object(map) => Value::Object(
171            map.iter()
172                .map(|(k, v)| (k.clone(), mark_yaml11_strings(v)))
173                .collect(),
174        ),
175        other => other.clone(),
176    }
177}
178
179fn json_to_yaml(value: &Value) -> String {
180    if value.as_object().is_some_and(|m| m.is_empty()) {
181        return String::new();
182    }
183    // a string YAML 1.1 reads as a boolean is written single-quoted, as Hermes writes it
184    let y: serde_yaml::Value =
185        serde_json::from_value(mark_yaml11_strings(value)).unwrap_or(serde_yaml::Value::Null);
186    let mut out = serde_yaml::to_string(&y).unwrap_or_default();
187    for word in YAML11_BOOLS {
188        out = out.replace(&format!("{YAML11_MARK}{word}"), &format!("'{word}'"));
189    }
190    out
191}
192
193/// The empty profile.
194pub fn empty_profile(name: &str, dir: &Path) -> Profile {
195    Profile {
196        name: name.to_string(),
197        dir: dir.to_path_buf(),
198        worker: None,
199        persona: None,
200        channels: BTreeMap::new(),
201        routes: Vec::new(),
202        jobs: BTreeMap::new(),
203        subscriptions: BTreeMap::new(),
204        access: Default::default(),
205        bindings: BTreeMap::new(),
206        fires: Vec::new(),
207        obligations: Vec::new(),
208        residue: ProfileResidue::default(),
209    }
210}
211
212/// The record `config.yaml`'s bytes are the serialization of.
213pub fn config_record(profile: &Profile) -> Value {
214    serde_json::json!({
215        "worker": profile.worker,
216        "routes": profile.routes, "channels": profile.channels, "residue": profile.residue.config,
217    })
218}
219
220fn state_record(profile: &Profile) -> Value {
221    serde_json::json!({ "bindings": profile.bindings, "obligations": profile.obligations })
222}
223
224/// Load a home folder.
225pub fn load_home(root: &Path, flavor: Flavor) -> Result<LoadedHome> {
226    if !root.is_dir() {
227        return Err(load_error(
228            &root.display().to_string(),
229            "",
230            "not a directory",
231        ));
232    }
233    let mut vault = BTreeMap::new();
234    let mut io = BTreeMap::new();
235    let mut profiles = BTreeMap::new();
236    let (default, default_io) = load_profile_dir("default", root, flavor, &mut vault)?;
237    profiles.insert("default".to_string(), default);
238    io.insert("default".to_string(), default_io);
239    let profiles_dir = root.join("profiles");
240    if profiles_dir.is_dir() {
241        let mut names: Vec<String> = fs::read_dir(&profiles_dir)?
242            .flatten()
243            .filter(|e| e.path().is_dir())
244            .filter_map(|e| e.file_name().into_string().ok())
245            .filter(|n| n != "node_modules" && !n.starts_with('.'))
246            .collect();
247        names.sort();
248        for name in names {
249            let dir = profiles_dir.join(&name);
250            if name == "default" {
251                return Err(load_error(
252                    &dir.display().to_string(),
253                    "",
254                    "\"default\" is the root folder, not a named profile",
255                ));
256            }
257            let (profile, meta) = load_profile_dir(&name, &dir, flavor, &mut vault)?;
258            profiles.insert(name.clone(), profile);
259            io.insert(name, meta);
260        }
261    }
262    let mut loaded = LoadedHome {
263        orchestration: Orchestration {
264            root: root.to_path_buf(),
265            profiles,
266        },
267        vault,
268        io,
269    };
270    if flavor == Flavor::Hermes {
271        partition_shared_store(&mut loaded, root)?;
272    }
273    link_fires(&mut loaded, root)?;
274    Ok(loaded)
275}
276
277/// A Hermes-shaped ledger writes a fire's session and delivery nowhere. The
278/// session is the one minted `cron_<job>_<instant>` inside the fire's window
279/// (its compression tip, when the conversation rolled over); the delivery is
280/// the obligation that session's key — else the job's surface — raised in the
281/// window. `Fire.session_id` / `Fire.obligation_id` are derived here, the
282/// obligation's source points back, and the encoders never write either
283/// (`docs/ONTOLOGY.md` §2.7).
284fn link_fires(loaded: &mut LoadedHome, root: &Path) -> Result<()> {
285    let names: Vec<String> = loaded.orchestration.profiles.keys().cloned().collect();
286    let root_store = root.join("state.db");
287    for name in &names {
288        let own = loaded.orchestration.profiles[name].dir.join("state.db");
289        let store = if own.is_file() {
290            own
291        } else {
292            root_store.clone()
293        };
294        let sessions: Vec<Map<String, Value>> = if table_exists(&store, "sessions") {
295            read_rows(
296                &store,
297                "select id, started_at, end_reason, parent_session_id, session_key from sessions",
298                &[],
299            )?
300            .unwrap_or_default()
301        } else {
302            Vec::new()
303        };
304        // the obligations that store holds: a shared root store was
305        // partitioned across the profiles lending from it
306        let holders: Vec<String> = {
307            let io = &loaded.io[name];
308            if io.borrowed_from.is_some() || !io.lenders.is_empty() {
309                let mut v = vec!["default".to_string()];
310                v.extend(loaded.io["default"].lenders.iter().cloned());
311                v
312            } else {
313                vec![name.clone()]
314            }
315        };
316        // an obligation that already names its fire (our folder persists the
317        // source) is the link; the window join below is for stores that do not
318        let mut by_fire: BTreeMap<String, (String, String)> = BTreeMap::new();
319        for (holder, profile) in &loaded.orchestration.profiles {
320            for o in &profile.obligations {
321                if let crate::orchestration::ObligationSource::Fire { fire_id } = &o.source {
322                    by_fire
323                        .entry(fire_id.clone())
324                        .or_insert_with(|| (holder.clone(), o.id.clone()));
325                }
326            }
327        }
328        let mut links: Vec<(usize, Option<String>, Option<(String, String)>)> = Vec::new();
329        let profile = &loaded.orchestration.profiles[name];
330        for (index, fire) in profile.fires.iter().enumerate() {
331            if let Some(known) = by_fire.get(&fire.id) {
332                // the session, when our ledger persisted it, is already on the fire
333                links.push((index, fire.session_id.clone(), Some(known.clone())));
334                continue;
335            }
336            let session_id = fire_session(&sessions, fire).or_else(|| fire.session_id.clone());
337            let session_key = session_id.as_deref().and_then(|id| {
338                sessions
339                    .iter()
340                    .find(|row| row.get("id").and_then(Value::as_str) == Some(id))
341                    .and_then(|row| row.get("session_key"))
342                    .and_then(Value::as_str)
343                    .filter(|k| !k.is_empty())
344                    .map(str::to_string)
345            });
346            let surface = profile.jobs.get(&fire.job_id).and_then(job_surface);
347            let (Some(from), to) = (
348                iso_epoch(&fire.claimed_at),
349                fire.finished_at
350                    .as_deref()
351                    .and_then(iso_epoch)
352                    .unwrap_or(f64::MAX),
353            ) else {
354                links.push((index, session_id, None));
355                continue;
356            };
357            let in_window = |o: &&crate::orchestration::Obligation| {
358                o.created_at
359                    .parse::<f64>()
360                    .is_ok_and(|at| at >= from && at <= to)
361            };
362            let latest = |mut found: Vec<(&String, &crate::orchestration::Obligation)>| {
363                found.sort_by(|a, b| {
364                    let at = |o: &crate::orchestration::Obligation| {
365                        o.created_at.parse::<f64>().unwrap_or(0.0)
366                    };
367                    at(b.1)
368                        .partial_cmp(&at(a.1))
369                        .unwrap_or(std::cmp::Ordering::Equal)
370                });
371                found
372                    .first()
373                    .map(|(holder, o)| ((*holder).clone(), o.id.clone()))
374            };
375            let candidates = |pick: &dyn Fn(&crate::orchestration::Obligation) -> bool| {
376                holders
377                    .iter()
378                    .flat_map(|h| {
379                        loaded.orchestration.profiles[h]
380                            .obligations
381                            .iter()
382                            .filter(in_window)
383                            .filter(|o| pick(o))
384                            .map(move |o| (h, o))
385                    })
386                    .collect::<Vec<_>>()
387            };
388            let obligation = match &session_key {
389                Some(key) => latest(candidates(&|o| o.session_key.as_deref() == Some(key))),
390                None => None,
391            }
392            .or_else(|| {
393                let (platform, chat_id) = surface.as_ref()?;
394                latest(candidates(&|o| {
395                    o.target.platform.as_deref() == Some(platform)
396                        && o.target.chat_id.as_deref() == Some(chat_id)
397                }))
398            });
399            links.push((index, session_id, obligation));
400        }
401        for (index, session_id, obligation) in links {
402            let fire_id = {
403                let fire = &mut loaded.orchestration.profiles.get_mut(name).unwrap().fires[index];
404                fire.session_id = session_id;
405                fire.obligation_id = obligation.as_ref().map(|(_, id)| id.clone());
406                fire.id.clone()
407            };
408            if let Some((holder, obligation_id)) = obligation {
409                if let Some(o) = loaded
410                    .orchestration
411                    .profiles
412                    .get_mut(&holder)
413                    .and_then(|p| p.obligations.iter_mut().find(|o| o.id == obligation_id))
414                {
415                    o.source = crate::orchestration::ObligationSource::Fire { fire_id };
416                }
417            }
418        }
419    }
420    // the links are derived: the records the stores hold are unchanged
421    for name in &names {
422        let profile = &loaded.orchestration.profiles[name];
423        let io = loaded.io.get_mut(name).unwrap();
424        io.snapshot.insert(
425            "cron/executions.db".into(),
426            canonical_json(&serde_json::to_value(&profile.fires).unwrap()),
427        );
428        io.snapshot
429            .insert("state.db".into(), canonical_json(&state_record(profile)));
430    }
431    Ok(())
432}
433
434/// The platform surface a job's fires deliver to, when the job names one an
435/// obligation could carry: `origin` (the creating conversation) and the
436/// explicit `<platform>:<chat>` form. `local` and `home` deliver nowhere a
437/// platform ledger sees.
438fn job_surface(job: &crate::orchestration::Job) -> Option<(String, String)> {
439    use crate::orchestration::Target;
440    match &job.deliver {
441        Target::Origin => {
442            let origin = job.origin.as_ref()?;
443            Some((origin.platform.clone(), origin.chat_id.clone()?))
444        }
445        Target::Explicit {
446            platform, chat_id, ..
447        } => Some((platform.clone(), chat_id.clone()?)),
448        Target::Home | Target::Local => None,
449    }
450}
451
452/// The session a Hermes fire ran in: the `cron_<job>_<YYYYMMDD_HHMMSS>` id
453/// minted inside [claimed, finished] — the earliest while the fire still
454/// runs, the latest once it finished — followed to its compression tip.
455fn fire_session(
456    sessions: &[Map<String, Value>],
457    fire: &crate::orchestration::Fire,
458) -> Option<String> {
459    let claimed = instant_key(&fire.claimed_at)?;
460    let finished = fire.finished_at.as_deref().and_then(instant_key);
461    let candidates = sessions.iter().filter_map(|row| {
462        let id = row.get("id").and_then(Value::as_str)?;
463        let key = cron_session_instant(id, &fire.job_id)?;
464        (key >= claimed && finished.is_none_or(|f| key <= f)).then(|| (key, id.to_string()))
465    });
466    let chosen = match finished {
467        Some(_) => candidates.max_by_key(|(key, _)| *key),
468        None => candidates.min_by_key(|(key, _)| *key),
469    }?;
470    let mut current = chosen.1;
471    for _ in 0..32 {
472        let row = sessions
473            .iter()
474            .find(|row| row.get("id").and_then(Value::as_str) == Some(current.as_str()));
475        let compressed = row
476            .and_then(|row| row.get("end_reason"))
477            .and_then(Value::as_str)
478            == Some("compression");
479        if !compressed {
480            return Some(current);
481        }
482        let next = sessions
483            .iter()
484            .filter(|row| {
485                row.get("parent_session_id").and_then(Value::as_str) == Some(current.as_str())
486            })
487            .max_by(|a, b| {
488                let at = |r: &Map<String, Value>| {
489                    r.get("started_at").and_then(Value::as_f64).unwrap_or(0.0)
490                };
491                at(a)
492                    .partial_cmp(&at(b))
493                    .unwrap_or(std::cmp::Ordering::Equal)
494                    .then_with(|| {
495                        let id = |r: &Map<String, Value>| {
496                            r.get("id")
497                                .and_then(Value::as_str)
498                                .unwrap_or("")
499                                .to_string()
500                        };
501                        id(a).cmp(&id(b))
502                    })
503            })
504            .and_then(|row| row.get("id").and_then(Value::as_str).map(str::to_string));
505        match next {
506            None => return Some(current),
507            Some(next) => current = next,
508        }
509    }
510    Some(current)
511}
512
513/// `YYYYMMDDHHMMSS` of an ISO instant, for ordering against a cron session id.
514fn instant_key(iso: &str) -> Option<u64> {
515    let digits: String = iso
516        .chars()
517        .take_while(|c| *c != '+' && *c != 'Z')
518        .filter(char::is_ascii_digit)
519        .collect();
520    (digits.len() >= 14).then(|| digits[..14].parse().ok())?
521}
522
523/// `YYYYMMDDHHMMSS` of a `cron_<job>_<YYYYMMDD_HHMMSS>` session id, when it is `job`'s.
524fn cron_session_instant(session_id: &str, job_id: &str) -> Option<u64> {
525    if hermes_cron_job_id(session_id).as_deref() != Some(job_id) {
526        return None;
527    }
528    let stamp = session_id.rsplit_once('_')?;
529    let date = stamp.0.rsplit_once('_')?.1;
530    format!("{date}{}", stamp.1).parse().ok()
531}
532
533/// Seconds since the epoch of an ISO-8601 instant with `Z` or `±HH:MM`.
534pub(crate) fn iso_epoch(iso: &str) -> Option<f64> {
535    let (instant, offset) = if let Some(instant) = iso.strip_suffix('Z') {
536        (instant, 0.0)
537    } else {
538        let time_at = iso.find('T')?;
539        let sign_at = iso[time_at..].find(['+', '-']).map(|i| i + time_at)?;
540        let (instant, offset) = iso.split_at(sign_at);
541        let (hours, minutes) = offset[1..].split_once(':')?;
542        let seconds = hours.parse::<f64>().ok()? * 3_600.0 + minutes.parse::<f64>().ok()? * 60.0;
543        (
544            instant,
545            if offset.starts_with('-') {
546                -seconds
547            } else {
548                seconds
549            },
550        )
551    };
552    let (date, time) = instant.split_once('T')?;
553    let mut date = date.splitn(3, '-');
554    let year: i64 = date.next()?.parse().ok()?;
555    let month: i64 = date.next()?.parse().ok()?;
556    let day: i64 = date.next()?.parse().ok()?;
557    let mut clock = time.splitn(3, ':');
558    let hour: i64 = clock.next()?.parse().ok()?;
559    let minute: i64 = clock.next()?.parse().ok()?;
560    let seconds: f64 = clock.next()?.parse().ok()?;
561    let year = year - i64::from(month <= 2);
562    let era = year.div_euclid(400);
563    let yoe = year - era * 400;
564    let doy = (153 * (if month > 2 { month - 3 } else { month + 9 }) + 2) / 5 + day - 1;
565    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
566    let days = era * 146_097 + doe - 719_468;
567    Some((days * 86_400 + hour * 3_600 + minute * 60) as f64 + seconds - offset)
568}
569
570/// Hermes's multiplexed gateway keeps every profile's rows in ONE store,
571/// partitioned by `profile_name` (sessions) and by the session key's profile
572/// segment (obligations). A named profile without its own `state.db` borrows
573/// its partition from the root store; the root profile keeps the rest.
574fn partition_shared_store(loaded: &mut LoadedHome, root: &Path) -> Result<()> {
575    let root_path = root.join("state.db");
576    if !root_path.is_file() {
577        return Ok(());
578    }
579    let names: Vec<String> = loaded
580        .orchestration
581        .profiles
582        .keys()
583        .filter(|n| *n != "default")
584        .cloned()
585        .collect();
586    for name in names {
587        let has_own = loaded.orchestration.profiles[&name]
588            .dir
589            .join("state.db")
590            .is_file();
591        if has_own {
592            continue;
593        }
594        loaded.io.get_mut(&name).unwrap().borrowed_from = Some(root_path.clone());
595        loaded
596            .io
597            .get_mut("default")
598            .unwrap()
599            .lenders
600            .push(name.clone());
601        if table_exists(&root_path, "sessions") {
602            let rows = read_rows(
603                &root_path,
604                "select * from sessions where profile_name = ?1 order by started_at, id",
605                &[&name],
606            )?
607            .unwrap_or_default();
608            for row in rows {
609                if let Some(b) = binding_from_hermes_session(&root_path, &row, &name) {
610                    loaded
611                        .orchestration
612                        .profiles
613                        .get_mut(&name)
614                        .unwrap()
615                        .bindings
616                        .insert(surface_key_string(&b.key), b);
617                }
618            }
619        }
620        // obligations addressed to this profile's surfaces move out of the root partition
621        let root_profile = loaded.orchestration.profiles.get_mut("default").unwrap();
622        let (mine, rest): (Vec<_>, Vec<_>) = root_profile.obligations.drain(..).partition(|o| {
623            o.session_key
624                .as_deref()
625                .and_then(parse_hermes_session_key)
626                .and_then(|(_, p)| p)
627                .as_deref()
628                == Some(name.as_str())
629        });
630        root_profile.obligations = rest;
631        loaded
632            .orchestration
633            .profiles
634            .get_mut(&name)
635            .unwrap()
636            .obligations = mine;
637        let snap = canonical_json(&state_record(&loaded.orchestration.profiles[&name]));
638        loaded
639            .io
640            .get_mut(&name)
641            .unwrap()
642            .snapshot
643            .insert("state.db".into(), snap);
644    }
645    let snap = canonical_json(&state_record(&loaded.orchestration.profiles["default"]));
646    loaded
647        .io
648        .get_mut("default")
649        .unwrap()
650        .snapshot
651        .insert("state.db".into(), snap);
652    Ok(())
653}
654
655const HERMES_SESSION_MAPPED: &[&str] = &[
656    "id",
657    "source",
658    "session_key",
659    "chat_id",
660    "chat_type",
661    "thread_id",
662    "user_id",
663    "profile_name",
664    "handoff_state",
665    "handoff_platform",
666    "handoff_error",
667    "started_at",
668    "ended_at",
669    "end_reason",
670];
671
672/// The `gateway_routing` scope Hermes's session store uses for a home: its `sessions` folder, resolved.
673fn routing_scope(dir: &Path) -> String {
674    let sessions = dir.join("sessions");
675    fs::canonicalize(&sessions)
676        .or_else(|_| fs::canonicalize(dir).map(|d| d.join("sessions")))
677        .unwrap_or(sessions)
678        .display()
679        .to_string()
680}
681
682/// A binding as Hermes's `SessionEntry` (`gateway/session.py`): its session key, session id, times, origin,
683/// platform and chat type, with the binding itself in `metadata.supercode` so it reads back whole.
684fn routing_entry(profile_name: &str, slot: &str, b: &Binding) -> (String, Value) {
685    let ns = if profile_name == "default" {
686        ""
687    } else {
688        profile_name
689    };
690    let key = b
691        .key
692        .key
693        .clone()
694        .filter(|k| k.starts_with("agent:"))
695        .unwrap_or_else(|| crate::ontology::render_hermes_session_key(ns, &b.key));
696    let entry = serde_json::json!({
697        "session_key": key,
698        "session_id": b.worker.session_id.clone().unwrap_or_else(|| slot.to_string()),
699        "created_at": b.started_at,
700        "updated_at": b.last_activity_at.clone().or_else(|| b.started_at.clone()),
701        "display_name": Value::Null,
702        "platform": b.key.platform,
703        "chat_type": b.key.kind.clone().unwrap_or_else(|| "dm".into()),
704        "origin": {
705            "platform": b.key.platform,
706            "chat_id": b.key.chat_id,
707            "chat_type": b.key.kind,
708            "thread_id": b.key.thread_id,
709            "user_id": b.key.participant_id,
710        },
711        "metadata": { "supercode": { "slot": slot, "binding": b } },
712        "input_tokens": 0, "output_tokens": 0, "cache_read_tokens": 0, "cache_write_tokens": 0,
713        "total_tokens": 0, "last_prompt_tokens": 0, "estimated_cost_usd": 0.0, "cost_status": "unknown",
714        "was_auto_reset": false, "auto_reset_reason": Value::Null, "reset_had_activity": false,
715    });
716    (key, entry)
717}
718
719/// One `gateway_routing` row as a binding: whole from `metadata.supercode` when the orchestrator wrote it,
720/// else from the entry Hermes wrote (a Hermes session, its worker Hermes itself).
721fn binding_from_routing(file: &Path, row: &Map<String, Value>) -> Option<(String, Binding)> {
722    let entry: Value = serde_json::from_str(row.get("entry_json")?.as_str()?).ok()?;
723    if let Some(sc) = entry.pointer("/metadata/supercode") {
724        let b: Binding = serde_json::from_value(sc.get("binding")?.clone()).ok()?;
725        let slot = sc
726            .get("slot")
727            .and_then(Value::as_str)
728            .map(str::to_string)
729            .unwrap_or_else(|| surface_key_string(&b.key));
730        return Some((slot, b));
731    }
732    let session_key = entry.get("session_key").and_then(Value::as_str)?;
733    let (mut key, _) = parse_hermes_session_key(session_key)?;
734    key.key = None;
735    let text = |k: &str| entry.get(k).and_then(Value::as_str).map(str::to_string);
736    let b = Binding {
737        key: key.clone(),
738        profile: None,
739        worker: Worker {
740            harness: HarnessId::new(HarnessId::HERMES),
741            session_id: text("session_id"),
742            locator: Some(file.display().to_string()),
743        },
744        trigger: Trigger::Unknown,
745        recurrence: None,
746        handoff: None,
747        started_at: text("created_at"),
748        last_activity_at: text("updated_at"),
749        ended_at: None,
750        end_reason: None,
751        residue: Residue::default(),
752    };
753    Some((surface_key_string(&key), b))
754}
755
756/// A Hermes `sessions` row is a binding when it has a surface (session_key) or is a cron fire.
757pub fn binding_from_hermes_session(
758    file: &Path,
759    row: &Map<String, Value>,
760    profile_name: &str,
761) -> Option<Binding> {
762    let text = |k: &str| {
763        row.get(k)
764            .and_then(|v| match v {
765                Value::String(s) => Some(s.clone()),
766                Value::Number(n) => Some(n.to_string()),
767                _ => None,
768            })
769            .filter(|s| !s.is_empty())
770    };
771    let num = |k: &str| row.get(k).and_then(Value::as_f64);
772    let id = text("id")?;
773    let source = text("source");
774    // The surface may live only in `session_key` (api_server conversations
775    // carry no chat_type/chat_id columns — ORC-8 finding).
776    let parsed = text("session_key").and_then(|k| parse_hermes_session_key(&k));
777    let chat_type = text("chat_type").or_else(|| parsed.as_ref().and_then(|(k, _)| k.kind.clone()));
778    let key = if text("session_key").is_some()
779        && chat_type
780            .as_deref()
781            .is_some_and(|c| super::decode::CHAT_TYPES.contains(&c))
782    {
783        SurfaceKey {
784            key: None,
785            platform: source
786                .clone()
787                .or_else(|| parsed.as_ref().and_then(|(k, _)| k.platform.clone())),
788            kind: chat_type,
789            chat_id: text("chat_id")
790                .or_else(|| parsed.as_ref().and_then(|(k, _)| k.chat_id.clone())),
791            thread_id: text("thread_id")
792                .or_else(|| parsed.as_ref().and_then(|(k, _)| k.thread_id.clone())),
793            participant_id: parsed.as_ref().and_then(|(k, _)| k.participant_id.clone()),
794        }
795    } else if source.as_deref() == Some("cron") {
796        let job = crate::ontology::hermes_cron_job_id(&id).unwrap_or_else(|| id.clone());
797        SurfaceKey {
798            key: None,
799            platform: Some("cron".into()),
800            kind: Some("dm".into()),
801            chat_id: Some(job),
802            thread_id: None,
803            participant_id: None,
804        }
805    } else {
806        return None;
807    };
808    if let Some(p) = text("profile_name") {
809        if p != profile_name && !(profile_name == "default" && p == "main") {
810            return None; // a foreign partition is not this profile's binding
811        }
812    }
813    let iso = |v: Option<f64>| v.map(epoch_iso);
814    let end_word = text("end_reason");
815    let end_reason = end_word.as_deref().and_then(EndReason::parse);
816    let mut residue = Residue::default();
817    for (k, v) in row {
818        if !HERMES_SESSION_MAPPED.contains(&k.as_str()) && !v.is_null() {
819            residue.keep(k.clone(), v.clone());
820        }
821    }
822    if let (Some(word), None) = (&end_word, end_reason) {
823        residue.keep("end_reason", Value::String(word.clone()));
824    }
825    if let Some(u) = text("user_id") {
826        residue.keep("user_id", Value::String(u));
827    }
828    let recurrence = if key.platform.as_deref() == Some("cron") {
829        key.chat_id.clone().map(|job_id| Recurrence {
830            job_id,
831            kind: "cron".into(),
832        })
833    } else {
834        None
835    };
836    Some(Binding {
837        trigger: match (recurrence.is_some(), source.as_deref()) {
838            (true, _) => Trigger::Cron,
839            (_, Some(s)) => crate::ontology::hermes_trigger_for_source(s),
840            _ => Trigger::Unknown,
841        },
842        key,
843        profile: None,
844        worker: Worker {
845            harness: HarnessId::new(HarnessId::HERMES),
846            session_id: Some(id),
847            locator: Some(file.display().to_string()),
848        },
849        recurrence,
850        handoff: text("handoff_state").map(|state| Handoff {
851            to: text("handoff_platform"),
852            state,
853            error: text("handoff_error"),
854        }),
855        started_at: iso(num("started_at")),
856        last_activity_at: iso(num("ended_at").or_else(|| num("started_at"))),
857        ended_at: iso(num("ended_at")),
858        end_reason,
859        residue,
860    })
861}
862
863/// `new Date(seconds * 1000).toISOString()`.
864fn epoch_iso(seconds: f64) -> String {
865    let row = HermesSessionRow {
866        started_at: Some(seconds),
867        ..Default::default()
868    };
869    Binding::from_hermes_row(&row, None)
870        .started_at
871        .unwrap_or_default()
872}
873
874fn load_profile_dir(
875    name: &str,
876    dir: &Path,
877    flavor: Flavor,
878    vault: &mut BTreeMap<String, String>,
879) -> Result<(Profile, ProfileIo)> {
880    let mut profile = empty_profile(name, dir);
881    let mut config_normalized = false;
882    let mut subs_normalized = false;
883    let mut meta = ProfileIo::new(flavor);
884    meta.source_dir = Some(dir.to_path_buf());
885    let remember = |meta: &mut ProfileIo, rel: &str, raw: Option<String>, record: &Value| {
886        if let Some(raw) = raw {
887            meta.raw.insert(rel.to_string(), raw);
888        }
889        meta.snapshot
890            .insert(rel.to_string(), canonical_json(record));
891    };
892
893    // .env → vault (values), never into the model.
894    if let Some(env) = read_text(dir, ".env")? {
895        for (k, v) in parse_dotenv(&env) {
896            vault.insert(k, v);
897        }
898        meta.raw.insert(".env".into(), env);
899    }
900
901    // config.yaml
902    let cfg_file = dir.join("config.yaml").display().to_string();
903    let cfg_text = read_text(dir, "config.yaml")?;
904    let cfg = match &cfg_text {
905        Some(text) => yaml_to_json(&cfg_file, text)?,
906        None => Value::Object(Map::new()),
907    };
908    let cfg_map = cfg
909        .as_object()
910        .ok_or_else(|| load_error(&cfg_file, "", "expected a mapping"))?;
911    profile.worker = decode_worker(&cfg_file, cfg_map.get("worker"))?;
912    let gateway = cfg_map.get("gateway").and_then(Value::as_object);
913    let routes_raw: Vec<Value> = match cfg_map.get("profile_routes").and_then(Value::as_array) {
914        Some(a) => {
915            meta.routes_at_top = true;
916            a.clone()
917        }
918        None => gateway
919            .and_then(|g| g.get("profile_routes"))
920            .and_then(Value::as_array)
921            .cloned()
922            .unwrap_or_default(),
923    };
924    for (i, r) in routes_raw.iter().enumerate() {
925        profile.routes.push(decode_route(&cfg_file, i, r)?);
926    }
927    if let Some(platforms) = cfg_map.get("platforms") {
928        let map = platforms
929            .as_object()
930            .ok_or_else(|| load_error(&cfg_file, "platforms", "expected a map"))?;
931        let known: BTreeSet<String> = vault.keys().cloned().collect();
932        for (platform, raw) in map {
933            profile.channels.insert(
934                platform.clone(),
935                decode_channel(&cfg_file, platform, raw, vault)?,
936            );
937        }
938        config_normalized =
939            flavor == Flavor::Orchestrator && vault.keys().any(|k| !known.contains(k));
940    }
941    // Hermes turns a messaging platform on from its token in the profile's own `.env` alone
942    // (`gateway/config.py` `_apply_env_overrides`), with its home channel from `<PLATFORM>_HOME_CHANNEL`.
943    // Such a channel is the environment's: it carries `extra.from_env`, and the writer never turns it
944    // into a `platforms:` block, so the home's `config.yaml` stays as its owner wrote it. Both flavors: the
945    // orchestrator's own folder is a Hermes home.
946    {
947        let own: BTreeMap<String, String> = meta
948            .raw
949            .get(".env")
950            .map(|text| parse_dotenv(text).into_iter().collect())
951            .unwrap_or_default();
952        let set = |k: &str| own.get(k).filter(|v| !v.trim().is_empty()).cloned();
953        for (platform, token, home) in [
954            ("discord", "DISCORD_BOT_TOKEN", "DISCORD_HOME_CHANNEL"),
955            ("telegram", "TELEGRAM_BOT_TOKEN", "TELEGRAM_HOME_CHANNEL"),
956            ("slack", "SLACK_BOT_TOKEN", "SLACK_HOME_CHANNEL"),
957        ] {
958            if profile.channels.contains_key(platform) || set(token).is_none() {
959                continue;
960            }
961            let mut extra = BTreeMap::new();
962            extra.insert("from_env".to_string(), Value::Bool(true));
963            if let Some(chat) = set(home) {
964                let mut hc = Map::new();
965                hc.insert("platform".into(), Value::String(platform.into()));
966                hc.insert("chat_id".into(), Value::String(chat));
967                hc.insert(
968                    "name".into(),
969                    Value::String(set(&format!("{home}_NAME")).unwrap_or_else(|| "Home".into())),
970                );
971                if let Some(thread) = set(&format!("{home}_THREAD_ID")) {
972                    hc.insert("thread_id".into(), Value::String(thread));
973                }
974                extra.insert("home_channel".to_string(), Value::Object(hc));
975            }
976            // no credentials: the adapter then reads Hermes's own variables from the profile's `.env`, as Hermes does
977            profile.channels.insert(
978                platform.to_string(),
979                ChannelConfig {
980                    platform: platform.to_string(),
981                    enabled: true,
982                    credentials: BTreeMap::new(),
983                    extra,
984                },
985            );
986        }
987    }
988    // Everything else in config.yaml is Hermes's and rides as residue.
989    for (k, v) in cfg_map {
990        if CONFIG_O_KEYS.contains(&k.as_str()) || k == "platforms" || k == "profile_routes" {
991            continue;
992        }
993        if k == "gateway" {
994            let mut g = v.as_object().cloned().unwrap_or_default();
995            g.remove("profile_routes");
996            if !g.is_empty() {
997                profile
998                    .residue
999                    .config
1000                    .insert("gateway".into(), Value::Object(g));
1001            }
1002            continue;
1003        }
1004        profile.residue.config.insert(k.clone(), v.clone());
1005    }
1006    remember(&mut meta, "config.yaml", cfg_text, &config_record(&profile));
1007    if config_normalized {
1008        // our flavor: a credential VALUE was pasted inline and has moved to the
1009        // vault, so the bytes on disk are not this record's and the next save
1010        // re-emits the file with the ref (and `.env` receives the value). A
1011        // Hermes home keeps values inline by design; its bytes stay reusable.
1012        meta.raw.remove("config.yaml");
1013        meta.snapshot.remove("config.yaml");
1014    }
1015
1016    // persona
1017    let persona_file = if flavor == Flavor::Hermes {
1018        "SOUL.md"
1019    } else {
1020        "AGENTS.md"
1021    };
1022    let persona_text = read_text(dir, persona_file)?;
1023    profile.persona = persona_text.as_ref().map(|t| PersonaRef {
1024        path: "AGENTS.md".into(),
1025        text: Some(t.clone()),
1026        sha256: sha256_hex(t),
1027    });
1028    remember(
1029        &mut meta,
1030        persona_file,
1031        persona_text,
1032        &serde_json::to_value(&profile.persona).unwrap(),
1033    );
1034
1035    // jobs — three forms load; the file's own form is remembered for the emit
1036    let jobs_file = dir.join("cron/jobs.json").display().to_string();
1037    let jobs_text = read_text(dir, "cron/jobs.json")?;
1038    if let Some(text) = &jobs_text {
1039        let parsed: Value = serde_json::from_str(text)
1040            .map_err(|e| load_error(&jobs_file, "", format!("JSON: {e}")))?;
1041        let arr: Vec<Value> = match &parsed {
1042            Value::Array(a) => {
1043                meta.jobs_form = Some(JobsForm {
1044                    object: false,
1045                    extras: Map::new(),
1046                });
1047                a.clone()
1048            }
1049            Value::Object(o) => match o.get("jobs") {
1050                Some(Value::Array(a)) => {
1051                    let mut extras = o.clone();
1052                    extras.remove("jobs");
1053                    meta.jobs_form = Some(JobsForm {
1054                        object: true,
1055                        extras,
1056                    });
1057                    a.clone()
1058                }
1059                Some(Value::Object(m)) => {
1060                    let mut extras = o.clone();
1061                    extras.remove("jobs");
1062                    meta.jobs_form = Some(JobsForm {
1063                        object: true,
1064                        extras,
1065                    });
1066                    m.iter()
1067                        .map(|(id, j)| {
1068                            let mut j = j.as_object().cloned().unwrap_or_default();
1069                            j.insert("id".into(), Value::String(id.clone()));
1070                            Value::Object(j)
1071                        })
1072                        .collect()
1073                }
1074                _ => {
1075                    return Err(load_error(
1076                        &jobs_file,
1077                        "",
1078                        "expected an array of jobs or {\"jobs\": [...]}",
1079                    ))
1080                }
1081            },
1082            _ => {
1083                return Err(load_error(
1084                    &jobs_file,
1085                    "",
1086                    "expected an array of jobs or {\"jobs\": [...]}",
1087                ))
1088            }
1089        };
1090        for raw in &arr {
1091            let job = decode_job(&jobs_file, raw)?;
1092            if profile.jobs.contains_key(&job.id) {
1093                return Err(load_error(&jobs_file, &job.id, "duplicate job id"));
1094            }
1095            profile.jobs.insert(job.id.clone(), job);
1096        }
1097    }
1098    let jobs_record: Vec<Value> = profile
1099        .jobs
1100        .values()
1101        .map(|j| serde_json::to_value(j).unwrap())
1102        .collect();
1103    remember(
1104        &mut meta,
1105        "cron/jobs.json",
1106        jobs_text,
1107        &Value::Array(jobs_record),
1108    );
1109
1110    // fires
1111    let exec_path = dir.join("cron/executions.db");
1112    if table_exists(&exec_path, "executions") {
1113        for row in read_rows(
1114            &exec_path,
1115            "select * from executions order by claimed_at, id",
1116            &[],
1117        )?
1118        .unwrap_or_default()
1119        {
1120            profile
1121                .fires
1122                .push(decode_fire_row(&exec_path.display().to_string(), &row)?);
1123        }
1124    }
1125    remember(
1126        &mut meta,
1127        "cron/executions.db",
1128        None,
1129        &serde_json::to_value(&profile.fires).unwrap(),
1130    );
1131
1132    // subscriptions
1133    let subs_file = dir.join("webhook_subscriptions.json").display().to_string();
1134    let subs_text = read_text(dir, "webhook_subscriptions.json")?;
1135    if let Some(text) = &subs_text {
1136        let parsed: Value = serde_json::from_str(text)
1137            .map_err(|e| load_error(&subs_file, "", format!("JSON: {e}")))?;
1138        let map = parsed
1139            .as_object()
1140            .ok_or_else(|| load_error(&subs_file, "", "expected a map"))?;
1141        let known: BTreeSet<String> = vault.keys().cloned().collect();
1142        for (n, raw) in map {
1143            profile
1144                .subscriptions
1145                .insert(n.clone(), decode_subscription(&subs_file, n, raw, vault)?);
1146        }
1147        subs_normalized =
1148            flavor == Flavor::Orchestrator && vault.keys().any(|k| !known.contains(k));
1149    }
1150    let subs_record: Vec<Value> = profile
1151        .subscriptions
1152        .values()
1153        .map(|s| serde_json::to_value(s).unwrap())
1154        .collect();
1155    remember(
1156        &mut meta,
1157        "webhook_subscriptions.json",
1158        subs_text,
1159        &Value::Array(subs_record),
1160    );
1161    if subs_normalized {
1162        meta.raw.remove("webhook_subscriptions.json");
1163        meta.snapshot.remove("webhook_subscriptions.json");
1164    }
1165
1166    // access: Hermes's own stores (docs/plans/hermes-compat.md row 8), this profile's `.env` only
1167    let own_env = meta
1168        .raw
1169        .get(".env")
1170        .map(|t| parse_dotenv(t))
1171        .unwrap_or_default();
1172    profile.access = hermes_access(dir, &own_env, cfg_map.get("platforms"));
1173
1174    // state.db: bindings + obligations
1175    let state_path = dir.join("state.db");
1176    if table_exists(&state_path, "delivery_obligations") {
1177        for row in read_rows(
1178            &state_path,
1179            "select * from delivery_obligations order by created_at, obligation_id",
1180            &[],
1181        )?
1182        .unwrap_or_default()
1183        {
1184            profile.obligations.push(decode_obligation_row(
1185                &state_path.display().to_string(),
1186                &row,
1187            )?);
1188        }
1189    }
1190    if flavor == Flavor::Orchestrator {
1191        // Hermes's session entries (docs/plans/hermes-compat.md row 10): this home's rows of
1192        // `gateway_routing`, whoever wrote them
1193        if table_exists(&state_path, "gateway_routing") {
1194            let scope = routing_scope(dir);
1195            for row in read_rows(
1196                &state_path,
1197                "select session_key, entry_json from gateway_routing where scope = ? order by updated_at",
1198                &[&scope],
1199            )?
1200            .unwrap_or_default()
1201            {
1202                if let Some((slot, b)) = binding_from_routing(&state_path, &row) {
1203                    profile.bindings.insert(slot, b);
1204                }
1205            }
1206        }
1207    } else if table_exists(&state_path, "sessions") {
1208        for row in read_rows(
1209            &state_path,
1210            "select * from sessions order by started_at, id",
1211            &[],
1212        )?
1213        .unwrap_or_default()
1214        {
1215            if let Some(b) = binding_from_hermes_session(&state_path, &row, name) {
1216                profile.bindings.insert(surface_key_string(&b.key), b);
1217            }
1218        }
1219    }
1220    remember(&mut meta, "state.db", None, &state_record(&profile));
1221
1222    // unmodeled files: carried by path, copied verbatim on export; never held in memory.
1223    profile.residue.files = list_unmodeled(dir, flavor)?;
1224    Ok((profile, meta))
1225}
1226
1227fn list_unmodeled(dir: &Path, flavor: Flavor) -> Result<Vec<String>> {
1228    let mut owned: Vec<&str> = OWNED_FILES.to_vec();
1229    if flavor == Flavor::Hermes {
1230        owned.push("SOUL.md");
1231        owned.retain(|f| !["AGENTS.md", "CLAUDE.md"].contains(f));
1232    }
1233    let runtime_artifacts = ["orchestrator.lock", "orchestrator.sock", "service"];
1234    let mut out = Vec::new();
1235    fn walk(
1236        base: &Path,
1237        d: &Path,
1238        owned: &[&str],
1239        runtime: &[&str],
1240        out: &mut Vec<String>,
1241    ) -> Result<()> {
1242        let entries = match fs::read_dir(d) {
1243            Ok(entries) => entries,
1244            // A live harness can remove an inventoried child directory between reads.
1245            Err(error) if d != base && error.kind() == std::io::ErrorKind::NotFound => {
1246                return Ok(());
1247            }
1248            Err(error) => return Err(error.into()),
1249        };
1250        let mut entries = entries.collect::<std::io::Result<Vec<_>>>()?;
1251        entries.sort_by_key(|e| e.file_name());
1252        for entry in entries {
1253            let p = entry.path();
1254            let rel = p
1255                .strip_prefix(base)
1256                .unwrap_or(&p)
1257                .to_string_lossy()
1258                .replace('\\', "/");
1259            let name = entry.file_name().to_string_lossy().into_owned();
1260            if rel == "profiles"
1261                || name == "node_modules"
1262                || name == ".git"
1263                || rel.starts_with("state.db")
1264                || rel.starts_with("cron/executions.db")
1265            {
1266                continue;
1267            }
1268            if runtime.contains(&rel.as_str()) || regex_tmp(&name) {
1269                continue;
1270            }
1271            let st = match fs::symlink_metadata(&p) {
1272                Ok(metadata) => metadata,
1273                // SQLite sidecars and other live residue can disappear after listing.
1274                Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
1275                Err(error) => return Err(error.into()),
1276            };
1277            if st.is_dir() {
1278                walk(base, &p, owned, runtime, out)?;
1279                continue;
1280            }
1281            if !st.is_file() {
1282                continue;
1283            }
1284            if owned.contains(&rel.as_str()) {
1285                continue;
1286            }
1287            out.push(rel);
1288        }
1289        Ok(())
1290    }
1291    walk(dir, dir, &owned, &runtime_artifacts, &mut out)?;
1292    Ok(out)
1293}
1294
1295fn regex_tmp(name: &str) -> bool {
1296    // `<file>.tmp-<pid>`
1297    name.rsplit_once(".tmp-")
1298        .is_some_and(|(_, pid)| !pid.is_empty() && pid.chars().all(|c| c.is_ascii_digit()))
1299}
1300
1301// ---------------------------------------------------------------- save
1302
1303fn write_atomic(path: &Path, text: &str) -> Result<()> {
1304    if let Some(parent) = path.parent() {
1305        fs::create_dir_all(parent)?;
1306    }
1307    let tmp = path.with_file_name(format!(
1308        "{}.tmp-{}",
1309        path.file_name().unwrap().to_string_lossy(),
1310        std::process::id()
1311    ));
1312    write_with_mode(&tmp, text, target_mode(path))?;
1313    fs::rename(&tmp, path)?;
1314    Ok(())
1315}
1316
1317/// The mode the replaced file keeps: its own when it exists, and never wider than owner-only for the
1318/// vault (`.env`), whose values are credentials. A rename over a file does not carry the old file's
1319/// mode, so the temporary file is created with it.
1320#[cfg(unix)]
1321fn target_mode(path: &Path) -> Option<u32> {
1322    use std::os::unix::fs::PermissionsExt;
1323    let current = fs::metadata(path)
1324        .ok()
1325        .map(|m| m.permissions().mode() & 0o777);
1326    if path.file_name().is_some_and(|name| name == ".env") {
1327        return Some(current.unwrap_or(0o600) & 0o600);
1328    }
1329    current
1330}
1331
1332#[cfg(not(unix))]
1333fn target_mode(_path: &Path) -> Option<u32> {
1334    None
1335}
1336
1337#[cfg(unix)]
1338fn write_with_mode(path: &Path, text: &str, mode: Option<u32>) -> Result<()> {
1339    use std::io::Write;
1340    use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
1341    let mut options = fs::OpenOptions::new();
1342    options.write(true).create(true).truncate(true);
1343    if let Some(mode) = mode {
1344        options.mode(mode);
1345    }
1346    let mut file = options.open(path)?;
1347    // `mode` at creation is filtered by the umask; the explicit set makes it exact.
1348    if let Some(mode) = mode {
1349        file.set_permissions(fs::Permissions::from_mode(mode))?;
1350    }
1351    file.write_all(text.as_bytes())?;
1352    Ok(())
1353}
1354
1355#[cfg(not(unix))]
1356fn write_with_mode(path: &Path, text: &str, _mode: Option<u32>) -> Result<()> {
1357    fs::write(path, text)?;
1358    Ok(())
1359}
1360
1361/// Canonical config.yaml: O-blocks + Hermes-shaped blocks + residue.
1362pub fn encode_config(
1363    profile: &Profile,
1364    meta: Option<&ProfileIo>,
1365    vault: Option<&BTreeMap<String, String>>,
1366    flavor: Flavor,
1367) -> String {
1368    let mut out = Map::new();
1369    for (k, v) in &profile.residue.config {
1370        if k != "gateway" {
1371            out.insert(k.clone(), v.clone());
1372        }
1373    }
1374    if let Some(w) = &profile.worker {
1375        let mut wm = Map::new();
1376        wm.insert("harness".into(), Value::String(w.harness.as_str().into()));
1377        if let Some(m) = &w.model {
1378            wm.insert("model".into(), Value::String(m.clone()));
1379        }
1380        if let Some(p) = &w.preset {
1381            wm.insert("preset".into(), Value::String(p.clone()));
1382        }
1383        if w.cwd != "." {
1384            wm.insert("cwd".into(), Value::String(w.cwd.clone()));
1385        }
1386        if !w.env.is_empty() {
1387            let env: Map<String, Value> = w
1388                .env
1389                .iter()
1390                .map(|(k, v)| {
1391                    let value = match v {
1392                        crate::orchestration::EnvValue::Literal(s) => Value::String(s.clone()),
1393                        crate::orchestration::EnvValue::Secret(r) => super::decode::render_ref(r),
1394                    };
1395                    (k.clone(), value)
1396                })
1397                .collect();
1398            wm.insert("env".into(), Value::Object(env));
1399        }
1400        if w.permission.timeout_seconds != 300
1401            || w.permission.default != crate::orchestration::PermissionDefault::Deny
1402            || w.permission.unattended != crate::orchestration::PermissionUnattended::Deny
1403        {
1404            wm.insert(
1405                "permission".into(),
1406                serde_json::to_value(&w.permission).unwrap(),
1407            );
1408        }
1409        out.insert("worker".into(), Value::Object(wm));
1410    }
1411    let mut gateway = profile
1412        .residue
1413        .config
1414        .get("gateway")
1415        .and_then(Value::as_object)
1416        .cloned()
1417        .unwrap_or_default();
1418    let routes: Vec<Value> = profile.routes.iter().map(encode_route).collect();
1419    if meta.is_some_and(|m| m.routes_at_top) {
1420        if !routes.is_empty() {
1421            out.insert("profile_routes".into(), Value::Array(routes));
1422        }
1423    } else if !routes.is_empty() {
1424        gateway.insert("profile_routes".into(), Value::Array(routes));
1425    }
1426    if !gateway.is_empty() {
1427        out.insert("gateway".into(), Value::Object(gateway));
1428    }
1429    let mut platforms = Map::new();
1430    for (p, ch) in &profile.channels {
1431        // a channel Hermes enables from the profile's `.env` stays there (the loader's `from_env`)
1432        if ch.extra.get("from_env") == Some(&Value::Bool(true)) {
1433            continue;
1434        }
1435        platforms.insert(
1436            p.clone(),
1437            encode_channel(
1438                ch,
1439                if flavor == Flavor::Hermes {
1440                    vault
1441                } else {
1442                    None
1443                },
1444            ),
1445        );
1446    }
1447    if !platforms.is_empty() {
1448        out.insert("platforms".into(), Value::Object(platforms));
1449    }
1450    json_to_yaml(&Value::Object(out))
1451}
1452
1453/// The pin's canonical form is `{"jobs": [...]}`; a file read as a bare array keeps that form.
1454pub fn encode_jobs_file(profile: &Profile, meta: Option<&ProfileIo>) -> String {
1455    let jobs: Vec<Value> = profile
1456        .jobs
1457        .values()
1458        .map(|j| ordered_object(encode_job(j)))
1459        .collect();
1460    let form = meta.and_then(|m| m.jobs_form.clone()).unwrap_or(JobsForm {
1461        object: true,
1462        extras: Map::new(),
1463    });
1464    let body = if form.object {
1465        let mut pairs = vec![("jobs".to_string(), Value::Array(jobs))];
1466        pairs.extend(form.extras.iter().map(|(k, v)| (k.clone(), v.clone())));
1467        ordered_object(pairs)
1468    } else {
1469        Value::Array(jobs)
1470    };
1471    format!("{}\n", pretty_ordered(&body, 0))
1472}
1473
1474/// An object whose key order is the given one, regardless of features: wrapped
1475/// as a marker array the ordered printer understands.
1476pub(crate) fn ordered_object(pairs: Vec<(String, Value)>) -> Value {
1477    // serde_json::Map may sort; keep the order in a side channel: encode as
1478    // [{"__k": k, "__v": v}, ...] under a private tag.
1479    Value::Array(vec![
1480        Value::String("__ordered__".into()),
1481        Value::Array(
1482            pairs
1483                .into_iter()
1484                .map(|(k, v)| serde_json::json!({"__k": k, "__v": v}))
1485                .collect(),
1486        ),
1487    ])
1488}
1489
1490fn is_ordered(value: &Value) -> Option<&Vec<Value>> {
1491    let arr = value.as_array()?;
1492    if arr.len() == 2 && arr[0].as_str() == Some("__ordered__") {
1493        arr[1].as_array()
1494    } else {
1495        None
1496    }
1497}
1498
1499/// `JSON.stringify(value, null, 2)` with ordered objects honoured and plain objects sorted.
1500pub(crate) fn pretty_ordered(value: &Value, depth: usize) -> String {
1501    let pad = |d: usize| "  ".repeat(d);
1502    if let Some(pairs) = is_ordered(value) {
1503        if pairs.is_empty() {
1504            return "{}".into();
1505        }
1506        let inner: Vec<String> = pairs
1507            .iter()
1508            .map(|p| {
1509                format!(
1510                    "{}{}: {}",
1511                    pad(depth + 1),
1512                    serde_json::to_string(p["__k"].as_str().unwrap_or("")).unwrap(),
1513                    pretty_ordered(&p["__v"], depth + 1)
1514                )
1515            })
1516            .collect();
1517        return format!("{{\n{}\n{}}}", inner.join(",\n"), pad(depth));
1518    }
1519    match value {
1520        Value::Array(items) if items.is_empty() => "[]".into(),
1521        Value::Array(items) => {
1522            let inner: Vec<String> = items
1523                .iter()
1524                .map(|v| format!("{}{}", pad(depth + 1), pretty_ordered(v, depth + 1)))
1525                .collect();
1526            format!("[\n{}\n{}]", inner.join(",\n"), pad(depth))
1527        }
1528        Value::Object(o) if o.is_empty() => "{}".into(),
1529        Value::Object(o) => {
1530            let inner: Vec<String> = o
1531                .iter()
1532                .map(|(k, v)| {
1533                    format!(
1534                        "{}{}: {}",
1535                        pad(depth + 1),
1536                        serde_json::to_string(k).unwrap(),
1537                        pretty_ordered(v, depth + 1)
1538                    )
1539                })
1540                .collect();
1541            format!("{{\n{}\n{}}}", inner.join(",\n"), pad(depth))
1542        }
1543        Value::Number(n) => {
1544            if let Some(f) = n.as_f64() {
1545                if n.is_f64() && f.fract() == 0.0 && f.abs() < 1e21 {
1546                    return format!("{}", f as i64);
1547                }
1548            }
1549            n.to_string()
1550        }
1551        other => serde_json::to_string(other).unwrap(),
1552    }
1553}
1554
1555/// `webhook_subscriptions.json`; secrets inlined only for a Hermes export.
1556pub fn encode_subscriptions_file(
1557    profile: &Profile,
1558    vault: Option<&BTreeMap<String, String>>,
1559) -> String {
1560    let mut out = Map::new();
1561    for (n, s) in &profile.subscriptions {
1562        out.insert(n.clone(), encode_subscription(s, vault));
1563    }
1564    format!("{}\n", pretty_ordered(&Value::Object(out), 0))
1565}
1566
1567/// Allowlists, allow-all policy and admins as Hermes keeps them: `<PLATFORM>_ALLOWED_USERS` and
1568/// `<PLATFORM>_ALLOW_ALL_USERS` (and `GATEWAY_ALLOW_ALL_USERS`) in the profile's own `.env`, the approved
1569/// pairings in `platforms/pairing/<platform>-approved.json` (and the legacy `pairing/`), and each
1570/// platform's `allow_admin_from` / `group_allow_admin_from` in `config.yaml`. Read, never written: a
1571/// change goes through Hermes's own pairing store and writers.
1572fn hermes_access(dir: &Path, env: &BTreeMap<String, String>, platforms: Option<&Value>) -> Access {
1573    let mut access = Access::default();
1574    let truthy = |v: &str| {
1575        matches!(
1576            v.trim().to_ascii_lowercase().as_str(),
1577            "1" | "true" | "yes" | "on"
1578        )
1579    };
1580    let split = |v: &str| {
1581        v.split(',')
1582            .map(|s| s.trim().to_string())
1583            .filter(|s| !s.is_empty())
1584            .collect::<Vec<_>>()
1585    };
1586    let global_open = env
1587        .get("GATEWAY_ALLOW_ALL_USERS")
1588        .is_some_and(|v| truthy(v));
1589    for (k, v) in env {
1590        if let Some(p) = k.strip_suffix("_ALLOWED_USERS") {
1591            access
1592                .allowlist
1593                .entry(p.to_ascii_lowercase())
1594                .or_default()
1595                .extend(split(v));
1596        } else if let Some(p) = k.strip_suffix("_ALLOW_ALL_USERS") {
1597            if truthy(v) {
1598                access
1599                    .policy
1600                    .insert(p.to_ascii_lowercase(), AccessPolicy::Open);
1601            }
1602        }
1603    }
1604    for sub in ["platforms/pairing", "pairing"] {
1605        let Ok(entries) = fs::read_dir(dir.join(sub)) else {
1606            continue;
1607        };
1608        for entry in entries.flatten() {
1609            let name = entry.file_name().to_string_lossy().into_owned();
1610            let Some(platform) = name.strip_suffix("-approved.json") else {
1611                continue;
1612            };
1613            let Ok(text) = fs::read_to_string(entry.path()) else {
1614                continue;
1615            };
1616            if let Ok(Value::Object(map)) = serde_json::from_str::<Value>(&text) {
1617                access
1618                    .allowlist
1619                    .entry(platform.to_string())
1620                    .or_default()
1621                    .extend(map.keys().cloned());
1622            }
1623        }
1624    }
1625    if let Some(Value::Object(map)) = platforms {
1626        for (platform, block) in map {
1627            if global_open {
1628                access.policy.insert(platform.clone(), AccessPolicy::Open);
1629            }
1630            for key in ["allow_admin_from", "group_allow_admin_from"] {
1631                let found = block
1632                    .get("extra")
1633                    .and_then(|e| e.get(key))
1634                    .or_else(|| block.get(key));
1635                let ids = match found {
1636                    Some(Value::String(s)) => split(s),
1637                    Some(Value::Array(items)) => items
1638                        .iter()
1639                        .filter_map(|v| {
1640                            v.as_str()
1641                                .map(str::to_string)
1642                                .or_else(|| v.as_i64().map(|n| n.to_string()))
1643                        })
1644                        .collect(),
1645                    _ => Vec::new(),
1646                };
1647                access
1648                    .admins
1649                    .entry(platform.clone())
1650                    .or_default()
1651                    .extend(ids);
1652            }
1653        }
1654    }
1655    for list in access
1656        .allowlist
1657        .values_mut()
1658        .chain(access.admins.values_mut())
1659    {
1660        list.sort();
1661        list.dedup();
1662    }
1663    access.allowlist.retain(|_, v| !v.is_empty());
1664    access.admins.retain(|_, v| !v.is_empty());
1665    access
1666}
1667
1668const EXECUTIONS_DDL: &str = "CREATE TABLE IF NOT EXISTS executions (
1669  id TEXT PRIMARY KEY, job_id TEXT NOT NULL, source TEXT NOT NULL, process_id TEXT NOT NULL, pid INTEGER NOT NULL,
1670  process_started_at INTEGER, status TEXT NOT NULL CHECK(status IN ('claimed','running','completed','failed','unknown')),
1671  claimed_at TEXT NOT NULL, started_at TEXT, finished_at TEXT, error TEXT);
1672CREATE INDEX IF NOT EXISTS idx_executions_job_claimed ON executions(job_id, claimed_at DESC, id DESC);
1673CREATE INDEX IF NOT EXISTS idx_executions_status_claimed ON executions(status, claimed_at DESC, id DESC);";
1674/// Our own ledger: Hermes's shape plus `residue_json`, the columns another
1675/// harness's run log has and Hermes's does not (an OpenClaw run's delivery
1676/// status, session key, summary, model, token counts, sequence — ORC-12
1677/// finding 5), so a run migrated through our folder loses none of them.
1678const FOLDER_EXECUTIONS_DDL: &str = "CREATE TABLE IF NOT EXISTS executions (
1679  id TEXT PRIMARY KEY, job_id TEXT NOT NULL, source TEXT NOT NULL, process_id TEXT NOT NULL, pid INTEGER NOT NULL,
1680  process_started_at INTEGER, status TEXT NOT NULL CHECK(status IN ('claimed','running','completed','failed','unknown')),
1681  claimed_at TEXT NOT NULL, started_at TEXT, finished_at TEXT, error TEXT, residue_json TEXT,
1682  session_id TEXT, obligation_id TEXT);
1683CREATE INDEX IF NOT EXISTS idx_executions_job_claimed ON executions(job_id, claimed_at DESC, id DESC);
1684CREATE INDEX IF NOT EXISTS idx_executions_status_claimed ON executions(status, claimed_at DESC, id DESC);";
1685
1686const OBLIGATIONS_DDL: &str = "CREATE TABLE IF NOT EXISTS delivery_obligations (
1687  obligation_id TEXT PRIMARY KEY, session_key TEXT NOT NULL, platform TEXT NOT NULL, chat_id TEXT NOT NULL, thread_id TEXT,
1688  content TEXT NOT NULL, state TEXT NOT NULL, attempts INTEGER NOT NULL DEFAULT 0, created_at REAL NOT NULL, updated_at REAL NOT NULL,
1689  owner_pid INTEGER, owner_started_at INTEGER, last_error TEXT, adapter_profile TEXT,
1690  posted_message_id TEXT, source_json TEXT);";
1691
1692/// The executions ledger, written fresh.
1693pub fn write_executions(path: &Path, fires: &[crate::orchestration::Fire]) -> Result<()> {
1694    write_executions_shaped(path, fires, false)
1695}
1696
1697/// `ours`: our folder's ledger carries every fire's residue; a Hermes home's
1698/// ledger is Hermes's shape exactly.
1699pub fn write_executions_shaped(
1700    path: &Path,
1701    fires: &[crate::orchestration::Fire],
1702    ours: bool,
1703) -> Result<()> {
1704    let mut cols: Vec<&str> = EXECUTION_COLUMNS.to_vec();
1705    if ours {
1706        // our ledger also keeps the two links Hermes's derives: a fire's
1707        // session and the delivery it produced
1708        cols.extend(["residue_json", "session_id", "obligation_id"]);
1709    }
1710    let insert = format!(
1711        "insert into executions ({}) values ({})",
1712        cols.join(", "),
1713        cols.iter().map(|_| "?").collect::<Vec<_>>().join(",")
1714    );
1715    let rows: Vec<Vec<Param>> = fires
1716        .iter()
1717        .map(|f| {
1718            let mut row: Vec<Param> = encode_fire_row(f).iter().map(Param::from).collect();
1719            if ours {
1720                // what the Hermes columns already hold stays out of the json
1721                let rest: serde_json::Map<String, Value> = f
1722                    .residue
1723                    .0
1724                    .iter()
1725                    .filter(|(k, _)| {
1726                        !["source", "process_id", "pid", "process_started_at"].contains(&k.as_str())
1727                    })
1728                    .map(|(k, v)| (k.clone(), v.clone()))
1729                    .collect();
1730                row.push(if rest.is_empty() {
1731                    Param::Null
1732                } else {
1733                    Param::Text(serde_json::to_string(&rest).unwrap())
1734                });
1735                let opt = |v: &Option<String>| v.clone().map(Param::Text).unwrap_or(Param::Null);
1736                row.push(opt(&f.session_id));
1737                row.push(opt(&f.obligation_id));
1738            }
1739            row
1740        })
1741        .collect();
1742    write_table(
1743        path,
1744        if ours {
1745            FOLDER_EXECUTIONS_DDL
1746        } else {
1747            EXECUTIONS_DDL
1748        },
1749        &insert,
1750        &rows,
1751    )
1752}
1753
1754/// Hermes's `gateway_routing` DDL (`hermes_state_schema.py`).
1755const ROUTING_DDL: &str = "CREATE TABLE IF NOT EXISTS gateway_routing (
1756  scope TEXT NOT NULL DEFAULT '', session_key TEXT NOT NULL, entry_json TEXT NOT NULL, updated_at REAL NOT NULL,
1757  PRIMARY KEY (scope, session_key));";
1758
1759/// The profile's session entries and delivery obligations into its `state.db`, in place, and the
1760/// `sessions/sessions.json` mirror Hermes also writes (`gateway.write_sessions_json`, on by default).
1761fn write_state(dir: &Path, path: &Path, profile: &Profile) -> Result<()> {
1762    fs::create_dir_all(dir.join("sessions"))?;
1763    let scope = routing_scope(dir);
1764    let now = std::time::SystemTime::now()
1765        .duration_since(std::time::UNIX_EPOCH)
1766        .map(|d| d.as_secs_f64())
1767        .unwrap_or(0.0);
1768    // Hermes routes one session per key: where several bindings share a surface (a job's successive fires, a
1769    // conversation reset and begun again), the live one is the key's, else the most recent
1770    let rank = |b: &Binding| {
1771        (
1772            b.ended_at.is_none(),
1773            b.last_activity_at
1774                .clone()
1775                .or_else(|| b.started_at.clone())
1776                .unwrap_or_default(),
1777        )
1778    };
1779    let mut chosen: BTreeMap<String, (&Binding, Value)> = BTreeMap::new();
1780    for (slot, b) in &profile.bindings {
1781        let (key, entry) = routing_entry(&profile.name, slot, b);
1782        if chosen
1783            .get(&key)
1784            .is_none_or(|(held, _)| rank(b) > rank(held))
1785        {
1786            chosen.insert(key, (b, entry));
1787        }
1788    }
1789    let mut mirror = Map::new();
1790    let rows: Vec<Vec<Param>> = chosen
1791        .into_iter()
1792        .map(|(key, (_, entry))| {
1793            mirror.insert(key.clone(), entry.clone());
1794            vec![
1795                Param::Text(scope.clone()),
1796                Param::Text(key),
1797                Param::Text(serde_json::to_string(&entry).unwrap()),
1798                Param::Real(now),
1799            ]
1800        })
1801        .collect();
1802    replace_rows(
1803        path,
1804        ROUTING_DDL,
1805        "gateway_routing",
1806        &[],
1807        ("delete from gateway_routing where scope = ?", &[Param::Text(scope)]),
1808        "insert into gateway_routing (scope, session_key, entry_json, updated_at) values (?, ?, ?, ?)",
1809        &rows,
1810    )?;
1811    let mirror_file = dir.join("sessions/sessions.json");
1812    let tmp = mirror_file.with_file_name(format!("sessions.json.tmp-{}", std::process::id()));
1813    fs::write(
1814        &tmp,
1815        serde_json::to_string_pretty(&Value::Object(mirror)).unwrap(),
1816    )?;
1817    fs::rename(&tmp, &mirror_file)?;
1818    // Hermes's columns, then ours (the sent message's handle, the source), added where Hermes's table lacks them
1819    let cols: Vec<&str> = OBLIGATION_COLUMNS
1820        .iter()
1821        .chain(FOLDER_OBLIGATION_EXTRA_COLUMNS.iter())
1822        .copied()
1823        .collect();
1824    let extras: Vec<(&str, &str)> = FOLDER_OBLIGATION_EXTRA_COLUMNS
1825        .iter()
1826        .map(|c| (*c, "TEXT"))
1827        .collect();
1828    let insert = format!(
1829        "insert into delivery_obligations ({}) values ({})",
1830        cols.join(", "),
1831        cols.iter().map(|_| "?").collect::<Vec<_>>().join(",")
1832    );
1833    let rows: Vec<Vec<Param>> = profile
1834        .obligations
1835        .iter()
1836        .map(|o| {
1837            encode_obligation_row(o)
1838                .iter()
1839                .chain(encode_obligation_folder_extras(o).iter())
1840                .map(Param::from)
1841                .collect()
1842        })
1843        .collect();
1844    replace_rows(
1845        path,
1846        OBLIGATIONS_DDL,
1847        "delivery_obligations",
1848        &extras,
1849        ("delete from delivery_obligations", &[]),
1850        &insert,
1851        &rows,
1852    )
1853}
1854
1855fn write_if_changed(
1856    meta: &mut ProfileIo,
1857    dir: &Path,
1858    rel: &str,
1859    record: &Value,
1860    render: impl FnOnce() -> Option<String>,
1861) -> Result<bool> {
1862    let snap = canonical_json(record);
1863    let path = dir.join(rel);
1864    // Raw bytes are reused only for a file read from OUR folder: another
1865    // harness's bytes may carry inline secrets our folder must never hold.
1866    let reuse = meta.flavor == Flavor::Orchestrator && meta.snapshot.get(rel) == Some(&snap);
1867    if reuse && path.exists() {
1868        return Ok(false);
1869    }
1870    if reuse {
1871        if let Some(raw) = meta.raw.get(rel).cloned() {
1872            write_atomic(&path, &raw)?;
1873            return Ok(true);
1874        }
1875    }
1876    let Some(text) = render() else {
1877        return Ok(false);
1878    };
1879    write_atomic(&path, &text)?;
1880    meta.raw.insert(rel.into(), text);
1881    meta.snapshot.insert(rel.into(), snap);
1882    meta.flavor = Flavor::Orchestrator; // from here on the bytes on disk are ours
1883    Ok(true)
1884}
1885
1886/// Save OUR folder. Never deletes a file it does not own; never writes a secret outside `.env`.
1887pub fn save_home(loaded: &mut LoadedHome, root: Option<&Path>) -> Result<()> {
1888    let root = root
1889        .map(Path::to_path_buf)
1890        .unwrap_or_else(|| loaded.orchestration.root.clone());
1891    fs::create_dir_all(&root)?;
1892    let names: Vec<String> = loaded.orchestration.profiles.keys().cloned().collect();
1893    for name in names {
1894        let dir = if name == "default" {
1895            root.clone()
1896        } else {
1897            root.join("profiles").join(&name)
1898        };
1899        fs::create_dir_all(dir.join("cron"))?;
1900        let profile = loaded.orchestration.profiles[&name].clone();
1901        let meta = loaded
1902            .io
1903            .entry(name.clone())
1904            .or_insert_with(|| ProfileIo::new(Flavor::Orchestrator));
1905        save_profile_dir(&profile, meta, &dir, &loaded.vault)?;
1906    }
1907    Ok(())
1908}
1909
1910fn save_profile_dir(
1911    profile: &Profile,
1912    meta: &mut ProfileIo,
1913    dir: &Path,
1914    vault: &BTreeMap<String, String>,
1915) -> Result<()> {
1916    let cfg_record = config_record(profile);
1917    write_if_changed(meta, dir, "config.yaml", &cfg_record, || {
1918        Some(encode_config(
1919            profile,
1920            None,
1921            Some(vault),
1922            Flavor::Orchestrator,
1923        ))
1924    })?;
1925    if let Some(persona) = &profile.persona {
1926        let text = persona.text.clone().unwrap_or_default();
1927        write_if_changed(
1928            meta,
1929            dir,
1930            "AGENTS.md",
1931            &serde_json::to_value(&profile.persona).unwrap(),
1932            || Some(text),
1933        )?;
1934        if !dir.join("CLAUDE.md").exists() {
1935            write_atomic(&dir.join("CLAUDE.md"), "@AGENTS.md\n")?;
1936        }
1937    }
1938    let jobs_record: Vec<Value> = profile
1939        .jobs
1940        .values()
1941        .map(|j| serde_json::to_value(j).unwrap())
1942        .collect();
1943    let had_jobs = meta.raw.contains_key("cron/jobs.json");
1944    let form = meta.jobs_form.clone();
1945    write_if_changed(
1946        meta,
1947        dir,
1948        "cron/jobs.json",
1949        &Value::Array(jobs_record),
1950        || {
1951            if profile.jobs.is_empty() && !had_jobs {
1952                None
1953            } else {
1954                let stub = ProfileIo {
1955                    jobs_form: form.clone(),
1956                    ..ProfileIo::new(Flavor::Orchestrator)
1957                };
1958                Some(encode_jobs_file(profile, Some(&stub)))
1959            }
1960        },
1961    )?;
1962    let subs_record: Vec<Value> = profile
1963        .subscriptions
1964        .values()
1965        .map(|s| serde_json::to_value(s).unwrap())
1966        .collect();
1967    let had_subs = meta.raw.contains_key("webhook_subscriptions.json");
1968    write_if_changed(
1969        meta,
1970        dir,
1971        "webhook_subscriptions.json",
1972        &Value::Array(subs_record),
1973        || {
1974            if profile.subscriptions.is_empty() && !had_subs {
1975                None
1976            } else {
1977                Some(encode_subscriptions_file(profile, None))
1978            }
1979        },
1980    )?;
1981    // sqlite: rewrite only when the decoded rows changed
1982    let fires_snap = canonical_json(&serde_json::to_value(&profile.fires).unwrap());
1983    let exec_path = dir.join("cron/executions.db");
1984    if (meta.snapshot.get("cron/executions.db") != Some(&fires_snap) || !exec_path.exists())
1985        && (!profile.fires.is_empty() || exec_path.exists())
1986    {
1987        let tmp = exec_path.with_file_name(format!("executions.db.tmp-{}", std::process::id()));
1988        let _ = fs::remove_file(&tmp);
1989        write_executions_shaped(&tmp, &profile.fires, true)?;
1990        fs::rename(&tmp, &exec_path)?;
1991        meta.snapshot
1992            .insert("cron/executions.db".into(), fires_snap);
1993    }
1994    let state_snap = canonical_json(&state_record(profile));
1995    let state_path = dir.join("state.db");
1996    if (meta.snapshot.get("state.db") != Some(&state_snap) || !state_path.exists())
1997        && (!profile.bindings.is_empty() || !profile.obligations.is_empty() || state_path.exists())
1998    {
1999        // in place: a Hermes home's state.db holds Hermes's sessions and messages beside these tables
2000        write_state(dir, &state_path, profile)?;
2001        meta.snapshot.insert("state.db".into(), state_snap);
2002    }
2003
2004    // .env: every ref this profile uses
2005    let mut refs: Vec<String> = Vec::new();
2006    for ch in profile.channels.values() {
2007        for r in ch.credentials.values() {
2008            if let crate::ontology::SecretRef::Dotenv(n) = r {
2009                refs.push(n.clone());
2010            }
2011        }
2012    }
2013    for s in profile.subscriptions.values() {
2014        if let Some(crate::ontology::SecretRef::Dotenv(n)) = &s.secret {
2015            refs.push(n.clone());
2016        }
2017    }
2018    if let Some(w) = &profile.worker {
2019        for v in w.env.values() {
2020            if let crate::orchestration::EnvValue::Secret(crate::ontology::SecretRef::Dotenv(n)) = v
2021            {
2022                refs.push(n.clone());
2023            }
2024        }
2025    }
2026    let mut entries: BTreeMap<String, String> = BTreeMap::new();
2027    for r in refs {
2028        if let Some(v) = vault.get(&r) {
2029            entries.insert(r, v.clone());
2030        }
2031    }
2032    if !entries.is_empty() {
2033        let existing = meta.raw.get(".env").cloned();
2034        let mut merged = existing.as_deref().map(parse_dotenv).unwrap_or_default();
2035        for (k, v) in entries {
2036            merged.insert(k, v);
2037        }
2038        let text = render_dotenv(&merged);
2039        if existing.as_deref() != Some(text.as_str()) {
2040            write_atomic(&dir.join(".env"), &text)?;
2041            meta.raw.insert(".env".into(), text);
2042        }
2043    }
2044    Ok(())
2045}
2046
2047/// Copy every unmodeled file of a profile from its source dir into `dest`, byte for byte.
2048pub fn copy_unmodeled(profile: &Profile, meta: &ProfileIo, dest: &Path) -> Result<()> {
2049    let Some(src) = &meta.source_dir else {
2050        return Ok(());
2051    };
2052    carry_unmodeled(&profile.residue.files, src, dest)?;
2053    Ok(())
2054}
2055
2056/// Copy the named unmodeled files from `src` into `dest`, byte for byte,
2057/// answering the relative paths carried. A file the source no longer holds is
2058/// skipped: the list is what was seen at load, the copy is what is there now.
2059pub fn carry_unmodeled(files: &[String], src: &Path, dest: &Path) -> Result<Vec<String>> {
2060    let mut carried = Vec::new();
2061    for rel in files {
2062        let from = src.join(rel);
2063        if !from.is_file() {
2064            continue;
2065        }
2066        let to = dest.join(rel);
2067        if let Some(parent) = to.parent() {
2068            fs::create_dir_all(parent)?;
2069        }
2070        fs::copy(&from, &to)?;
2071        carried.push(rel.clone());
2072    }
2073    Ok(carried)
2074}