From e5fb820e632ba89fc4a2dcd4678970063d773430 Mon Sep 17 00:00:00 2001 From: leetcrypt Date: Tue, 30 Jun 2026 14:06:19 -0700 Subject: [PATCH] feat(loop): headless sbx save/publish + hh-loop VM-library skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 4 + hh/src/app.rs | 97 +-------------------- hh/src/main.rs | 67 +++++++++++++++ hh/src/registry.rs | 70 ++++++++++++++- hh/src/snapshot.rs | 99 +++++++++++++++++++++ skills/hh-loop/SKILL.md | 186 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 429 insertions(+), 94 deletions(-) create mode 100644 hh/src/snapshot.rs create mode 100644 skills/hh-loop/SKILL.md diff --git a/.gitignore b/.gitignore index 3212e45..e411b73 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,10 @@ err.log # Sandbox save/load snapshots (large runtime tarballs, not source) /hh/hh-snapshots/ +# VM-library /loop artifacts — exported snapshot tars (repo-root, when `sbx` +# is run from here) + per-run logs. Generated; the VMs live in the host registry. +/hh-snapshots/ +/.loop-runs/ # Out-of-tree experiments (not part of hack-house) /experiments/ diff --git a/hh/src/app.rs b/hh/src/app.rs index 1cdc0e4..8cd41a4 100644 --- a/hh/src/app.rs +++ b/hh/src/app.rs @@ -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::(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-