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}
126
127/// What an import did: the orchestration as saved, the vault's key names, and the
128/// unmodeled files carried by path.
129#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
130pub struct OrchestrationImported {
131    /// The orchestration value, as written into `root`.
132    pub orchestration: Orchestration,
133    /// The `.env` names the orchestration's secret refs point at — names only.
134    pub vault_keys: Vec<String>,
135    /// Our folder.
136    pub root: PathBuf,
137    /// Files the orchestration does not model, copied byte for byte (relative to `root`).
138    pub carried: Vec<String>,
139}
140
141fn keys(vault: &BTreeMap<String, String>) -> Vec<String> {
142    vault.keys().cloned().collect()
143}
144
145/// Point an orchestration at the folder it is about to be written to, so the record and
146/// the disk agree afterwards. `dir` is bookkeeping, not part of any artifact's
147/// record, so this never forces a re-emit.
148fn repoint(orchestration: &mut Orchestration, root: &Path) {
149    orchestration.root = root.to_path_buf();
150    for (name, profile) in orchestration.profiles.iter_mut() {
151        profile.dir = if name == "default" {
152            root.to_path_buf()
153        } else {
154            root.join("profiles").join(name)
155        };
156    }
157}
158
159/// `harness.v1.orchestration.load`: read a home folder as one orchestration value.
160pub fn load(root: &Path, flavor: HomeFlavor) -> Result<OrchestrationRead> {
161    let loaded = load_home(root, flavor.into())?;
162    Ok(OrchestrationRead {
163        vault_keys: keys(&loaded.vault),
164        orchestration: loaded.orchestration,
165    })
166}
167
168/// `harness.v1.orchestration.save`: write an orchestration into our own folder.
169///
170/// An existing root is loaded first: its `io` bookkeeping is what tells the
171/// encoder which artifacts are unchanged, so a save of an unmodified orchestration
172/// leaves every byte alone. `vault` is merged into the loaded one — a caller
173/// that sends no secrets keeps the home's own `.env`.
174pub fn save(
175    root: &Path,
176    orchestration: Orchestration,
177    vault: BTreeMap<String, String>,
178) -> Result<OrchestrationSaved> {
179    let mut loaded = if root.is_dir() {
180        load_home(root, Flavor::Orchestrator)?
181    } else {
182        LoadedHome {
183            orchestration: orchestration.clone(),
184            vault: BTreeMap::new(),
185            io: BTreeMap::new(),
186        }
187    };
188    loaded.orchestration = orchestration;
189    repoint(&mut loaded.orchestration, root);
190    loaded.vault.extend(vault);
191    save_home(&mut loaded, Some(root))?;
192    Ok(OrchestrationSaved {
193        written: true,
194        root: root.to_path_buf(),
195    })
196}
197
198/// `harness.v1.orchestration.compile`: read another harness's home as one orchestration value.
199pub fn compile(from: OrchestrationHarness, home: &Path) -> Result<OrchestrationRead> {
200    Ok(match from {
201        OrchestrationHarness::Hermes => {
202            let loaded = from_hermes(home)?;
203            OrchestrationRead {
204                vault_keys: keys(&loaded.vault),
205                orchestration: loaded.orchestration,
206            }
207        }
208        OrchestrationHarness::Openclaw => {
209            let loaded = from_openclaw(home)?;
210            OrchestrationRead {
211                vault_keys: keys(&loaded.vault),
212                orchestration: loaded.orchestration,
213            }
214        }
215    })
216}
217
218/// `harness.v1.orchestration.decompile`: write an orchestration back as the source harness's home.
219///
220/// `source` is the home the orchestration was compiled from. It is re-compiled here
221/// for one reason: the io bookkeeping. That is what lets an artifact whose
222/// record has not changed be reused byte for byte, and what lets the codec
223/// refuse a live `state.db` (UNI-18's write) instead of guessing at one.
224pub fn decompile(
225    to: OrchestrationHarness,
226    orchestration: Orchestration,
227    source: &Path,
228    source_flavor: SourceFlavor,
229    dest: &Path,
230    vault: BTreeMap<String, String>,
231) -> Result<OrchestrationDecompiled> {
232    Ok(match (to, source_flavor) {
233        // our own folder on its way out: its bytes are ours (refs, not values),
234        // so the codec re-emits credentials from the vault and refuses the
235        // session half by construction
236        (OrchestrationHarness::Hermes, SourceFlavor::Orchestrator) => {
237            let mut loaded = load_home(source, HomeFlavor::Orchestrator.into())?;
238            loaded.orchestration = orchestration;
239            loaded.vault.extend(vault);
240            let report = to_hermes(&loaded, dest, None)?;
241            OrchestrationDecompiled {
242                written: report.written,
243                refused: report.refused.iter().map(RefusalRow::from).collect(),
244                ..OrchestrationDecompiled::default()
245            }
246        }
247        (OrchestrationHarness::Openclaw, SourceFlavor::Orchestrator) => {
248            let loaded =
249                supercode_interchange::orchestration::codec::OpenclawLoaded::from_orchestration(
250                    orchestration,
251                    {
252                        let mut v = load_home(source, HomeFlavor::Orchestrator.into())?.vault;
253                        v.extend(vault);
254                        v
255                    },
256                );
257            let report = to_openclaw(&loaded, dest)?;
258            OrchestrationDecompiled {
259                written: report.written,
260                refused: report.refused.iter().map(RefusalRow::from).collect(),
261                notes: report.notes,
262                rows_byte: Some(report.rows_byte),
263                rows_emitted: Some(report.rows_emitted),
264            }
265        }
266        (OrchestrationHarness::Hermes, SourceFlavor::Native) => {
267            let mut loaded = from_hermes(source)?;
268            loaded.orchestration = orchestration;
269            loaded.vault.extend(vault);
270            let report = to_hermes(&loaded, dest, None)?;
271            OrchestrationDecompiled {
272                written: report.written,
273                refused: report.refused.iter().map(RefusalRow::from).collect(),
274                ..OrchestrationDecompiled::default()
275            }
276        }
277        (OrchestrationHarness::Openclaw, SourceFlavor::Native) => {
278            let mut loaded = from_openclaw(source)?;
279            loaded.orchestration = orchestration;
280            loaded.vault.extend(vault);
281            let report = to_openclaw(&loaded, dest)?;
282            OrchestrationDecompiled {
283                written: report.written,
284                refused: report.refused.iter().map(RefusalRow::from).collect(),
285                notes: report.notes,
286                rows_byte: Some(report.rows_byte),
287                rows_emitted: Some(report.rows_emitted),
288            }
289        }
290    })
291}
292
293/// `harness.v1.orchestration.import`: another harness's home becomes our folder.
294///
295/// A compile followed by a save, with the credentials along: the source's
296/// secret values land in our `.env` and every other file carries a ref. Every
297/// artifact is emitted canonically — the source's bytes are another
298/// harness's, never reused as ours — and the files the orchestration does not model
299/// (`MEMORY.md`, `skills/`, an agent's transcripts) are carried by path.
300pub fn import(
301    from: OrchestrationHarness,
302    home: &Path,
303    into: &Path,
304) -> Result<OrchestrationImported> {
305    let (orchestration, vault, sources): (
306        Orchestration,
307        BTreeMap<String, String>,
308        BTreeMap<String, PathBuf>,
309    ) = match from {
310        OrchestrationHarness::Hermes => {
311            let loaded = from_hermes(home)?;
312            let sources = loaded
313                .io
314                .iter()
315                .filter_map(|(name, io)| Some((name.clone(), io.source_dir.clone()?)))
316                .collect();
317            (loaded.orchestration, loaded.vault, sources)
318        }
319        OrchestrationHarness::Openclaw => {
320            let loaded = from_openclaw(home)?;
321            // the root profile's unmodeled files are listed from the state
322            // dir; a named agent's from `agents/<id>/`
323            let sources = loaded
324                .profiles
325                .iter()
326                .map(|(name, io)| {
327                    let src = if name == "default" {
328                        loaded.root.state_dir.clone()
329                    } else {
330                        io.source_dir.clone()
331                    };
332                    (name.clone(), src)
333                })
334                .collect();
335            (loaded.orchestration, loaded.vault, sources)
336        }
337    };
338    let mut loaded = LoadedHome {
339        orchestration,
340        vault,
341        io: BTreeMap::new(),
342    };
343    repoint(&mut loaded.orchestration, into);
344    save_home(&mut loaded, Some(into))?;
345    let mut carried = Vec::new();
346    for (name, profile) in &loaded.orchestration.profiles {
347        let Some(src) = sources.get(name) else {
348            continue;
349        };
350        // a file the SOURCE does not model may share its name with an
351        // artifact we own (OpenClaw's legacy `cron/jobs.json` is a store key
352        // to it and a jobs file to us); a carried byte never overwrites an
353        // owned artifact, and a re-import refreshes every other carried file
354        let files: Vec<String> = profile
355            .residue
356            .files
357            .iter()
358            .filter(|rel| !OWNED_FILES.contains(&rel.as_str()))
359            .cloned()
360            .collect();
361        for rel in carry_unmodeled(&files, src, &profile.dir)? {
362            carried.push(if name == "default" {
363                rel
364            } else {
365                format!("profiles/{name}/{rel}")
366            });
367        }
368    }
369    Ok(OrchestrationImported {
370        vault_keys: keys(&loaded.vault),
371        orchestration: loaded.orchestration,
372        root: into.to_path_buf(),
373        carried,
374    })
375}
376
377/// `harness.v1.orchestration.export`: our folder becomes another harness's home.
378///
379/// A load followed by a decompile from our flavor, with the credentials
380/// along: the values our `.env` holds are written where the harness reads
381/// them. The session half is written into a fresh destination (ONT-8) and
382/// refused into a live one (UNI-18).
383pub fn export(
384    to: OrchestrationHarness,
385    root: &Path,
386    dest: &Path,
387) -> Result<OrchestrationDecompiled> {
388    let loaded = load_home(root, Flavor::Orchestrator)?;
389    decompile(
390        to,
391        loaded.orchestration,
392        root,
393        SourceFlavor::Orchestrator,
394        dest,
395        loaded.vault,
396    )
397}