Skip to main content

supercode_harness/
orchestration_doors.rs

1//! ONT-4: the orchestration doors, in one implementation.
2//!
3//! `harness.v1.orchestration.load|save|compile|decompile|import|export` and
4//! `supercode orchestration <verb>` are two transports over the functions here, which
5//! are themselves a thin wrapper over the ONT-3 codecs
6//! (`supercode_interchange::orchestration::codec`). Nothing in this module decides
7//! anything a codec does not: it picks the codec the caller named, keeps the
8//! io bookkeeping a decompile needs, and shapes the answer for the wire.
9//!
10//! One rule the wire adds: a vault VALUE never leaves. A load or a compile
11//! answers with the vault's KEY NAMES only — the caller that needs a value
12//! reads the home's own `.env`. That rule is why `import` and `export` exist
13//! as verbs of their own: a migration moves credentials between homes, and
14//! composed from the value-level verbs by a client it could not — the
15//! credential would have to cross the wire. Here it stays in this process.
16
17use 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/// Which layout a folder is read as (`harness.v1.orchestration.load`'s `flavor`).
33#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(rename_all = "snake_case")]
35pub enum HomeFlavor {
36    /// Our own folder.
37    #[default]
38    Orchestrator,
39    /// A Hermes home read in place.
40    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/// What kind of home `decompile`'s `source` is.
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
54#[serde(rename_all = "snake_case")]
55pub enum SourceFlavor {
56    /// The target harness's own home, the one the orchestration was compiled from.
57    #[default]
58    Native,
59    /// Our own folder: refs where the harness reads values, and no session store of the target's.
60    Orchestrator,
61}
62
63/// Which source harness an orchestration is compiled from or decompiled back to.
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
65#[serde(rename_all = "snake_case")]
66pub enum OrchestrationHarness {
67    /// A Hermes home.
68    Hermes,
69    /// An OpenClaw state directory.
70    Openclaw,
71}
72
73/// A refused write, on the wire.
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
75pub struct RefusalRow {
76    /// The file, relative to the destination home.
77    pub file: String,
78    /// Why, naming the gate.
79    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/// What a load or a compile answers: the orchestration, and the vault's key names.
92#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
93pub struct OrchestrationRead {
94    /// The orchestration value.
95    pub orchestration: Orchestration,
96    /// The `.env` names the orchestration's secret refs point at — names only.
97    pub vault_keys: Vec<String>,
98}
99
100/// What a save answers.
101#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
102pub struct OrchestrationSaved {
103    /// Always true; a failure is an error, never a `false`.
104    pub written: bool,
105    /// The folder the orchestration was written to.
106    pub root: PathBuf,
107}
108
109/// What a decompile did.
110#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
111pub struct OrchestrationDecompiled {
112    /// Every artifact written, with its tier.
113    pub written: Vec<ArtifactFidelity>,
114    /// Every write refused, with the gate named.
115    pub refused: Vec<RefusalRow>,
116    /// What a semantic write gave up (OpenClaw only).
117    #[serde(default, skip_serializing_if = "Vec::is_empty")]
118    pub notes: Vec<String>,
119    /// Store rows written back column for column (OpenClaw only).
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub rows_byte: Option<usize>,
122    /// Store rows re-encoded (OpenClaw only).
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub rows_emitted: Option<usize>,
125    /// Every folded and dropped item, also kept beside the output (`supercode-export-report.json`).
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    pub loss: Option<supercode_interchange::orchestration::codec::LossReport>,
128}
129
130/// What an import did: the orchestration as saved, the vault's key names, and the
131/// unmodeled files carried by path.
132#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
133pub struct OrchestrationImported {
134    /// The orchestration value, as written into `root`.
135    pub orchestration: Orchestration,
136    /// The `.env` names the orchestration's secret refs point at — names only.
137    pub vault_keys: Vec<String>,
138    /// Our folder.
139    pub root: PathBuf,
140    /// Files the orchestration does not model, copied byte for byte (relative to `root`).
141    pub carried: Vec<String>,
142}
143
144fn keys(vault: &BTreeMap<String, String>) -> Vec<String> {
145    vault.keys().cloned().collect()
146}
147
148/// Point an orchestration at the folder it is about to be written to, so the record and
149/// the disk agree afterwards. `dir` is bookkeeping, not part of any artifact's
150/// record, so this never forces a re-emit.
151fn 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
162/// A harness rewrites its home atomically (write beside, rename over): a name
163/// can be missing for the instant between, and a read that lands there fails
164/// with `NotFound` on a file that exists again a moment later. One retry after
165/// a short pause is the reader's own patience, not every consumer's.
166fn 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
180/// `harness.v1.orchestration.load`: read a home folder as one orchestration value.
181pub 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
189/// `harness.v1.orchestration.save`: write an orchestration into our own folder.
190///
191/// An existing root is loaded first: its `io` bookkeeping is what tells the
192/// encoder which artifacts are unchanged, so a save of an unmodified orchestration
193/// leaves every byte alone. `vault` is merged into the loaded one — a caller
194/// that sends no secrets keeps the home's own `.env`.
195pub 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
219/// `harness.v1.orchestration.compile`: read another harness's home as one orchestration value.
220pub 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
239/// `harness.v1.orchestration.decompile`: write an orchestration back as the source harness's home.
240///
241/// `source` is the home the orchestration was compiled from. It is re-compiled here
242/// for one reason: the io bookkeeping. That is what lets an artifact whose
243/// record has not changed be reused byte for byte, and what lets the codec
244/// refuse a live `state.db` (UNI-18's write) instead of guessing at one.
245pub 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        // our own folder on its way out: its bytes are ours (refs, not values),
255        // so the codec re-emits credentials from the vault and refuses the
256        // session half by construction
257        (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
318/// `harness.v1.orchestration.import`: another harness's home becomes our folder.
319///
320/// A compile followed by a save, with the credentials along: the source's
321/// secret values land in our `.env` and every other file carries a ref. Every
322/// artifact is emitted canonically — the source's bytes are another
323/// harness's, never reused as ours — and the files the orchestration does not model
324/// (`MEMORY.md`, `skills/`, an agent's transcripts) are carried by path.
325pub 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            // the root profile's unmodeled files are listed from the state
347            // dir; a named agent's from `agents/<id>/`
348            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        // a file the SOURCE does not model may share its name with an
376        // artifact we own (OpenClaw's legacy `cron/jobs.json` is a store key
377        // to it and a jobs file to us); a carried byte never overwrites an
378        // owned artifact, and a re-import refreshes every other carried file
379        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
402/// `harness.v1.orchestration.export`: our folder becomes another harness's home.
403///
404/// A load followed by a decompile from our flavor, with the credentials
405/// along: the values our `.env` holds are written where the harness reads
406/// them. The session half is written into a fresh destination (ONT-8) and
407/// refused into a live one (UNI-18).
408pub 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
428/// What an IR home (Open Autonomy's company skew: an organization's layer at the root, named profiles, a declared
429/// board, agents with mailboxes) folds into when it becomes a Hermes home, each named, so the loss is read, not found.
430fn 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}