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 {
+67
View File
@@ -11,6 +11,7 @@ mod layout;
mod net;
mod registry;
mod sbx;
mod snapshot;
mod theme;
mod ui;
@@ -76,6 +77,44 @@ enum Cmd {
#[arg(long, default_value_t = false)]
insecure: bool,
},
/// Headless snapshot ops on the VM library — the save/publish surface the
/// interactive `/sbx` grammar exposes, usable by an autonomous `/loop` runner
/// without a TUI. Writes byte-identical registry entries to the in-room path.
Sbx {
#[command(subcommand)]
action: SbxCmd,
},
}
#[derive(Subcommand)]
enum SbxCmd {
/// Commit the running sandbox to a snapshot and index it in the VM registry,
/// caching the container's `.hh-agent` manifest summary. Mirrors `/sbx save`.
Save {
/// Snapshot label (image tag / registry key).
label: String,
/// Also export a portable `hh-snapshots/hh-snap-<label>.tar` immediately.
#[arg(long, default_value_t = false)]
local: bool,
/// Running container name to commit + read the manifest from.
#[arg(long, default_value = "hack-house")]
name: String,
/// Sandbox backend: docker | podman | multipass | local.
#[arg(long, default_value = "podman")]
backend: String,
/// Origin tag recorded on the entry's `created_by`.
#[arg(long, default_value = "operator")]
created_by: String,
},
/// Mark a saved snapshot shareable, exporting a portable artifact if needed,
/// so peers can `/sbx pull` it from the library. Mirrors `/sbx publish`.
Publish {
/// Label of an already-saved snapshot (see `Sbx Save`).
label: String,
/// Skill tags for catalog filtering (repeatable: `--tag recon --tag kali`).
#[arg(long = "tag")]
tags: Vec<String>,
},
}
fn main() -> Result<()> {
@@ -164,6 +203,34 @@ fn main() -> Result<()> {
no_tls,
insecure,
} => handshake(&ip, port, &user, &password, no_tls, insecure),
Cmd::Sbx { action } => sbx_cmd(action),
}
}
/// Headless `hack-house sbx …` — the save/publish library surface for the
/// autonomous `/loop` runner. Delegates to the same `snapshot`/`sbx`/`registry`
/// code the interactive TUI uses, so there's exactly one canonical entry shape.
fn sbx_cmd(action: SbxCmd) -> Result<()> {
match action {
SbxCmd::Save {
label,
local,
name,
backend,
created_by,
} => {
let be = sbx::Backend::parse(&backend)
.with_context(|| format!("unknown backend '{backend}'"))?;
let desc = sbx::save_state(be, &name, &label, local)?;
snapshot::register_saved_snapshot(be, &name, &label, &created_by);
println!("{desc}");
Ok(())
}
SbxCmd::Publish { label, tags } => {
let line = snapshot::publish_snapshot(&label, &tags)?;
println!("{line}");
Ok(())
}
}
}
+69 -1
View File
@@ -105,12 +105,76 @@ fn store(reg: &Registry) -> Result<()> {
std::fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
}
let text = serde_json::to_string_pretty(reg).context("serializing registry")?;
std::fs::write(&path, text).with_context(|| format!("writing {}", path.display()))?;
// Write to a per-process temp then atomically rename into place, so a reader
// (or a concurrent wave member taking the lock next) never observes a
// half-written file even if this process is killed mid-write.
let tmp = path.with_extension(format!("json.tmp.{}", std::process::id()));
std::fs::write(&tmp, text).with_context(|| format!("writing {}", tmp.display()))?;
std::fs::rename(&tmp, &path).with_context(|| format!("committing {}", path.display()))?;
Ok(())
}
/// How long a lockfile may sit before it's presumed stale (its holder died) and
/// forcibly reclaimed. Generous enough for a slow `export_image` of a fat VM.
const LOCK_STALE_SECS: u64 = 120;
/// An advisory cross-process lock on the registry, held for the duration of one
/// read-modify-write. Dropping it releases the lock (removes the lockfile).
struct RegistryLock {
path: PathBuf,
}
impl Drop for RegistryLock {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.path);
}
}
fn lock_path() -> PathBuf {
registry_path().with_extension("lock")
}
/// Take the registry lock, spinning until it's free or presumed-stale. The lock
/// is a `create_new` (O_EXCL) sentinel file — atomic across processes on a local
/// filesystem — which lets concurrent `/loop` wave members serialize their
/// registry writes instead of clobbering each other through last-writer-wins.
fn acquire_lock() -> Result<RegistryLock> {
let path = lock_path();
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
}
let deadline =
std::time::Instant::now() + std::time::Duration::from_secs(LOCK_STALE_SECS + 5);
loop {
match std::fs::OpenOptions::new().write(true).create_new(true).open(&path) {
Ok(mut f) => {
use std::io::Write;
let _ = writeln!(f, "{}", std::process::id());
return Ok(RegistryLock { path });
}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
// Reclaim a lock whose holder appears to have died.
if let Ok(age) = std::fs::metadata(&path).and_then(|m| m.modified()) {
if age.elapsed().map(|d| d.as_secs() > LOCK_STALE_SECS).unwrap_or(false) {
let _ = std::fs::remove_file(&path);
continue;
}
}
if std::time::Instant::now() > deadline {
anyhow::bail!(
"registry locked (held >{LOCK_STALE_SECS}s) — another save/publish in progress?"
);
}
std::thread::sleep(std::time::Duration::from_millis(50));
}
Err(e) => return Err(e).with_context(|| format!("opening {}", path.display())),
}
}
}
/// Insert or replace the entry for `entry.label`.
pub fn upsert(entry: Entry) -> Result<()> {
let _lock = acquire_lock()?;
let mut reg = load();
reg.entries.insert(entry.label.clone(), entry);
store(&reg)
@@ -126,6 +190,7 @@ pub fn get(label: &str) -> Option<Entry> {
/// `share_path` a peer can receive, merges in any `tags`, and flips `shareable`.
/// Errors if the label isn't in the registry. Idempotent.
pub fn publish(label: &str, share_path: &str, tags: &[String]) -> Result<Entry> {
let _lock = acquire_lock()?;
let mut reg = load();
let entry = reg
.entries
@@ -173,6 +238,9 @@ pub fn list_shareable() -> Vec<Entry> {
/// `image_exists` is injected so the engine probe stays out of this module's deps;
/// it is asked only about `artifact_kind == "image"` entries.
pub fn list_reconciled(image_exists: impl Fn(&str, &str) -> bool) -> Vec<Entry> {
// Best-effort lock: this prunes-and-stores, so serialize it against writers
// when we can, but never block a read-only listing if the lock is contended.
let _lock = acquire_lock().ok();
let mut reg = load();
let before = reg.entries.len();
reg.entries.retain(|_, e| match e.artifact_kind.as_str() {
+99
View File
@@ -0,0 +1,99 @@
//! Snapshot indexing + publishing — shared between the interactive TUI (`app.rs`,
//! the `/sbx save`/`/sbx publish` commands) and the headless `hack-house sbx`
//! subcommand (`main.rs`, used by the autonomous `/loop` runner). Both paths call
//! these same functions so a VM saved by an operator agent lands a byte-identical
//! registry entry to one saved by a human in the room — no schema drift between
//! the two save/publish surfaces.
use crate::registry;
use crate::sbx;
/// Size in bytes of an OCI image (`<engine> image inspect … --format {{.Size}}`),
/// or None if the engine/image isn't available. Blocking.
pub 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()
}
/// 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` (TUI) or directly (headless CLI).
pub 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}");
}
}
/// 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.
pub 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}`"
))
}