Skip to main content

supercode_harness/
world_doors.rs

1//! ONT-4: the world doors, in one implementation.
2//!
3//! `harness.v1.world.load|save|compile|decompile|import|export` and
4//! `supercode world <verb>` are two transports over the functions here, which
5//! are themselves a thin wrapper over the ONT-3 codecs
6//! (`supercode_interchange::world::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::world::codec::folder::OWNED_FILES;
24use supercode_interchange::world::codec::{
25    carry_unmodeled, from_hermes, from_openclaw, load_home, save_home, to_hermes, to_openclaw,
26    Flavor, LoadedHome, Refusal,
27};
28use supercode_interchange::world::World;
29
30use crate::Result;
31
32/// Which layout a folder is read as (`harness.v1.world.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 world 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 a world is compiled from or decompiled back to.
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
65#[serde(rename_all = "snake_case")]
66pub enum WorldHarness {
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 world, and the vault's key names.
92#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
93pub struct WorldRead {
94    /// The world value.
95    pub world: World,
96    /// The `.env` names the world'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 WorldSaved {
103    /// Always true; a failure is an error, never a `false`.
104    pub written: bool,
105    /// The folder the world 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 WorldDecompiled {
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 world 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 WorldImported {
131    /// The world value, as written into `root`.
132    pub world: World,
133    /// The `.env` names the world's secret refs point at — names only.
134    pub vault_keys: Vec<String>,
135    /// Our folder.
136    pub root: PathBuf,
137    /// Files the world 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 a world 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(world: &mut World, root: &Path) {
149    world.root = root.to_path_buf();
150    for (name, profile) in world.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.world.load`: read a home folder as one world value.
160pub fn load(root: &Path, flavor: HomeFlavor) -> Result<WorldRead> {
161    let loaded = load_home(root, flavor.into())?;
162    Ok(WorldRead {
163        vault_keys: keys(&loaded.vault),
164        world: loaded.world,
165    })
166}
167
168/// `harness.v1.world.save`: write a world 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 world
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(root: &Path, world: World, vault: BTreeMap<String, String>) -> Result<WorldSaved> {
175    let mut loaded = if root.is_dir() {
176        load_home(root, Flavor::Orchestrator)?
177    } else {
178        LoadedHome {
179            world: world.clone(),
180            vault: BTreeMap::new(),
181            io: BTreeMap::new(),
182        }
183    };
184    loaded.world = world;
185    repoint(&mut loaded.world, root);
186    loaded.vault.extend(vault);
187    save_home(&mut loaded, Some(root))?;
188    Ok(WorldSaved {
189        written: true,
190        root: root.to_path_buf(),
191    })
192}
193
194/// `harness.v1.world.compile`: read another harness's home as one world value.
195pub fn compile(from: WorldHarness, home: &Path) -> Result<WorldRead> {
196    Ok(match from {
197        WorldHarness::Hermes => {
198            let loaded = from_hermes(home)?;
199            WorldRead {
200                vault_keys: keys(&loaded.vault),
201                world: loaded.world,
202            }
203        }
204        WorldHarness::Openclaw => {
205            let loaded = from_openclaw(home)?;
206            WorldRead {
207                vault_keys: keys(&loaded.vault),
208                world: loaded.world,
209            }
210        }
211    })
212}
213
214/// `harness.v1.world.decompile`: write a world back as the source harness's home.
215///
216/// `source` is the home the world was compiled from. It is re-compiled here
217/// for one reason: the io bookkeeping. That is what lets an artifact whose
218/// record has not changed be reused byte for byte, and what lets the codec
219/// refuse a live `state.db` (UNI-18's write) instead of guessing at one.
220pub fn decompile(
221    to: WorldHarness,
222    world: World,
223    source: &Path,
224    source_flavor: SourceFlavor,
225    dest: &Path,
226    vault: BTreeMap<String, String>,
227) -> Result<WorldDecompiled> {
228    Ok(match (to, source_flavor) {
229        // our own folder on its way out: its bytes are ours (refs, not values),
230        // so the codec re-emits credentials from the vault and refuses the
231        // session half by construction
232        (WorldHarness::Hermes, SourceFlavor::Orchestrator) => {
233            let mut loaded = load_home(source, HomeFlavor::Orchestrator.into())?;
234            loaded.world = world;
235            loaded.vault.extend(vault);
236            let report = to_hermes(&loaded, dest, None)?;
237            WorldDecompiled {
238                written: report.written,
239                refused: report.refused.iter().map(RefusalRow::from).collect(),
240                ..WorldDecompiled::default()
241            }
242        }
243        (WorldHarness::Openclaw, SourceFlavor::Orchestrator) => {
244            let loaded = supercode_interchange::world::codec::OpenclawLoaded::from_world(world, {
245                let mut v = load_home(source, HomeFlavor::Orchestrator.into())?.vault;
246                v.extend(vault);
247                v
248            });
249            let report = to_openclaw(&loaded, dest)?;
250            WorldDecompiled {
251                written: report.written,
252                refused: report.refused.iter().map(RefusalRow::from).collect(),
253                notes: report.notes,
254                rows_byte: Some(report.rows_byte),
255                rows_emitted: Some(report.rows_emitted),
256            }
257        }
258        (WorldHarness::Hermes, SourceFlavor::Native) => {
259            let mut loaded = from_hermes(source)?;
260            loaded.world = world;
261            loaded.vault.extend(vault);
262            let report = to_hermes(&loaded, dest, None)?;
263            WorldDecompiled {
264                written: report.written,
265                refused: report.refused.iter().map(RefusalRow::from).collect(),
266                ..WorldDecompiled::default()
267            }
268        }
269        (WorldHarness::Openclaw, SourceFlavor::Native) => {
270            let mut loaded = from_openclaw(source)?;
271            loaded.world = world;
272            loaded.vault.extend(vault);
273            let report = to_openclaw(&loaded, dest)?;
274            WorldDecompiled {
275                written: report.written,
276                refused: report.refused.iter().map(RefusalRow::from).collect(),
277                notes: report.notes,
278                rows_byte: Some(report.rows_byte),
279                rows_emitted: Some(report.rows_emitted),
280            }
281        }
282    })
283}
284
285/// `harness.v1.world.import`: another harness's home becomes our folder.
286///
287/// A compile followed by a save, with the credentials along: the source's
288/// secret values land in our `.env` and every other file carries a ref. Every
289/// artifact is emitted canonically — the source's bytes are another
290/// harness's, never reused as ours — and the files the world does not model
291/// (`MEMORY.md`, `skills/`, an agent's transcripts) are carried by path.
292pub fn import(from: WorldHarness, home: &Path, into: &Path) -> Result<WorldImported> {
293    let (world, vault, sources): (World, BTreeMap<String, String>, BTreeMap<String, PathBuf>) =
294        match from {
295            WorldHarness::Hermes => {
296                let loaded = from_hermes(home)?;
297                let sources = loaded
298                    .io
299                    .iter()
300                    .filter_map(|(name, io)| Some((name.clone(), io.source_dir.clone()?)))
301                    .collect();
302                (loaded.world, loaded.vault, sources)
303            }
304            WorldHarness::Openclaw => {
305                let loaded = from_openclaw(home)?;
306                // the root profile's unmodeled files are listed from the state
307                // dir; a named agent's from `agents/<id>/`
308                let sources = loaded
309                    .profiles
310                    .iter()
311                    .map(|(name, io)| {
312                        let src = if name == "default" {
313                            loaded.root.state_dir.clone()
314                        } else {
315                            io.source_dir.clone()
316                        };
317                        (name.clone(), src)
318                    })
319                    .collect();
320                (loaded.world, loaded.vault, sources)
321            }
322        };
323    let mut loaded = LoadedHome {
324        world,
325        vault,
326        io: BTreeMap::new(),
327    };
328    repoint(&mut loaded.world, into);
329    save_home(&mut loaded, Some(into))?;
330    let mut carried = Vec::new();
331    for (name, profile) in &loaded.world.profiles {
332        let Some(src) = sources.get(name) else {
333            continue;
334        };
335        // a file the SOURCE does not model may share its name with an
336        // artifact we own (OpenClaw's legacy `cron/jobs.json` is a store key
337        // to it and a jobs file to us); a carried byte never overwrites an
338        // owned artifact, and a re-import refreshes every other carried file
339        let files: Vec<String> = profile
340            .residue
341            .files
342            .iter()
343            .filter(|rel| !OWNED_FILES.contains(&rel.as_str()))
344            .cloned()
345            .collect();
346        for rel in carry_unmodeled(&files, src, &profile.dir)? {
347            carried.push(if name == "default" {
348                rel
349            } else {
350                format!("profiles/{name}/{rel}")
351            });
352        }
353    }
354    Ok(WorldImported {
355        vault_keys: keys(&loaded.vault),
356        world: loaded.world,
357        root: into.to_path_buf(),
358        carried,
359    })
360}
361
362/// `harness.v1.world.export`: our folder becomes another harness's home.
363///
364/// A load followed by a decompile from our flavor, with the credentials
365/// along: the values our `.env` holds are written where the harness reads
366/// them. The session half is written into a fresh destination (ONT-8) and
367/// refused into a live one (UNI-18).
368pub fn export(to: WorldHarness, root: &Path, dest: &Path) -> Result<WorldDecompiled> {
369    let loaded = load_home(root, Flavor::Orchestrator)?;
370    decompile(
371        to,
372        loaded.world,
373        root,
374        SourceFlavor::Orchestrator,
375        dest,
376        loaded.vault,
377    )
378}