feat(loop): headless sbx save/publish + hh-loop VM-library skill

Adds the autonomous /loop foundation: a headless `hack-house sbx save|publish`
subcommand so a non-TUI operator can persist a built VM to the host library
through the same canonical registry path the room UI uses (no schema drift).

- snapshot.rs: hoist register_saved_snapshot/publish_snapshot/oci_image_size out
  of app.rs into a shared module used by both the TUI and the new CLI.
- registry.rs: advisory cross-process lockfile (~/.hh/registry.lock, O_EXCL spin
  + stale-reclaim) around every read-modify-write, plus atomic temp+rename store,
  so concurrent /loop wave members can't clobber each other.
- main.rs: `Sbx { Save, Publish }` subcommand wired to the shared snapshot logic.
- skills/hh-loop: the loop doctrine — value rubric, adaptive 1-3 operator
  topology, visible-tmux-by-default run flow, --record logs/film, wave scaling,
  and safe `tmux -L hh-loop` teardown.

Proven end-to-end: built+verified a stdlib VM in a sandbox, headless
save+publish -> registry entry shareable:true with a portable tar.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
leetcrypt
2026-06-30 14:06:19 -07:00
parent 354205cc16
commit e5fb820e63
6 changed files with 429 additions and 94 deletions
+4 -93
View File
@@ -5,6 +5,7 @@ use crate::layout::{Dir, Layout, Resize};
use crate::net::{self, Session};
use crate::registry;
use crate::sbx;
use crate::snapshot;
use crate::theme::Theme;
use crate::ui;
use anyhow::Result;
@@ -2321,7 +2322,7 @@ fn handle_command(
// Index the snapshot in the host-global registry, caching
// its .hh-agent manifest summary. Best-effort: an index
// hiccup must never fail the save the user just asked for.
register_saved_snapshot(be, &name, &label, &created_by);
snapshot::register_saved_snapshot(be, &name, &label, &created_by);
Ok::<String, anyhow::Error>(desc)
})
.await;
@@ -2496,7 +2497,7 @@ fn handle_command(
let tx = app_tx.clone();
tokio::spawn(async move {
let res = tokio::task::spawn_blocking(move || {
publish_snapshot(&label, &tags)
snapshot::publish_snapshot(&label, &tags)
})
.await;
let _ = match res {
@@ -3102,48 +3103,6 @@ fn closest<'a>(input: &str, candidates: &[&'a str]) -> Option<&'a str> {
.map(|(_, c)| c)
}
/// Record a freshly-saved snapshot in the host-global VM registry, caching the
/// `.hh-agent` manifest summary read out of the (still-running, for OCI backends)
/// container. Best-effort — never panics, never fails the save. Blocking; call
/// from inside `spawn_blocking`.
fn register_saved_snapshot(be: sbx::Backend, name: &str, label: &str, created_by: &str) {
let (artifact_kind, artifact_ref, size_bytes, manifest) = match be {
sbx::Backend::Docker | sbx::Backend::Podman => {
let engine = be.engine();
let image = format!("{}:{}", sbx::SNAP_REPO, label);
let size = oci_image_size(engine, &image);
let manifest = registry::read_container_manifest(engine, name, "/root");
("image".to_string(), image, size, manifest)
}
// Multipass snapshots live in multipass's own store; the instance is
// powered off by save time, so there's no container to read a manifest
// from. Record the pointer; reconcile leaves it (no image to probe).
sbx::Backend::Multipass => ("snapshot".to_string(), label.to_string(), None, None),
sbx::Backend::Local => return, // nothing persistent to index
};
let (purpose, status, todo) = manifest
.as_deref()
.map(registry::scan_manifest)
.unwrap_or_default();
let entry = registry::Entry {
label: label.to_string(),
backend: be.engine().to_string(),
artifact_kind,
artifact_ref,
size_bytes,
created_unix: registry::now_unix(),
created_by: created_by.to_string(),
repo: registry::cwd_repo(),
purpose,
status,
todo,
..Default::default()
};
if let Err(e) = registry::upsert(entry) {
eprintln!("registry upsert failed for '{label}': {e}");
}
}
/// Load a pulled `hh-snap-<label>.tar` into the local engine and record it in the
/// host registry, scraping the `.hh-agent` manifest that rides inside the image so
/// the receiver's `/sbx browse`/`load` know what the VM is for. Blocking — runs
@@ -3160,7 +3119,7 @@ fn register_received_snapshot(tar: &std::path::Path) -> anyhow::Result<String> {
backend: engine.clone(),
artifact_kind: "image".to_string(),
artifact_ref: image.clone(),
size_bytes: oci_image_size(&engine, &image),
size_bytes: snapshot::oci_image_size(&engine, &image),
created_unix: registry::now_unix(),
created_by: "pulled".to_string(),
repo: registry::cwd_repo(),
@@ -3179,54 +3138,6 @@ fn register_received_snapshot(tar: &std::path::Path) -> anyhow::Result<String> {
))
}
/// Mark a saved snapshot shareable and ensure it has a portable, sendable file.
/// File-backed snaps (`.tar`/`.ova`) are already tradeable; image-backed snaps
/// are exported to `hh-snapshots/hh-snap-<label>.tar` first so a peer can receive
/// them over `/send`. Blocking — run off the UI thread. Returns a status line.
fn publish_snapshot(label: &str, tags: &[String]) -> anyhow::Result<String> {
use anyhow::Context;
let entry = registry::get(label)
.with_context(|| format!("no saved VM labelled '{label}' — `/sbx browse` to list"))?;
let share_path = match entry.artifact_kind.as_str() {
// Already a standalone file the owner controls — send it as-is.
"file" => entry.artifact_ref.clone(),
// Lives only in the engine image store — export a portable copy.
"image" => {
let backend = sbx::Backend::parse(&entry.backend)
.with_context(|| format!("unknown backend '{}' for '{label}'", entry.backend))?;
sbx::export_image(backend, label)?
.to_string_lossy()
.into_owned()
}
other => anyhow::bail!(
"'{label}' is a {other} snapshot — only file/image snapshots are tradeable"
),
};
let published = registry::publish(label, &share_path, tags)?;
let tagnote = if published.tags.is_empty() {
String::new()
} else {
format!(" [{}]", published.tags.join(", "))
};
Ok(format!(
"✓ published '{label}'{tagnote} — shareable artifact at {share_path}; peers can `/sbx pull @<you> {label}`"
))
}
/// Size in bytes of an OCI image (`<engine> image inspect … --format {{.Size}}`),
/// or None if the engine/image isn't available. Blocking.
fn oci_image_size(engine: &str, image: &str) -> Option<u64> {
let out = std::process::Command::new(engine)
.args(["image", "inspect", image, "--format", "{{.Size}}"])
.stderr(std::process::Stdio::null())
.output()
.ok()?;
if !out.status.success() {
return None;
}
String::from_utf8_lossy(&out.stdout).trim().parse::<u64>().ok()
}
/// Does an OCI image still exist? Used to reconcile the registry so entries whose
/// snapshot was pruned drop out of `/sbx browse`. Blocking.
fn oci_image_exists(backend: &str, image: &str) -> bool {