1use 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_access, decode_binding_row, decode_channel, decode_expiry, decode_fire_row, decode_home,
25 decode_job, decode_obligation_row, decode_route, decode_subscription, decode_worker,
26 encode_access, encode_channel, encode_fire_row, encode_job, encode_obligation_folder_extras,
27 encode_obligation_row, encode_route, encode_subscription, encode_surface_key, load_error,
28 surface_key_string, EXECUTION_COLUMNS, FOLDER_OBLIGATION_EXTRA_COLUMNS, OBLIGATION_COLUMNS,
29};
30use super::dotenv::{parse_dotenv, render_dotenv};
31use super::sqlite::{read_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::{ExpiryPolicy, Orchestration, PersonaRef, Profile, ProfileResidue};
37use crate::Result;
38
39pub const OWNED_FILES: &[&str] = &[
41 "config.yaml",
42 "AGENTS.md",
43 "CLAUDE.md",
44 "access.yaml",
45 ".env",
46 "cron/jobs.json",
47 "cron/executions.db",
48 "webhook_subscriptions.json",
49 "state.db",
50];
51
52const CONFIG_O_KEYS: &[&str] = &["worker", "expiry", "home"];
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum Flavor {
57 Orchestrator,
59 Hermes,
61}
62
63#[derive(Debug, Clone, PartialEq)]
65pub struct JobsForm {
66 pub object: bool,
68 pub extras: Map<String, Value>,
70}
71
72#[derive(Debug, Clone)]
75pub struct ProfileIo {
76 pub raw: BTreeMap<String, String>,
78 pub snapshot: BTreeMap<String, String>,
80 pub source_dir: Option<PathBuf>,
82 pub flavor: Flavor,
84 pub jobs_form: Option<JobsForm>,
86 pub routes_at_top: bool,
88 pub borrowed_from: Option<PathBuf>,
90 pub lenders: Vec<String>,
92}
93
94impl ProfileIo {
95 fn new(flavor: Flavor) -> Self {
96 Self {
97 raw: BTreeMap::new(),
98 snapshot: BTreeMap::new(),
99 source_dir: None,
100 flavor,
101 jobs_form: None,
102 routes_at_top: false,
103 borrowed_from: None,
104 lenders: Vec::new(),
105 }
106 }
107}
108
109#[derive(Debug, Clone)]
111pub struct LoadedHome {
112 pub orchestration: Orchestration,
114 pub vault: BTreeMap<String, String>,
116 pub io: BTreeMap<String, ProfileIo>,
118}
119
120fn sha256_hex(text: &str) -> String {
121 let mut h = Sha256::new();
122 h.update(text.as_bytes());
123 h.finalize().iter().map(|b| format!("{b:02x}")).collect()
124}
125
126pub fn persona_ref(text: &str) -> PersonaRef {
128 PersonaRef {
129 path: "AGENTS.md".into(),
130 text: Some(text.to_string()),
131 sha256: sha256_hex(text),
132 }
133}
134
135fn read_text(dir: &Path, rel: &str) -> Result<Option<String>> {
136 let p = dir.join(rel);
137 if !p.is_file() {
138 return Ok(None);
139 }
140 Ok(Some(fs::read_to_string(&p)?))
141}
142
143fn yaml_to_json(file: &str, text: &str) -> Result<Value> {
144 let value: serde_yaml::Value =
145 serde_yaml::from_str(text).map_err(|e| load_error(file, "", format!("YAML: {e}")))?;
146 let json: Value =
147 serde_json::to_value(value).map_err(|e| load_error(file, "", format!("YAML: {e}")))?;
148 Ok(if json.is_null() {
149 Value::Object(Map::new())
150 } else {
151 json
152 })
153}
154
155fn json_to_yaml(value: &Value) -> String {
156 let y: serde_yaml::Value =
157 serde_json::from_value(value.clone()).unwrap_or(serde_yaml::Value::Null);
158 match value.as_object() {
159 Some(m) if m.is_empty() => String::new(),
160 _ => serde_yaml::to_string(&y).unwrap_or_default(),
161 }
162}
163
164pub fn empty_profile(name: &str, dir: &Path) -> Profile {
166 Profile {
167 name: name.to_string(),
168 dir: dir.to_path_buf(),
169 worker: None,
170 persona: None,
171 channels: BTreeMap::new(),
172 routes: Vec::new(),
173 expiry: ExpiryPolicy::default(),
174 home: None,
175 jobs: BTreeMap::new(),
176 subscriptions: BTreeMap::new(),
177 access: Default::default(),
178 bindings: BTreeMap::new(),
179 fires: Vec::new(),
180 obligations: Vec::new(),
181 residue: ProfileResidue::default(),
182 }
183}
184
185pub fn config_record(profile: &Profile) -> Value {
187 serde_json::json!({
188 "worker": profile.worker, "expiry": profile.expiry, "home": profile.home.as_ref().map(encode_surface_key),
189 "routes": profile.routes, "channels": profile.channels, "residue": profile.residue.config,
190 })
191}
192
193fn state_record(profile: &Profile) -> Value {
194 serde_json::json!({ "bindings": profile.bindings, "obligations": profile.obligations })
195}
196
197pub fn load_home(root: &Path, flavor: Flavor) -> Result<LoadedHome> {
199 if !root.is_dir() {
200 return Err(load_error(
201 &root.display().to_string(),
202 "",
203 "not a directory",
204 ));
205 }
206 let mut vault = BTreeMap::new();
207 let mut io = BTreeMap::new();
208 let mut profiles = BTreeMap::new();
209 let (default, default_io) = load_profile_dir("default", root, flavor, &mut vault)?;
210 profiles.insert("default".to_string(), default);
211 io.insert("default".to_string(), default_io);
212 let profiles_dir = root.join("profiles");
213 if profiles_dir.is_dir() {
214 let mut names: Vec<String> = fs::read_dir(&profiles_dir)?
215 .flatten()
216 .filter(|e| e.path().is_dir())
217 .filter_map(|e| e.file_name().into_string().ok())
218 .filter(|n| n != "node_modules" && !n.starts_with('.'))
219 .collect();
220 names.sort();
221 for name in names {
222 let dir = profiles_dir.join(&name);
223 if name == "default" {
224 return Err(load_error(
225 &dir.display().to_string(),
226 "",
227 "\"default\" is the root folder, not a named profile",
228 ));
229 }
230 let (profile, meta) = load_profile_dir(&name, &dir, flavor, &mut vault)?;
231 profiles.insert(name.clone(), profile);
232 io.insert(name, meta);
233 }
234 }
235 let mut loaded = LoadedHome {
236 orchestration: Orchestration {
237 root: root.to_path_buf(),
238 profiles,
239 },
240 vault,
241 io,
242 };
243 if flavor == Flavor::Hermes {
244 partition_shared_store(&mut loaded, root)?;
245 }
246 link_fires(&mut loaded, root)?;
247 Ok(loaded)
248}
249
250fn link_fires(loaded: &mut LoadedHome, root: &Path) -> Result<()> {
258 let names: Vec<String> = loaded.orchestration.profiles.keys().cloned().collect();
259 let root_store = root.join("state.db");
260 for name in &names {
261 let own = loaded.orchestration.profiles[name].dir.join("state.db");
262 let store = if own.is_file() {
263 own
264 } else {
265 root_store.clone()
266 };
267 let sessions: Vec<Map<String, Value>> = if table_exists(&store, "sessions") {
268 read_rows(
269 &store,
270 "select id, started_at, end_reason, parent_session_id, session_key from sessions",
271 &[],
272 )?
273 .unwrap_or_default()
274 } else {
275 Vec::new()
276 };
277 let holders: Vec<String> = {
280 let io = &loaded.io[name];
281 if io.borrowed_from.is_some() || !io.lenders.is_empty() {
282 let mut v = vec!["default".to_string()];
283 v.extend(loaded.io["default"].lenders.iter().cloned());
284 v
285 } else {
286 vec![name.clone()]
287 }
288 };
289 let mut by_fire: BTreeMap<String, (String, String)> = BTreeMap::new();
292 for (holder, profile) in &loaded.orchestration.profiles {
293 for o in &profile.obligations {
294 if let crate::orchestration::ObligationSource::Fire { fire_id } = &o.source {
295 by_fire
296 .entry(fire_id.clone())
297 .or_insert_with(|| (holder.clone(), o.id.clone()));
298 }
299 }
300 }
301 let mut links: Vec<(usize, Option<String>, Option<(String, String)>)> = Vec::new();
302 let profile = &loaded.orchestration.profiles[name];
303 for (index, fire) in profile.fires.iter().enumerate() {
304 if let Some(known) = by_fire.get(&fire.id) {
305 links.push((index, fire.session_id.clone(), Some(known.clone())));
307 continue;
308 }
309 let session_id = fire_session(&sessions, fire).or_else(|| fire.session_id.clone());
310 let session_key = session_id.as_deref().and_then(|id| {
311 sessions
312 .iter()
313 .find(|row| row.get("id").and_then(Value::as_str) == Some(id))
314 .and_then(|row| row.get("session_key"))
315 .and_then(Value::as_str)
316 .filter(|k| !k.is_empty())
317 .map(str::to_string)
318 });
319 let surface = profile.jobs.get(&fire.job_id).and_then(job_surface);
320 let (Some(from), to) = (
321 iso_epoch(&fire.claimed_at),
322 fire.finished_at
323 .as_deref()
324 .and_then(iso_epoch)
325 .unwrap_or(f64::MAX),
326 ) else {
327 links.push((index, session_id, None));
328 continue;
329 };
330 let in_window = |o: &&crate::orchestration::Obligation| {
331 o.created_at
332 .parse::<f64>()
333 .is_ok_and(|at| at >= from && at <= to)
334 };
335 let latest = |mut found: Vec<(&String, &crate::orchestration::Obligation)>| {
336 found.sort_by(|a, b| {
337 let at = |o: &crate::orchestration::Obligation| {
338 o.created_at.parse::<f64>().unwrap_or(0.0)
339 };
340 at(b.1)
341 .partial_cmp(&at(a.1))
342 .unwrap_or(std::cmp::Ordering::Equal)
343 });
344 found
345 .first()
346 .map(|(holder, o)| ((*holder).clone(), o.id.clone()))
347 };
348 let candidates = |pick: &dyn Fn(&crate::orchestration::Obligation) -> bool| {
349 holders
350 .iter()
351 .flat_map(|h| {
352 loaded.orchestration.profiles[h]
353 .obligations
354 .iter()
355 .filter(in_window)
356 .filter(|o| pick(o))
357 .map(move |o| (h, o))
358 })
359 .collect::<Vec<_>>()
360 };
361 let obligation = match &session_key {
362 Some(key) => latest(candidates(&|o| o.session_key.as_deref() == Some(key))),
363 None => None,
364 }
365 .or_else(|| {
366 let (platform, chat_id) = surface.as_ref()?;
367 latest(candidates(&|o| {
368 o.target.platform.as_deref() == Some(platform)
369 && o.target.chat_id.as_deref() == Some(chat_id)
370 }))
371 });
372 links.push((index, session_id, obligation));
373 }
374 for (index, session_id, obligation) in links {
375 let fire_id = {
376 let fire = &mut loaded.orchestration.profiles.get_mut(name).unwrap().fires[index];
377 fire.session_id = session_id;
378 fire.obligation_id = obligation.as_ref().map(|(_, id)| id.clone());
379 fire.id.clone()
380 };
381 if let Some((holder, obligation_id)) = obligation {
382 if let Some(o) = loaded
383 .orchestration
384 .profiles
385 .get_mut(&holder)
386 .and_then(|p| p.obligations.iter_mut().find(|o| o.id == obligation_id))
387 {
388 o.source = crate::orchestration::ObligationSource::Fire { fire_id };
389 }
390 }
391 }
392 }
393 for name in &names {
395 let profile = &loaded.orchestration.profiles[name];
396 let io = loaded.io.get_mut(name).unwrap();
397 io.snapshot.insert(
398 "cron/executions.db".into(),
399 canonical_json(&serde_json::to_value(&profile.fires).unwrap()),
400 );
401 io.snapshot
402 .insert("state.db".into(), canonical_json(&state_record(profile)));
403 }
404 Ok(())
405}
406
407fn job_surface(job: &crate::orchestration::Job) -> Option<(String, String)> {
412 use crate::orchestration::Target;
413 match &job.deliver {
414 Target::Origin => {
415 let origin = job.origin.as_ref()?;
416 Some((origin.platform.clone(), origin.chat_id.clone()?))
417 }
418 Target::Explicit {
419 platform, chat_id, ..
420 } => Some((platform.clone(), chat_id.clone()?)),
421 Target::Home | Target::Local => None,
422 }
423}
424
425fn fire_session(
429 sessions: &[Map<String, Value>],
430 fire: &crate::orchestration::Fire,
431) -> Option<String> {
432 let claimed = instant_key(&fire.claimed_at)?;
433 let finished = fire.finished_at.as_deref().and_then(instant_key);
434 let candidates = sessions.iter().filter_map(|row| {
435 let id = row.get("id").and_then(Value::as_str)?;
436 let key = cron_session_instant(id, &fire.job_id)?;
437 (key >= claimed && finished.is_none_or(|f| key <= f)).then(|| (key, id.to_string()))
438 });
439 let chosen = match finished {
440 Some(_) => candidates.max_by_key(|(key, _)| *key),
441 None => candidates.min_by_key(|(key, _)| *key),
442 }?;
443 let mut current = chosen.1;
444 for _ in 0..32 {
445 let row = sessions
446 .iter()
447 .find(|row| row.get("id").and_then(Value::as_str) == Some(current.as_str()));
448 let compressed = row
449 .and_then(|row| row.get("end_reason"))
450 .and_then(Value::as_str)
451 == Some("compression");
452 if !compressed {
453 return Some(current);
454 }
455 let next = sessions
456 .iter()
457 .filter(|row| {
458 row.get("parent_session_id").and_then(Value::as_str) == Some(current.as_str())
459 })
460 .max_by(|a, b| {
461 let at = |r: &Map<String, Value>| {
462 r.get("started_at").and_then(Value::as_f64).unwrap_or(0.0)
463 };
464 at(a)
465 .partial_cmp(&at(b))
466 .unwrap_or(std::cmp::Ordering::Equal)
467 .then_with(|| {
468 let id = |r: &Map<String, Value>| {
469 r.get("id")
470 .and_then(Value::as_str)
471 .unwrap_or("")
472 .to_string()
473 };
474 id(a).cmp(&id(b))
475 })
476 })
477 .and_then(|row| row.get("id").and_then(Value::as_str).map(str::to_string));
478 match next {
479 None => return Some(current),
480 Some(next) => current = next,
481 }
482 }
483 Some(current)
484}
485
486fn instant_key(iso: &str) -> Option<u64> {
488 let digits: String = iso
489 .chars()
490 .take_while(|c| *c != '+' && *c != 'Z')
491 .filter(char::is_ascii_digit)
492 .collect();
493 (digits.len() >= 14).then(|| digits[..14].parse().ok())?
494}
495
496fn cron_session_instant(session_id: &str, job_id: &str) -> Option<u64> {
498 if hermes_cron_job_id(session_id).as_deref() != Some(job_id) {
499 return None;
500 }
501 let stamp = session_id.rsplit_once('_')?;
502 let date = stamp.0.rsplit_once('_')?.1;
503 format!("{date}{}", stamp.1).parse().ok()
504}
505
506pub(crate) fn iso_epoch(iso: &str) -> Option<f64> {
508 let (instant, offset) = if let Some(instant) = iso.strip_suffix('Z') {
509 (instant, 0.0)
510 } else {
511 let time_at = iso.find('T')?;
512 let sign_at = iso[time_at..].find(['+', '-']).map(|i| i + time_at)?;
513 let (instant, offset) = iso.split_at(sign_at);
514 let (hours, minutes) = offset[1..].split_once(':')?;
515 let seconds = hours.parse::<f64>().ok()? * 3_600.0 + minutes.parse::<f64>().ok()? * 60.0;
516 (
517 instant,
518 if offset.starts_with('-') {
519 -seconds
520 } else {
521 seconds
522 },
523 )
524 };
525 let (date, time) = instant.split_once('T')?;
526 let mut date = date.splitn(3, '-');
527 let year: i64 = date.next()?.parse().ok()?;
528 let month: i64 = date.next()?.parse().ok()?;
529 let day: i64 = date.next()?.parse().ok()?;
530 let mut clock = time.splitn(3, ':');
531 let hour: i64 = clock.next()?.parse().ok()?;
532 let minute: i64 = clock.next()?.parse().ok()?;
533 let seconds: f64 = clock.next()?.parse().ok()?;
534 let year = year - i64::from(month <= 2);
535 let era = year.div_euclid(400);
536 let yoe = year - era * 400;
537 let doy = (153 * (if month > 2 { month - 3 } else { month + 9 }) + 2) / 5 + day - 1;
538 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
539 let days = era * 146_097 + doe - 719_468;
540 Some((days * 86_400 + hour * 3_600 + minute * 60) as f64 + seconds - offset)
541}
542
543fn partition_shared_store(loaded: &mut LoadedHome, root: &Path) -> Result<()> {
548 let root_path = root.join("state.db");
549 if !root_path.is_file() {
550 return Ok(());
551 }
552 let names: Vec<String> = loaded
553 .orchestration
554 .profiles
555 .keys()
556 .filter(|n| *n != "default")
557 .cloned()
558 .collect();
559 for name in names {
560 let has_own = loaded.orchestration.profiles[&name]
561 .dir
562 .join("state.db")
563 .is_file();
564 if has_own {
565 continue;
566 }
567 loaded.io.get_mut(&name).unwrap().borrowed_from = Some(root_path.clone());
568 loaded
569 .io
570 .get_mut("default")
571 .unwrap()
572 .lenders
573 .push(name.clone());
574 if table_exists(&root_path, "sessions") {
575 let rows = read_rows(
576 &root_path,
577 "select * from sessions where profile_name = ?1 order by started_at, id",
578 &[&name],
579 )?
580 .unwrap_or_default();
581 for row in rows {
582 if let Some(b) = binding_from_hermes_session(&root_path, &row, &name) {
583 loaded
584 .orchestration
585 .profiles
586 .get_mut(&name)
587 .unwrap()
588 .bindings
589 .insert(surface_key_string(&b.key), b);
590 }
591 }
592 }
593 let root_profile = loaded.orchestration.profiles.get_mut("default").unwrap();
595 let (mine, rest): (Vec<_>, Vec<_>) = root_profile.obligations.drain(..).partition(|o| {
596 o.session_key
597 .as_deref()
598 .and_then(parse_hermes_session_key)
599 .and_then(|(_, p)| p)
600 .as_deref()
601 == Some(name.as_str())
602 });
603 root_profile.obligations = rest;
604 loaded
605 .orchestration
606 .profiles
607 .get_mut(&name)
608 .unwrap()
609 .obligations = mine;
610 let snap = canonical_json(&state_record(&loaded.orchestration.profiles[&name]));
611 loaded
612 .io
613 .get_mut(&name)
614 .unwrap()
615 .snapshot
616 .insert("state.db".into(), snap);
617 }
618 let snap = canonical_json(&state_record(&loaded.orchestration.profiles["default"]));
619 loaded
620 .io
621 .get_mut("default")
622 .unwrap()
623 .snapshot
624 .insert("state.db".into(), snap);
625 Ok(())
626}
627
628const HERMES_SESSION_MAPPED: &[&str] = &[
629 "id",
630 "source",
631 "session_key",
632 "chat_id",
633 "chat_type",
634 "thread_id",
635 "user_id",
636 "profile_name",
637 "handoff_state",
638 "handoff_platform",
639 "handoff_error",
640 "started_at",
641 "ended_at",
642 "end_reason",
643];
644
645pub fn binding_from_hermes_session(
647 file: &Path,
648 row: &Map<String, Value>,
649 profile_name: &str,
650) -> Option<Binding> {
651 let text = |k: &str| {
652 row.get(k)
653 .and_then(|v| match v {
654 Value::String(s) => Some(s.clone()),
655 Value::Number(n) => Some(n.to_string()),
656 _ => None,
657 })
658 .filter(|s| !s.is_empty())
659 };
660 let num = |k: &str| row.get(k).and_then(Value::as_f64);
661 let id = text("id")?;
662 let source = text("source");
663 let parsed = text("session_key").and_then(|k| parse_hermes_session_key(&k));
666 let chat_type = text("chat_type").or_else(|| parsed.as_ref().and_then(|(k, _)| k.kind.clone()));
667 let key = if text("session_key").is_some()
668 && chat_type
669 .as_deref()
670 .is_some_and(|c| super::decode::CHAT_TYPES.contains(&c))
671 {
672 SurfaceKey {
673 key: None,
674 platform: source
675 .clone()
676 .or_else(|| parsed.as_ref().and_then(|(k, _)| k.platform.clone())),
677 kind: chat_type,
678 chat_id: text("chat_id")
679 .or_else(|| parsed.as_ref().and_then(|(k, _)| k.chat_id.clone())),
680 thread_id: text("thread_id")
681 .or_else(|| parsed.as_ref().and_then(|(k, _)| k.thread_id.clone())),
682 participant_id: parsed.as_ref().and_then(|(k, _)| k.participant_id.clone()),
683 }
684 } else if source.as_deref() == Some("cron") {
685 let job = crate::ontology::hermes_cron_job_id(&id).unwrap_or_else(|| id.clone());
686 SurfaceKey {
687 key: None,
688 platform: Some("cron".into()),
689 kind: Some("dm".into()),
690 chat_id: Some(job),
691 thread_id: None,
692 participant_id: None,
693 }
694 } else {
695 return None;
696 };
697 if let Some(p) = text("profile_name") {
698 if p != profile_name && !(profile_name == "default" && p == "main") {
699 return None; }
701 }
702 let iso = |v: Option<f64>| v.map(epoch_iso);
703 let end_word = text("end_reason");
704 let end_reason = end_word.as_deref().and_then(EndReason::parse);
705 let mut residue = Residue::default();
706 for (k, v) in row {
707 if !HERMES_SESSION_MAPPED.contains(&k.as_str()) && !v.is_null() {
708 residue.keep(k.clone(), v.clone());
709 }
710 }
711 if let (Some(word), None) = (&end_word, end_reason) {
712 residue.keep("end_reason", Value::String(word.clone()));
713 }
714 if let Some(u) = text("user_id") {
715 residue.keep("user_id", Value::String(u));
716 }
717 let recurrence = if key.platform.as_deref() == Some("cron") {
718 key.chat_id.clone().map(|job_id| Recurrence {
719 job_id,
720 kind: "cron".into(),
721 })
722 } else {
723 None
724 };
725 Some(Binding {
726 trigger: match (recurrence.is_some(), source.as_deref()) {
727 (true, _) => Trigger::Cron,
728 (_, Some(s)) => crate::ontology::hermes_trigger_for_source(s),
729 _ => Trigger::Unknown,
730 },
731 key,
732 profile: None,
733 worker: Worker {
734 harness: HarnessId::new(HarnessId::HERMES),
735 session_id: Some(id),
736 locator: Some(file.display().to_string()),
737 },
738 recurrence,
739 handoff: text("handoff_state").map(|state| Handoff {
740 to: text("handoff_platform"),
741 state,
742 error: text("handoff_error"),
743 }),
744 started_at: iso(num("started_at")),
745 last_activity_at: iso(num("ended_at").or_else(|| num("started_at"))),
746 ended_at: iso(num("ended_at")),
747 end_reason,
748 residue,
749 })
750}
751
752fn epoch_iso(seconds: f64) -> String {
754 let row = HermesSessionRow {
755 started_at: Some(seconds),
756 ..Default::default()
757 };
758 Binding::from_hermes_row(&row, None)
759 .started_at
760 .unwrap_or_default()
761}
762
763fn load_profile_dir(
764 name: &str,
765 dir: &Path,
766 flavor: Flavor,
767 vault: &mut BTreeMap<String, String>,
768) -> Result<(Profile, ProfileIo)> {
769 let mut profile = empty_profile(name, dir);
770 let mut config_normalized = false;
771 let mut subs_normalized = false;
772 let mut meta = ProfileIo::new(flavor);
773 meta.source_dir = Some(dir.to_path_buf());
774 let remember = |meta: &mut ProfileIo, rel: &str, raw: Option<String>, record: &Value| {
775 if let Some(raw) = raw {
776 meta.raw.insert(rel.to_string(), raw);
777 }
778 meta.snapshot
779 .insert(rel.to_string(), canonical_json(record));
780 };
781
782 if let Some(env) = read_text(dir, ".env")? {
784 for (k, v) in parse_dotenv(&env) {
785 vault.insert(k, v);
786 }
787 meta.raw.insert(".env".into(), env);
788 }
789
790 let cfg_file = dir.join("config.yaml").display().to_string();
792 let cfg_text = read_text(dir, "config.yaml")?;
793 let cfg = match &cfg_text {
794 Some(text) => yaml_to_json(&cfg_file, text)?,
795 None => Value::Object(Map::new()),
796 };
797 let cfg_map = cfg
798 .as_object()
799 .ok_or_else(|| load_error(&cfg_file, "", "expected a mapping"))?;
800 profile.worker = decode_worker(&cfg_file, cfg_map.get("worker"))?;
801 profile.expiry = decode_expiry(&cfg_file, cfg_map.get("expiry"))?;
802 profile.home = decode_home(&cfg_file, cfg_map.get("home"))?;
803 let gateway = cfg_map.get("gateway").and_then(Value::as_object);
804 let routes_raw: Vec<Value> = match cfg_map.get("profile_routes").and_then(Value::as_array) {
805 Some(a) => {
806 meta.routes_at_top = true;
807 a.clone()
808 }
809 None => gateway
810 .and_then(|g| g.get("profile_routes"))
811 .and_then(Value::as_array)
812 .cloned()
813 .unwrap_or_default(),
814 };
815 for (i, r) in routes_raw.iter().enumerate() {
816 profile.routes.push(decode_route(&cfg_file, i, r)?);
817 }
818 if let Some(platforms) = cfg_map.get("platforms") {
819 let map = platforms
820 .as_object()
821 .ok_or_else(|| load_error(&cfg_file, "platforms", "expected a map"))?;
822 let known: BTreeSet<String> = vault.keys().cloned().collect();
823 for (platform, raw) in map {
824 profile.channels.insert(
825 platform.clone(),
826 decode_channel(&cfg_file, platform, raw, vault)?,
827 );
828 }
829 config_normalized =
830 flavor == Flavor::Orchestrator && vault.keys().any(|k| !known.contains(k));
831 }
832 for (k, v) in cfg_map {
834 if CONFIG_O_KEYS.contains(&k.as_str()) || k == "platforms" || k == "profile_routes" {
835 continue;
836 }
837 if k == "gateway" {
838 let mut g = v.as_object().cloned().unwrap_or_default();
839 g.remove("profile_routes");
840 if !g.is_empty() {
841 profile
842 .residue
843 .config
844 .insert("gateway".into(), Value::Object(g));
845 }
846 continue;
847 }
848 profile.residue.config.insert(k.clone(), v.clone());
849 }
850 remember(&mut meta, "config.yaml", cfg_text, &config_record(&profile));
851 if config_normalized {
852 meta.raw.remove("config.yaml");
857 meta.snapshot.remove("config.yaml");
858 }
859
860 let persona_file = if flavor == Flavor::Hermes {
862 "SOUL.md"
863 } else {
864 "AGENTS.md"
865 };
866 let persona_text = read_text(dir, persona_file)?;
867 profile.persona = persona_text.as_ref().map(|t| PersonaRef {
868 path: "AGENTS.md".into(),
869 text: Some(t.clone()),
870 sha256: sha256_hex(t),
871 });
872 remember(
873 &mut meta,
874 persona_file,
875 persona_text,
876 &serde_json::to_value(&profile.persona).unwrap(),
877 );
878
879 let jobs_file = dir.join("cron/jobs.json").display().to_string();
881 let jobs_text = read_text(dir, "cron/jobs.json")?;
882 if let Some(text) = &jobs_text {
883 let parsed: Value = serde_json::from_str(text)
884 .map_err(|e| load_error(&jobs_file, "", format!("JSON: {e}")))?;
885 let arr: Vec<Value> = match &parsed {
886 Value::Array(a) => {
887 meta.jobs_form = Some(JobsForm {
888 object: false,
889 extras: Map::new(),
890 });
891 a.clone()
892 }
893 Value::Object(o) => match o.get("jobs") {
894 Some(Value::Array(a)) => {
895 let mut extras = o.clone();
896 extras.remove("jobs");
897 meta.jobs_form = Some(JobsForm {
898 object: true,
899 extras,
900 });
901 a.clone()
902 }
903 Some(Value::Object(m)) => {
904 let mut extras = o.clone();
905 extras.remove("jobs");
906 meta.jobs_form = Some(JobsForm {
907 object: true,
908 extras,
909 });
910 m.iter()
911 .map(|(id, j)| {
912 let mut j = j.as_object().cloned().unwrap_or_default();
913 j.insert("id".into(), Value::String(id.clone()));
914 Value::Object(j)
915 })
916 .collect()
917 }
918 _ => {
919 return Err(load_error(
920 &jobs_file,
921 "",
922 "expected an array of jobs or {\"jobs\": [...]}",
923 ))
924 }
925 },
926 _ => {
927 return Err(load_error(
928 &jobs_file,
929 "",
930 "expected an array of jobs or {\"jobs\": [...]}",
931 ))
932 }
933 };
934 for raw in &arr {
935 let job = decode_job(&jobs_file, raw)?;
936 if profile.jobs.contains_key(&job.id) {
937 return Err(load_error(&jobs_file, &job.id, "duplicate job id"));
938 }
939 profile.jobs.insert(job.id.clone(), job);
940 }
941 }
942 let jobs_record: Vec<Value> = profile
943 .jobs
944 .values()
945 .map(|j| serde_json::to_value(j).unwrap())
946 .collect();
947 remember(
948 &mut meta,
949 "cron/jobs.json",
950 jobs_text,
951 &Value::Array(jobs_record),
952 );
953
954 let exec_path = dir.join("cron/executions.db");
956 if table_exists(&exec_path, "executions") {
957 for row in read_rows(
958 &exec_path,
959 "select * from executions order by claimed_at, id",
960 &[],
961 )?
962 .unwrap_or_default()
963 {
964 profile
965 .fires
966 .push(decode_fire_row(&exec_path.display().to_string(), &row)?);
967 }
968 }
969 remember(
970 &mut meta,
971 "cron/executions.db",
972 None,
973 &serde_json::to_value(&profile.fires).unwrap(),
974 );
975
976 let subs_file = dir.join("webhook_subscriptions.json").display().to_string();
978 let subs_text = read_text(dir, "webhook_subscriptions.json")?;
979 if let Some(text) = &subs_text {
980 let parsed: Value = serde_json::from_str(text)
981 .map_err(|e| load_error(&subs_file, "", format!("JSON: {e}")))?;
982 let map = parsed
983 .as_object()
984 .ok_or_else(|| load_error(&subs_file, "", "expected a map"))?;
985 let known: BTreeSet<String> = vault.keys().cloned().collect();
986 for (n, raw) in map {
987 profile
988 .subscriptions
989 .insert(n.clone(), decode_subscription(&subs_file, n, raw, vault)?);
990 }
991 subs_normalized =
992 flavor == Flavor::Orchestrator && vault.keys().any(|k| !known.contains(k));
993 }
994 let subs_record: Vec<Value> = profile
995 .subscriptions
996 .values()
997 .map(|s| serde_json::to_value(s).unwrap())
998 .collect();
999 remember(
1000 &mut meta,
1001 "webhook_subscriptions.json",
1002 subs_text,
1003 &Value::Array(subs_record),
1004 );
1005 if subs_normalized {
1006 meta.raw.remove("webhook_subscriptions.json");
1007 meta.snapshot.remove("webhook_subscriptions.json");
1008 }
1009
1010 let access_file = dir.join("access.yaml").display().to_string();
1012 let access_text = read_text(dir, "access.yaml")?;
1013 let access_raw = match &access_text {
1014 Some(t) => Some(yaml_to_json(&access_file, t)?),
1015 None => None,
1016 };
1017 profile.access = decode_access(&access_file, access_raw.as_ref())?;
1018 remember(
1019 &mut meta,
1020 "access.yaml",
1021 access_text,
1022 &serde_json::to_value(&profile.access).unwrap(),
1023 );
1024
1025 let state_path = dir.join("state.db");
1027 if table_exists(&state_path, "delivery_obligations") {
1028 for row in read_rows(
1029 &state_path,
1030 "select * from delivery_obligations order by created_at, obligation_id",
1031 &[],
1032 )?
1033 .unwrap_or_default()
1034 {
1035 profile.obligations.push(decode_obligation_row(
1036 &state_path.display().to_string(),
1037 &row,
1038 )?);
1039 }
1040 }
1041 if flavor == Flavor::Orchestrator {
1042 if table_exists(&state_path, "bindings") {
1043 for row in read_rows(
1044 &state_path,
1045 "select * from bindings order by started_at, slot",
1046 &[],
1047 )?
1048 .unwrap_or_default()
1049 {
1050 let b = decode_binding_row(&state_path.display().to_string(), &row)?;
1051 let slot = row
1052 .get("slot")
1053 .and_then(Value::as_str)
1054 .map(str::to_string)
1055 .unwrap_or_else(|| surface_key_string(&b.key));
1056 profile.bindings.insert(slot, b);
1057 }
1058 }
1059 } else if table_exists(&state_path, "sessions") {
1060 for row in read_rows(
1061 &state_path,
1062 "select * from sessions order by started_at, id",
1063 &[],
1064 )?
1065 .unwrap_or_default()
1066 {
1067 if let Some(b) = binding_from_hermes_session(&state_path, &row, name) {
1068 profile.bindings.insert(surface_key_string(&b.key), b);
1069 }
1070 }
1071 }
1072 remember(&mut meta, "state.db", None, &state_record(&profile));
1073
1074 profile.residue.files = list_unmodeled(dir, flavor)?;
1076 Ok((profile, meta))
1077}
1078
1079fn list_unmodeled(dir: &Path, flavor: Flavor) -> Result<Vec<String>> {
1080 let mut owned: Vec<&str> = OWNED_FILES.to_vec();
1081 if flavor == Flavor::Hermes {
1082 owned.push("SOUL.md");
1083 owned.retain(|f| !["AGENTS.md", "CLAUDE.md", "access.yaml"].contains(f));
1084 }
1085 let runtime_artifacts = ["orchestrator.lock", "orchestrator.sock", "service"];
1086 let mut out = Vec::new();
1087 fn walk(
1088 base: &Path,
1089 d: &Path,
1090 owned: &[&str],
1091 runtime: &[&str],
1092 out: &mut Vec<String>,
1093 ) -> Result<()> {
1094 let entries = match fs::read_dir(d) {
1095 Ok(entries) => entries,
1096 Err(error) if d != base && error.kind() == std::io::ErrorKind::NotFound => {
1098 return Ok(());
1099 }
1100 Err(error) => return Err(error.into()),
1101 };
1102 let mut entries = entries.collect::<std::io::Result<Vec<_>>>()?;
1103 entries.sort_by_key(|e| e.file_name());
1104 for entry in entries {
1105 let p = entry.path();
1106 let rel = p
1107 .strip_prefix(base)
1108 .unwrap_or(&p)
1109 .to_string_lossy()
1110 .replace('\\', "/");
1111 let name = entry.file_name().to_string_lossy().into_owned();
1112 if rel == "profiles"
1113 || name == "node_modules"
1114 || name == ".git"
1115 || rel.starts_with("state.db")
1116 || rel.starts_with("cron/executions.db")
1117 {
1118 continue;
1119 }
1120 if runtime.contains(&rel.as_str()) || regex_tmp(&name) {
1121 continue;
1122 }
1123 let st = match fs::symlink_metadata(&p) {
1124 Ok(metadata) => metadata,
1125 Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
1127 Err(error) => return Err(error.into()),
1128 };
1129 if st.is_dir() {
1130 walk(base, &p, owned, runtime, out)?;
1131 continue;
1132 }
1133 if !st.is_file() {
1134 continue;
1135 }
1136 if owned.contains(&rel.as_str()) {
1137 continue;
1138 }
1139 out.push(rel);
1140 }
1141 Ok(())
1142 }
1143 walk(dir, dir, &owned, &runtime_artifacts, &mut out)?;
1144 Ok(out)
1145}
1146
1147fn regex_tmp(name: &str) -> bool {
1148 name.rsplit_once(".tmp-")
1150 .is_some_and(|(_, pid)| !pid.is_empty() && pid.chars().all(|c| c.is_ascii_digit()))
1151}
1152
1153fn write_atomic(path: &Path, text: &str) -> Result<()> {
1156 if let Some(parent) = path.parent() {
1157 fs::create_dir_all(parent)?;
1158 }
1159 let tmp = path.with_file_name(format!(
1160 "{}.tmp-{}",
1161 path.file_name().unwrap().to_string_lossy(),
1162 std::process::id()
1163 ));
1164 write_with_mode(&tmp, text, target_mode(path))?;
1165 fs::rename(&tmp, path)?;
1166 Ok(())
1167}
1168
1169#[cfg(unix)]
1173fn target_mode(path: &Path) -> Option<u32> {
1174 use std::os::unix::fs::PermissionsExt;
1175 let current = fs::metadata(path)
1176 .ok()
1177 .map(|m| m.permissions().mode() & 0o777);
1178 if path.file_name().is_some_and(|name| name == ".env") {
1179 return Some(current.unwrap_or(0o600) & 0o600);
1180 }
1181 current
1182}
1183
1184#[cfg(not(unix))]
1185fn target_mode(_path: &Path) -> Option<u32> {
1186 None
1187}
1188
1189#[cfg(unix)]
1190fn write_with_mode(path: &Path, text: &str, mode: Option<u32>) -> Result<()> {
1191 use std::io::Write;
1192 use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
1193 let mut options = fs::OpenOptions::new();
1194 options.write(true).create(true).truncate(true);
1195 if let Some(mode) = mode {
1196 options.mode(mode);
1197 }
1198 let mut file = options.open(path)?;
1199 if let Some(mode) = mode {
1201 file.set_permissions(fs::Permissions::from_mode(mode))?;
1202 }
1203 file.write_all(text.as_bytes())?;
1204 Ok(())
1205}
1206
1207#[cfg(not(unix))]
1208fn write_with_mode(path: &Path, text: &str, _mode: Option<u32>) -> Result<()> {
1209 fs::write(path, text)?;
1210 Ok(())
1211}
1212
1213pub fn encode_config(
1215 profile: &Profile,
1216 meta: Option<&ProfileIo>,
1217 vault: Option<&BTreeMap<String, String>>,
1218 flavor: Flavor,
1219) -> String {
1220 let mut out = Map::new();
1221 for (k, v) in &profile.residue.config {
1222 if k != "gateway" {
1223 out.insert(k.clone(), v.clone());
1224 }
1225 }
1226 if let Some(w) = &profile.worker {
1227 let mut wm = Map::new();
1228 wm.insert("harness".into(), Value::String(w.harness.as_str().into()));
1229 if let Some(m) = &w.model {
1230 wm.insert("model".into(), Value::String(m.clone()));
1231 }
1232 if let Some(p) = &w.preset {
1233 wm.insert("preset".into(), Value::String(p.clone()));
1234 }
1235 if w.cwd != "." {
1236 wm.insert("cwd".into(), Value::String(w.cwd.clone()));
1237 }
1238 if !w.env.is_empty() {
1239 wm.insert("env".into(), serde_json::to_value(&w.env).unwrap());
1240 }
1241 if w.permission.timeout_seconds != 300
1242 || w.permission.default != crate::orchestration::PermissionDefault::Deny
1243 || w.permission.unattended != crate::orchestration::PermissionUnattended::Deny
1244 {
1245 wm.insert(
1246 "permission".into(),
1247 serde_json::to_value(&w.permission).unwrap(),
1248 );
1249 }
1250 out.insert("worker".into(), Value::Object(wm));
1251 }
1252 if flavor == Flavor::Orchestrator || profile.expiry != ExpiryPolicy::default() {
1253 out.insert(
1254 "expiry".into(),
1255 serde_json::to_value(&profile.expiry).unwrap(),
1256 );
1257 }
1258 if let Some(h) = &profile.home {
1259 out.insert("home".into(), encode_surface_key(h));
1260 }
1261 let mut gateway = profile
1262 .residue
1263 .config
1264 .get("gateway")
1265 .and_then(Value::as_object)
1266 .cloned()
1267 .unwrap_or_default();
1268 let routes: Vec<Value> = profile.routes.iter().map(encode_route).collect();
1269 if meta.is_some_and(|m| m.routes_at_top) {
1270 if !routes.is_empty() {
1271 out.insert("profile_routes".into(), Value::Array(routes));
1272 }
1273 } else if !routes.is_empty() {
1274 gateway.insert("profile_routes".into(), Value::Array(routes));
1275 }
1276 if !gateway.is_empty() {
1277 out.insert("gateway".into(), Value::Object(gateway));
1278 }
1279 let mut platforms = Map::new();
1280 for (p, ch) in &profile.channels {
1281 platforms.insert(
1282 p.clone(),
1283 encode_channel(
1284 ch,
1285 if flavor == Flavor::Hermes {
1286 vault
1287 } else {
1288 None
1289 },
1290 ),
1291 );
1292 }
1293 if !platforms.is_empty() {
1294 out.insert("platforms".into(), Value::Object(platforms));
1295 }
1296 json_to_yaml(&Value::Object(out))
1297}
1298
1299pub fn encode_jobs_file(profile: &Profile, meta: Option<&ProfileIo>) -> String {
1301 let jobs: Vec<Value> = profile
1302 .jobs
1303 .values()
1304 .map(|j| ordered_object(encode_job(j)))
1305 .collect();
1306 let form = meta.and_then(|m| m.jobs_form.clone()).unwrap_or(JobsForm {
1307 object: true,
1308 extras: Map::new(),
1309 });
1310 let body = if form.object {
1311 let mut pairs = vec![("jobs".to_string(), Value::Array(jobs))];
1312 pairs.extend(form.extras.iter().map(|(k, v)| (k.clone(), v.clone())));
1313 ordered_object(pairs)
1314 } else {
1315 Value::Array(jobs)
1316 };
1317 format!("{}\n", pretty_ordered(&body, 0))
1318}
1319
1320pub(crate) fn ordered_object(pairs: Vec<(String, Value)>) -> Value {
1323 Value::Array(vec![
1326 Value::String("__ordered__".into()),
1327 Value::Array(
1328 pairs
1329 .into_iter()
1330 .map(|(k, v)| serde_json::json!({"__k": k, "__v": v}))
1331 .collect(),
1332 ),
1333 ])
1334}
1335
1336fn is_ordered(value: &Value) -> Option<&Vec<Value>> {
1337 let arr = value.as_array()?;
1338 if arr.len() == 2 && arr[0].as_str() == Some("__ordered__") {
1339 arr[1].as_array()
1340 } else {
1341 None
1342 }
1343}
1344
1345pub(crate) fn pretty_ordered(value: &Value, depth: usize) -> String {
1347 let pad = |d: usize| " ".repeat(d);
1348 if let Some(pairs) = is_ordered(value) {
1349 if pairs.is_empty() {
1350 return "{}".into();
1351 }
1352 let inner: Vec<String> = pairs
1353 .iter()
1354 .map(|p| {
1355 format!(
1356 "{}{}: {}",
1357 pad(depth + 1),
1358 serde_json::to_string(p["__k"].as_str().unwrap_or("")).unwrap(),
1359 pretty_ordered(&p["__v"], depth + 1)
1360 )
1361 })
1362 .collect();
1363 return format!("{{\n{}\n{}}}", inner.join(",\n"), pad(depth));
1364 }
1365 match value {
1366 Value::Array(items) if items.is_empty() => "[]".into(),
1367 Value::Array(items) => {
1368 let inner: Vec<String> = items
1369 .iter()
1370 .map(|v| format!("{}{}", pad(depth + 1), pretty_ordered(v, depth + 1)))
1371 .collect();
1372 format!("[\n{}\n{}]", inner.join(",\n"), pad(depth))
1373 }
1374 Value::Object(o) if o.is_empty() => "{}".into(),
1375 Value::Object(o) => {
1376 let inner: Vec<String> = o
1377 .iter()
1378 .map(|(k, v)| {
1379 format!(
1380 "{}{}: {}",
1381 pad(depth + 1),
1382 serde_json::to_string(k).unwrap(),
1383 pretty_ordered(v, depth + 1)
1384 )
1385 })
1386 .collect();
1387 format!("{{\n{}\n{}}}", inner.join(",\n"), pad(depth))
1388 }
1389 Value::Number(n) => {
1390 if let Some(f) = n.as_f64() {
1391 if n.is_f64() && f.fract() == 0.0 && f.abs() < 1e21 {
1392 return format!("{}", f as i64);
1393 }
1394 }
1395 n.to_string()
1396 }
1397 other => serde_json::to_string(other).unwrap(),
1398 }
1399}
1400
1401pub fn encode_subscriptions_file(
1403 profile: &Profile,
1404 vault: Option<&BTreeMap<String, String>>,
1405) -> String {
1406 let mut out = Map::new();
1407 for (n, s) in &profile.subscriptions {
1408 out.insert(n.clone(), encode_subscription(s, vault));
1409 }
1410 format!("{}\n", pretty_ordered(&Value::Object(out), 0))
1411}
1412
1413pub fn encode_access_file(profile: &Profile) -> String {
1415 json_to_yaml(&encode_access(&profile.access))
1416}
1417
1418const EXECUTIONS_DDL: &str = "CREATE TABLE IF NOT EXISTS executions (
1419 id TEXT PRIMARY KEY, job_id TEXT NOT NULL, source TEXT NOT NULL, process_id TEXT NOT NULL, pid INTEGER NOT NULL,
1420 process_started_at INTEGER, status TEXT NOT NULL CHECK(status IN ('claimed','running','completed','failed','unknown')),
1421 claimed_at TEXT NOT NULL, started_at TEXT, finished_at TEXT, error TEXT);
1422CREATE INDEX IF NOT EXISTS idx_executions_job_claimed ON executions(job_id, claimed_at DESC, id DESC);
1423CREATE INDEX IF NOT EXISTS idx_executions_status_claimed ON executions(status, claimed_at DESC, id DESC);";
1424const FOLDER_EXECUTIONS_DDL: &str = "CREATE TABLE IF NOT EXISTS executions (
1429 id TEXT PRIMARY KEY, job_id TEXT NOT NULL, source TEXT NOT NULL, process_id TEXT NOT NULL, pid INTEGER NOT NULL,
1430 process_started_at INTEGER, status TEXT NOT NULL CHECK(status IN ('claimed','running','completed','failed','unknown')),
1431 claimed_at TEXT NOT NULL, started_at TEXT, finished_at TEXT, error TEXT, residue_json TEXT,
1432 session_id TEXT, obligation_id TEXT);
1433CREATE INDEX IF NOT EXISTS idx_executions_job_claimed ON executions(job_id, claimed_at DESC, id DESC);
1434CREATE INDEX IF NOT EXISTS idx_executions_status_claimed ON executions(status, claimed_at DESC, id DESC);";
1435
1436const OBLIGATIONS_DDL: &str = "CREATE TABLE IF NOT EXISTS delivery_obligations (
1437 obligation_id TEXT PRIMARY KEY, session_key TEXT NOT NULL, platform TEXT NOT NULL, chat_id TEXT NOT NULL, thread_id TEXT,
1438 content TEXT NOT NULL, state TEXT NOT NULL, attempts INTEGER NOT NULL DEFAULT 0, created_at REAL NOT NULL, updated_at REAL NOT NULL,
1439 owner_pid INTEGER, owner_started_at INTEGER, last_error TEXT, adapter_profile TEXT,
1440 posted_message_id TEXT, source_json TEXT);";
1441
1442const BINDINGS_DDL: &str = "CREATE TABLE IF NOT EXISTS bindings (
1443 slot TEXT PRIMARY KEY,
1444 platform TEXT NOT NULL, chat_type TEXT NOT NULL, chat_id TEXT, thread_id TEXT, participant_id TEXT,
1445 worker_harness TEXT NOT NULL, worker_session_id TEXT, worker_locator TEXT,
1446 started_at TEXT NOT NULL, last_activity_at TEXT NOT NULL, ended_at TEXT, end_reason TEXT,
1447 handoff_to TEXT, handoff_state TEXT, handoff_error TEXT, recurrence_job_id TEXT, residue_json TEXT);";
1448
1449pub fn write_executions(path: &Path, fires: &[crate::orchestration::Fire]) -> Result<()> {
1451 write_executions_shaped(path, fires, false)
1452}
1453
1454pub fn write_executions_shaped(
1457 path: &Path,
1458 fires: &[crate::orchestration::Fire],
1459 ours: bool,
1460) -> Result<()> {
1461 let mut cols: Vec<&str> = EXECUTION_COLUMNS.to_vec();
1462 if ours {
1463 cols.extend(["residue_json", "session_id", "obligation_id"]);
1466 }
1467 let insert = format!(
1468 "insert into executions ({}) values ({})",
1469 cols.join(", "),
1470 cols.iter().map(|_| "?").collect::<Vec<_>>().join(",")
1471 );
1472 let rows: Vec<Vec<Param>> = fires
1473 .iter()
1474 .map(|f| {
1475 let mut row: Vec<Param> = encode_fire_row(f).iter().map(Param::from).collect();
1476 if ours {
1477 let rest: serde_json::Map<String, Value> = f
1479 .residue
1480 .0
1481 .iter()
1482 .filter(|(k, _)| {
1483 !["source", "process_id", "pid", "process_started_at"].contains(&k.as_str())
1484 })
1485 .map(|(k, v)| (k.clone(), v.clone()))
1486 .collect();
1487 row.push(if rest.is_empty() {
1488 Param::Null
1489 } else {
1490 Param::Text(serde_json::to_string(&rest).unwrap())
1491 });
1492 let opt = |v: &Option<String>| v.clone().map(Param::Text).unwrap_or(Param::Null);
1493 row.push(opt(&f.session_id));
1494 row.push(opt(&f.obligation_id));
1495 }
1496 row
1497 })
1498 .collect();
1499 write_table(
1500 path,
1501 if ours {
1502 FOLDER_EXECUTIONS_DDL
1503 } else {
1504 EXECUTIONS_DDL
1505 },
1506 &insert,
1507 &rows,
1508 )
1509}
1510
1511fn write_state(path: &Path, profile: &Profile) -> Result<()> {
1512 let cols = [
1513 "slot",
1514 "platform",
1515 "chat_type",
1516 "chat_id",
1517 "thread_id",
1518 "participant_id",
1519 "worker_harness",
1520 "worker_session_id",
1521 "worker_locator",
1522 "started_at",
1523 "last_activity_at",
1524 "ended_at",
1525 "end_reason",
1526 "handoff_to",
1527 "handoff_state",
1528 "handoff_error",
1529 "recurrence_job_id",
1530 "residue_json",
1531 ];
1532 let insert = format!(
1533 "insert into bindings ({}) values ({})",
1534 cols.join(", "),
1535 cols.iter().map(|_| "?").collect::<Vec<_>>().join(",")
1536 );
1537 let s = |v: &Option<String>| v.clone().map(Param::Text).unwrap_or(Param::Null);
1538 let rows: Vec<Vec<Param>> = profile
1539 .bindings
1540 .iter()
1541 .map(|(slot, b)| {
1542 vec![
1543 Param::Text(slot.clone()),
1544 Param::Text(b.key.platform.clone().unwrap_or_default()),
1545 Param::Text(b.key.kind.clone().unwrap_or_default()),
1546 Param::Text(b.key.chat_id.clone().unwrap_or_default()),
1547 Param::Text(b.key.thread_id.clone().unwrap_or_default()),
1548 Param::Text(b.key.participant_id.clone().unwrap_or_default()),
1549 Param::Text(b.worker.harness.as_str().into()),
1550 s(&b.worker.session_id.clone().filter(|v| !v.is_empty())),
1551 s(&b.worker.locator),
1552 Param::Text(b.started_at.clone().unwrap_or_default()),
1553 Param::Text(b.last_activity_at.clone().unwrap_or_default()),
1554 s(&b.ended_at),
1555 b.end_reason
1556 .map(|r| Param::Text(r.as_str().into()))
1557 .unwrap_or(Param::Null),
1558 s(&b.handoff.as_ref().and_then(|h| h.to.clone())),
1559 b.handoff
1560 .as_ref()
1561 .map(|h| Param::Text(h.state.clone()))
1562 .unwrap_or(Param::Null),
1563 s(&b.handoff.as_ref().and_then(|h| h.error.clone())),
1564 s(&b.recurrence.as_ref().map(|r| r.job_id.clone())),
1565 if b.residue.is_empty() {
1566 Param::Null
1567 } else {
1568 Param::Text(serde_json::to_string(&b.residue).unwrap())
1569 },
1570 ]
1571 })
1572 .collect();
1573 write_table(
1574 path,
1575 &format!("{BINDINGS_DDL}\n{OBLIGATIONS_DDL}"),
1576 &insert,
1577 &rows,
1578 )?;
1579 let cols: Vec<&str> = OBLIGATION_COLUMNS
1581 .iter()
1582 .chain(FOLDER_OBLIGATION_EXTRA_COLUMNS.iter())
1583 .copied()
1584 .collect();
1585 let insert = format!(
1586 "insert into delivery_obligations ({}) values ({})",
1587 cols.join(", "),
1588 cols.iter().map(|_| "?").collect::<Vec<_>>().join(",")
1589 );
1590 let rows: Vec<Vec<Param>> = profile
1591 .obligations
1592 .iter()
1593 .map(|o| {
1594 encode_obligation_row(o)
1595 .iter()
1596 .chain(encode_obligation_folder_extras(o).iter())
1597 .map(Param::from)
1598 .collect()
1599 })
1600 .collect();
1601 write_table(path, "", &insert, &rows)
1602}
1603
1604fn write_if_changed(
1605 meta: &mut ProfileIo,
1606 dir: &Path,
1607 rel: &str,
1608 record: &Value,
1609 render: impl FnOnce() -> Option<String>,
1610) -> Result<bool> {
1611 let snap = canonical_json(record);
1612 let path = dir.join(rel);
1613 let reuse = meta.flavor == Flavor::Orchestrator && meta.snapshot.get(rel) == Some(&snap);
1616 if reuse && path.exists() {
1617 return Ok(false);
1618 }
1619 if reuse {
1620 if let Some(raw) = meta.raw.get(rel).cloned() {
1621 write_atomic(&path, &raw)?;
1622 return Ok(true);
1623 }
1624 }
1625 let Some(text) = render() else {
1626 return Ok(false);
1627 };
1628 write_atomic(&path, &text)?;
1629 meta.raw.insert(rel.into(), text);
1630 meta.snapshot.insert(rel.into(), snap);
1631 meta.flavor = Flavor::Orchestrator; Ok(true)
1633}
1634
1635pub fn save_home(loaded: &mut LoadedHome, root: Option<&Path>) -> Result<()> {
1637 let root = root
1638 .map(Path::to_path_buf)
1639 .unwrap_or_else(|| loaded.orchestration.root.clone());
1640 fs::create_dir_all(&root)?;
1641 let names: Vec<String> = loaded.orchestration.profiles.keys().cloned().collect();
1642 for name in names {
1643 let dir = if name == "default" {
1644 root.clone()
1645 } else {
1646 root.join("profiles").join(&name)
1647 };
1648 fs::create_dir_all(dir.join("cron"))?;
1649 let profile = loaded.orchestration.profiles[&name].clone();
1650 let meta = loaded
1651 .io
1652 .entry(name.clone())
1653 .or_insert_with(|| ProfileIo::new(Flavor::Orchestrator));
1654 save_profile_dir(&profile, meta, &dir, &loaded.vault)?;
1655 }
1656 Ok(())
1657}
1658
1659fn save_profile_dir(
1660 profile: &Profile,
1661 meta: &mut ProfileIo,
1662 dir: &Path,
1663 vault: &BTreeMap<String, String>,
1664) -> Result<()> {
1665 let cfg_record = config_record(profile);
1666 write_if_changed(meta, dir, "config.yaml", &cfg_record, || {
1667 Some(encode_config(
1668 profile,
1669 None,
1670 Some(vault),
1671 Flavor::Orchestrator,
1672 ))
1673 })?;
1674 if let Some(persona) = &profile.persona {
1675 let text = persona.text.clone().unwrap_or_default();
1676 write_if_changed(
1677 meta,
1678 dir,
1679 "AGENTS.md",
1680 &serde_json::to_value(&profile.persona).unwrap(),
1681 || Some(text),
1682 )?;
1683 if !dir.join("CLAUDE.md").exists() {
1684 write_atomic(&dir.join("CLAUDE.md"), "@AGENTS.md\n")?;
1685 }
1686 }
1687 let jobs_record: Vec<Value> = profile
1688 .jobs
1689 .values()
1690 .map(|j| serde_json::to_value(j).unwrap())
1691 .collect();
1692 let had_jobs = meta.raw.contains_key("cron/jobs.json");
1693 let form = meta.jobs_form.clone();
1694 write_if_changed(
1695 meta,
1696 dir,
1697 "cron/jobs.json",
1698 &Value::Array(jobs_record),
1699 || {
1700 if profile.jobs.is_empty() && !had_jobs {
1701 None
1702 } else {
1703 let stub = ProfileIo {
1704 jobs_form: form.clone(),
1705 ..ProfileIo::new(Flavor::Orchestrator)
1706 };
1707 Some(encode_jobs_file(profile, Some(&stub)))
1708 }
1709 },
1710 )?;
1711 let subs_record: Vec<Value> = profile
1712 .subscriptions
1713 .values()
1714 .map(|s| serde_json::to_value(s).unwrap())
1715 .collect();
1716 let had_subs = meta.raw.contains_key("webhook_subscriptions.json");
1717 write_if_changed(
1718 meta,
1719 dir,
1720 "webhook_subscriptions.json",
1721 &Value::Array(subs_record),
1722 || {
1723 if profile.subscriptions.is_empty() && !had_subs {
1724 None
1725 } else {
1726 Some(encode_subscriptions_file(profile, None))
1727 }
1728 },
1729 )?;
1730 let a = &profile.access;
1731 let access_empty = a.allowlist.is_empty()
1732 && a.admins.is_empty()
1733 && a.pending_pairings.is_empty()
1734 && a.policy.is_empty()
1735 && a.pairing_ttl_minutes.is_none();
1736 let had_access = meta.raw.contains_key("access.yaml");
1737 write_if_changed(
1738 meta,
1739 dir,
1740 "access.yaml",
1741 &serde_json::to_value(a).unwrap(),
1742 || {
1743 if access_empty && !had_access {
1744 None
1745 } else {
1746 Some(encode_access_file(profile))
1747 }
1748 },
1749 )?;
1750
1751 let fires_snap = canonical_json(&serde_json::to_value(&profile.fires).unwrap());
1753 let exec_path = dir.join("cron/executions.db");
1754 if (meta.snapshot.get("cron/executions.db") != Some(&fires_snap) || !exec_path.exists())
1755 && (!profile.fires.is_empty() || exec_path.exists())
1756 {
1757 let tmp = exec_path.with_file_name(format!("executions.db.tmp-{}", std::process::id()));
1758 let _ = fs::remove_file(&tmp);
1759 write_executions_shaped(&tmp, &profile.fires, true)?;
1760 fs::rename(&tmp, &exec_path)?;
1761 meta.snapshot
1762 .insert("cron/executions.db".into(), fires_snap);
1763 }
1764 let state_snap = canonical_json(&state_record(profile));
1765 let state_path = dir.join("state.db");
1766 if (meta.snapshot.get("state.db") != Some(&state_snap) || !state_path.exists())
1767 && (!profile.bindings.is_empty() || !profile.obligations.is_empty() || state_path.exists())
1768 {
1769 let tmp = state_path.with_file_name(format!("state.db.tmp-{}", std::process::id()));
1770 let _ = fs::remove_file(&tmp);
1771 write_state(&tmp, profile)?;
1772 fs::rename(&tmp, &state_path)?;
1773 meta.snapshot.insert("state.db".into(), state_snap);
1774 }
1775
1776 let mut refs: Vec<String> = Vec::new();
1778 for ch in profile.channels.values() {
1779 for r in ch.credentials.values() {
1780 if let crate::ontology::SecretRef::Dotenv(n) = r {
1781 refs.push(n.clone());
1782 }
1783 }
1784 }
1785 for s in profile.subscriptions.values() {
1786 if let Some(crate::ontology::SecretRef::Dotenv(n)) = &s.secret {
1787 refs.push(n.clone());
1788 }
1789 }
1790 if let Some(w) = &profile.worker {
1791 for v in w.env.values() {
1792 if let crate::orchestration::EnvValue::Secret(crate::ontology::SecretRef::Dotenv(n)) = v
1793 {
1794 refs.push(n.clone());
1795 }
1796 }
1797 }
1798 let mut entries: BTreeMap<String, String> = BTreeMap::new();
1799 for r in refs {
1800 if let Some(v) = vault.get(&r) {
1801 entries.insert(r, v.clone());
1802 }
1803 }
1804 if !entries.is_empty() {
1805 let existing = meta.raw.get(".env").cloned();
1806 let mut merged = existing.as_deref().map(parse_dotenv).unwrap_or_default();
1807 for (k, v) in entries {
1808 merged.insert(k, v);
1809 }
1810 let text = render_dotenv(&merged);
1811 if existing.as_deref() != Some(text.as_str()) {
1812 write_atomic(&dir.join(".env"), &text)?;
1813 meta.raw.insert(".env".into(), text);
1814 }
1815 }
1816 Ok(())
1817}
1818
1819pub fn copy_unmodeled(profile: &Profile, meta: &ProfileIo, dest: &Path) -> Result<()> {
1821 let Some(src) = &meta.source_dir else {
1822 return Ok(());
1823 };
1824 carry_unmodeled(&profile.residue.files, src, dest)?;
1825 Ok(())
1826}
1827
1828pub fn carry_unmodeled(files: &[String], src: &Path, dest: &Path) -> Result<Vec<String>> {
1832 let mut carried = Vec::new();
1833 for rel in files {
1834 let from = src.join(rel);
1835 if !from.is_file() {
1836 continue;
1837 }
1838 let to = dest.join(rel);
1839 if let Some(parent) = to.parent() {
1840 fs::create_dir_all(parent)?;
1841 }
1842 fs::copy(&from, &to)?;
1843 carried.push(rel.clone());
1844 }
1845 Ok(carried)
1846}