1use std::collections::BTreeMap;
18use std::path::{Path, PathBuf};
19
20use serde::{Deserialize, Serialize};
21
22use supercode_interchange::ontology::ArtifactFidelity;
23use supercode_interchange::orchestration::codec::folder::OWNED_FILES;
24use supercode_interchange::orchestration::codec::{
25 carry_unmodeled, from_hermes, from_openclaw, load_home, save_home, to_hermes, to_openclaw,
26 Flavor, LoadedHome, Refusal,
27};
28use supercode_interchange::orchestration::Orchestration;
29
30use crate::Result;
31
32#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(rename_all = "snake_case")]
35pub enum HomeFlavor {
36 #[default]
38 Orchestrator,
39 Hermes,
41}
42
43impl From<HomeFlavor> for Flavor {
44 fn from(flavor: HomeFlavor) -> Self {
45 match flavor {
46 HomeFlavor::Orchestrator => Flavor::Orchestrator,
47 HomeFlavor::Hermes => Flavor::Hermes,
48 }
49 }
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
54#[serde(rename_all = "snake_case")]
55pub enum SourceFlavor {
56 #[default]
58 Native,
59 Orchestrator,
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
65#[serde(rename_all = "snake_case")]
66pub enum OrchestrationHarness {
67 Hermes,
69 Openclaw,
71}
72
73#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
75pub struct RefusalRow {
76 pub file: String,
78 pub reason: String,
80}
81
82impl From<&Refusal> for RefusalRow {
83 fn from(refusal: &Refusal) -> Self {
84 Self {
85 file: refusal.file.clone(),
86 reason: refusal.reason.clone(),
87 }
88 }
89}
90
91#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
93pub struct OrchestrationRead {
94 pub orchestration: Orchestration,
96 pub vault_keys: Vec<String>,
98}
99
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
102pub struct OrchestrationSaved {
103 pub written: bool,
105 pub root: PathBuf,
107}
108
109#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
111pub struct OrchestrationDecompiled {
112 pub written: Vec<ArtifactFidelity>,
114 pub refused: Vec<RefusalRow>,
116 #[serde(default, skip_serializing_if = "Vec::is_empty")]
118 pub notes: Vec<String>,
119 #[serde(default, skip_serializing_if = "Option::is_none")]
121 pub rows_byte: Option<usize>,
122 #[serde(default, skip_serializing_if = "Option::is_none")]
124 pub rows_emitted: Option<usize>,
125 #[serde(default, skip_serializing_if = "Option::is_none")]
127 pub loss: Option<supercode_interchange::orchestration::codec::LossReport>,
128}
129
130#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
133pub struct OrchestrationImported {
134 pub orchestration: Orchestration,
136 pub vault_keys: Vec<String>,
138 pub root: PathBuf,
140 pub carried: Vec<String>,
142}
143
144fn keys(vault: &BTreeMap<String, String>) -> Vec<String> {
145 vault.keys().cloned().collect()
146}
147
148fn repoint(orchestration: &mut Orchestration, root: &Path) {
152 orchestration.root = root.to_path_buf();
153 for (name, profile) in orchestration.profiles.iter_mut() {
154 profile.dir = if name == "default" {
155 root.to_path_buf()
156 } else {
157 root.join("profiles").join(name)
158 };
159 }
160}
161
162fn patiently<T>(
167 read: impl Fn() -> std::result::Result<T, supercode_interchange::InterchangeError>,
168) -> std::result::Result<T, supercode_interchange::InterchangeError> {
169 match read() {
170 Err(supercode_interchange::InterchangeError::Io(ref io))
171 if io.kind() == std::io::ErrorKind::NotFound =>
172 {
173 std::thread::sleep(std::time::Duration::from_millis(150));
174 read()
175 }
176 other => other,
177 }
178}
179
180pub fn load(root: &Path, flavor: HomeFlavor) -> Result<OrchestrationRead> {
182 let loaded = patiently(|| load_home(root, flavor.into()))?;
183 Ok(OrchestrationRead {
184 vault_keys: keys(&loaded.vault),
185 orchestration: loaded.orchestration,
186 })
187}
188
189pub fn save(
196 root: &Path,
197 orchestration: Orchestration,
198 vault: BTreeMap<String, String>,
199) -> Result<OrchestrationSaved> {
200 let mut loaded = if root.is_dir() {
201 load_home(root, Flavor::Orchestrator)?
202 } else {
203 LoadedHome {
204 orchestration: orchestration.clone(),
205 vault: BTreeMap::new(),
206 io: BTreeMap::new(),
207 }
208 };
209 loaded.orchestration = orchestration;
210 repoint(&mut loaded.orchestration, root);
211 loaded.vault.extend(vault);
212 save_home(&mut loaded, Some(root))?;
213 Ok(OrchestrationSaved {
214 written: true,
215 root: root.to_path_buf(),
216 })
217}
218
219pub fn compile(from: OrchestrationHarness, home: &Path) -> Result<OrchestrationRead> {
221 Ok(match from {
222 OrchestrationHarness::Hermes => {
223 let loaded = patiently(|| from_hermes(home))?;
224 OrchestrationRead {
225 vault_keys: keys(&loaded.vault),
226 orchestration: loaded.orchestration,
227 }
228 }
229 OrchestrationHarness::Openclaw => {
230 let loaded = from_openclaw(home)?;
231 OrchestrationRead {
232 vault_keys: keys(&loaded.vault),
233 orchestration: loaded.orchestration,
234 }
235 }
236 })
237}
238
239pub fn decompile(
246 to: OrchestrationHarness,
247 orchestration: Orchestration,
248 source: &Path,
249 source_flavor: SourceFlavor,
250 dest: &Path,
251 vault: BTreeMap<String, String>,
252) -> Result<OrchestrationDecompiled> {
253 Ok(match (to, source_flavor) {
254 (OrchestrationHarness::Hermes, SourceFlavor::Orchestrator) => {
258 let mut loaded = load_home(source, HomeFlavor::Orchestrator.into())?;
259 loaded.orchestration = orchestration;
260 loaded.vault.extend(vault);
261 let report = to_hermes(&loaded, dest, None)?;
262 OrchestrationDecompiled {
263 written: report.written,
264 refused: report.refused.iter().map(RefusalRow::from).collect(),
265 loss: Some(report.loss),
266 ..OrchestrationDecompiled::default()
267 }
268 }
269 (OrchestrationHarness::Openclaw, SourceFlavor::Orchestrator) => {
270 let loaded =
271 supercode_interchange::orchestration::codec::OpenclawLoaded::from_orchestration(
272 orchestration,
273 {
274 let mut v = load_home(source, HomeFlavor::Orchestrator.into())?.vault;
275 v.extend(vault);
276 v
277 },
278 );
279 let report = to_openclaw(&loaded, dest)?;
280 OrchestrationDecompiled {
281 written: report.written,
282 refused: report.refused.iter().map(RefusalRow::from).collect(),
283 notes: report.notes,
284 rows_byte: Some(report.rows_byte),
285 rows_emitted: Some(report.rows_emitted),
286 loss: Some(report.loss),
287 }
288 }
289 (OrchestrationHarness::Hermes, SourceFlavor::Native) => {
290 let mut loaded = from_hermes(source)?;
291 loaded.orchestration = orchestration;
292 loaded.vault.extend(vault);
293 let report = to_hermes(&loaded, dest, None)?;
294 OrchestrationDecompiled {
295 written: report.written,
296 refused: report.refused.iter().map(RefusalRow::from).collect(),
297 loss: Some(report.loss),
298 ..OrchestrationDecompiled::default()
299 }
300 }
301 (OrchestrationHarness::Openclaw, SourceFlavor::Native) => {
302 let mut loaded = from_openclaw(source)?;
303 loaded.orchestration = orchestration;
304 loaded.vault.extend(vault);
305 let report = to_openclaw(&loaded, dest)?;
306 OrchestrationDecompiled {
307 written: report.written,
308 refused: report.refused.iter().map(RefusalRow::from).collect(),
309 notes: report.notes,
310 rows_byte: Some(report.rows_byte),
311 rows_emitted: Some(report.rows_emitted),
312 loss: Some(report.loss),
313 }
314 }
315 })
316}
317
318pub fn import(
326 from: OrchestrationHarness,
327 home: &Path,
328 into: &Path,
329) -> Result<OrchestrationImported> {
330 let (orchestration, vault, sources): (
331 Orchestration,
332 BTreeMap<String, String>,
333 BTreeMap<String, PathBuf>,
334 ) = match from {
335 OrchestrationHarness::Hermes => {
336 let loaded = from_hermes(home)?;
337 let sources = loaded
338 .io
339 .iter()
340 .filter_map(|(name, io)| Some((name.clone(), io.source_dir.clone()?)))
341 .collect();
342 (loaded.orchestration, loaded.vault, sources)
343 }
344 OrchestrationHarness::Openclaw => {
345 let loaded = from_openclaw(home)?;
346 let sources = loaded
349 .profiles
350 .iter()
351 .map(|(name, io)| {
352 let src = if name == "default" {
353 loaded.root.state_dir.clone()
354 } else {
355 io.source_dir.clone()
356 };
357 (name.clone(), src)
358 })
359 .collect();
360 (loaded.orchestration, loaded.vault, sources)
361 }
362 };
363 let mut loaded = LoadedHome {
364 orchestration,
365 vault,
366 io: BTreeMap::new(),
367 };
368 repoint(&mut loaded.orchestration, into);
369 save_home(&mut loaded, Some(into))?;
370 let mut carried = Vec::new();
371 for (name, profile) in &loaded.orchestration.profiles {
372 let Some(src) = sources.get(name) else {
373 continue;
374 };
375 let files: Vec<String> = profile
380 .residue
381 .files
382 .iter()
383 .filter(|rel| !OWNED_FILES.contains(&rel.as_str()))
384 .cloned()
385 .collect();
386 for rel in carry_unmodeled(&files, src, &profile.dir)? {
387 carried.push(if name == "default" {
388 rel
389 } else {
390 format!("profiles/{name}/{rel}")
391 });
392 }
393 }
394 Ok(OrchestrationImported {
395 vault_keys: keys(&loaded.vault),
396 orchestration: loaded.orchestration,
397 root: into.to_path_buf(),
398 carried,
399 })
400}
401
402pub fn export(
409 to: OrchestrationHarness,
410 root: &Path,
411 dest: &Path,
412) -> Result<OrchestrationDecompiled> {
413 let loaded = load_home(root, Flavor::Orchestrator)?;
414 let mut report = decompile(
415 to,
416 loaded.orchestration,
417 root,
418 SourceFlavor::Orchestrator,
419 dest,
420 loaded.vault,
421 )?;
422 if to == OrchestrationHarness::Hermes {
423 report.notes.extend(hermes_folds(root));
424 }
425 Ok(report)
426}
427
428fn hermes_folds(root: &Path) -> Vec<String> {
431 let mut notes = vec![
432 "the root (the organization's layer) becomes Hermes's `default` profile: its instructions (SOUL.md) and \
433 settings. Hermes runs the default profile; the IR assigns nothing to the root."
434 .to_string(),
435 ];
436 if root.join("workflow.yaml").is_file() {
437 notes.push(
438 "workflow.yaml (the board IR) is carried as a file only: Hermes runs its own kanban, not this \
439 workflow, so its statuses, verbs, roles and the dispatcher's review are not in force there."
440 .to_string(),
441 );
442 }
443 #[cfg(feature = "adapter-api")]
444 {
445 let profiles = root.join("profiles");
446 for agent in crate::mail_agent::all_agents() {
447 let Some(folder) = agent.folder.as_deref() else {
448 continue;
449 };
450 if let Ok(profile) = folder.strip_prefix(&profiles) {
451 notes.push(format!(
452 "agent {} becomes its profile {}: Hermes has no agent mailbox, so its address, its main session, \
453 its threads and its channel stay with Supercode.",
454 agent.name,
455 profile.display()
456 ));
457 }
458 }
459 }
460 notes
461}