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