979e820f68
Three confirmed-live bugs tonight: - `nsbx up` printed "daemon did not become ready" immediately followed by a green "your sandbox is ready" banner and exited 0, because the existing- sandbox restart path (`daemon_alive || start_daemon`) never checked start_daemon's return code. `cmd_build` had the identical unguarded pattern, plus `cmd_run`/`cmd_validate`'s own start-if-dead calls. All four now `|| die` with a message pointing at daemon.log. - `nsbx status`/`nsbx list` reported bare "state: running" for a process that's alive (passes kill -0) but not actually answering /api/stats -- pegged, hung, or mid-boot. Added daemon_health(), which does the real stats fetch and distinguishes stopped/running/unresponsive; both commands now say "running but NOT RESPONDING" with a next-step hint instead of silently going quiet on the stats field. Reproduced live against another agent's actively-running (CPU-pinned, non-responsive) sandbox tonight, and again via a deliberate SIGSTOP on a throwaway sandbox. - Sandboxes carried no visible signal that their binary predated a relevant fix. `status`/`list` now show the binary's sha + real build timestamp (mtime survives `cp -p`), plus a best-effort staleness note: for stock-prod clones, compare against the currently-configured live binary; for source/branch builds, compare the recorded source commit against local origin/dev via merge-base --is-ancestor. Also, found live while verifying the above: - A cold boot under concurrent sandbox/CPU load can legitimately take past the old hardcoded 15s readiness window. Made it configurable (NSBX_READY_TIMEOUT_SECS) rather than just widening the default blindly. - cmd_create's post-boot baseline capture could silently record sbx_baseline as 0/0 when the stats fetch came back empty right after the auto-remerge step -- which would make every future `nsbx validate` zero-loss/reboot- prove check trivially PASS regardless of real data loss. Added a bounded retry and a loud warning if it still comes back empty. - Sharpened a handful of "no such sandbox" / missing-binary errors to name the next command instead of just stating the failure.
942 lines
47 KiB
Bash
Executable File
942 lines
47 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
# nsbx — the Neuron Sandbox: a reproducible primitive for running experiments and
|
||
# code changes against the REAL engram runtime on an isolated snapshot of the live
|
||
# mind, with a gated promote-to-prod path built on the proven rails.
|
||
#
|
||
# It WRAPS the real engram binary — it never reimplements any engram logic. The only
|
||
# prod-touching op is `promote`, which is explicit, gated, and per-use approved.
|
||
#
|
||
# Generalises two proven proto-sandboxes:
|
||
# - the cog-arch build (isolated git worktree + build + clone of live .egm + real C tests)
|
||
# - the store-fix cutover (secondary soul + launchctl bootout->settle->bootstrap rails)
|
||
#
|
||
# Lifecycle: create -> [build] -> run -> validate -> promote(gated) -> destroy
|
||
#
|
||
# Rails (always): built offline; NEVER auto-promotes; never touches live :8742/:7770
|
||
# except READ for the snapshot and the gated promote; snapshot-first; honest measured
|
||
# reporting. Cutover is launchctl bootout -> settle-poll -> bootstrap ONLY —
|
||
# never pkill, never kickstart -k.
|
||
set -uo pipefail
|
||
|
||
# ---------------------------------------------------------------- constants ----
|
||
LIVE_DATA_DIR="${ENGRAM_LIVE_DATA_DIR:-$HOME/.neuron/engram}"
|
||
LIVE_PLIST="${ENGRAM_LIVE_PLIST:-$HOME/Library/LaunchAgents/ai.neuron.engram.plist}"
|
||
LIVE_LABEL="ai.neuron.engram"
|
||
LIVE_BIND_PORT=8742 # engram — FORBIDDEN for sandboxes
|
||
SOUL_PORT=7770 # soul — FORBIDDEN for sandboxes
|
||
LIVE_KEY="${ENGRAM_API_KEY:-ntn-user-2026}"
|
||
LIVE_URL="http://127.0.0.1:${LIVE_BIND_PORT}"
|
||
SBX_ROOT="${NSBX_ROOT:-$HOME/.neuron/sandboxes}"
|
||
BACKUP_ROOT="$HOME/.neuron/backups"
|
||
EL_REPO="${EL_REPO:-$HOME/Development/neuron-technologies/foundation/el}"
|
||
PORT_BASE="${NSBX_PORT_BASE:-8900}"
|
||
RSS_BOUND_MB="${NSBX_RSS_BOUND_MB:-550}" # from store-fix reboot-proof (aaf13f88)
|
||
REMERGE_THRESHOLD="${NSBX_REMERGE_THRESHOLD:-40000}"
|
||
# readiness-poll window for start_daemon (0.5s ticks). Default unchanged (15s) —
|
||
# but a cold boot against the full live store, under concurrent CPU contention
|
||
# from other running sandboxes, has been observed live to take well past that.
|
||
# Bump per-invocation with NSBX_READY_TIMEOUT_SECS if `up`/`create` reports a
|
||
# not-ready failure but the daemon looks otherwise fine (see its logs/daemon.log).
|
||
READY_TICKS=$(( ${NSBX_READY_TIMEOUT_SECS:-15} * 2 ))
|
||
KEYSTONES=( "kn-efeb4a5b-5aff-4759-8a97-7233099be6ee" "kn-5b606390-a52d-4ca2-8e0e-eba141d13440" )
|
||
# fixed probe set for retrieval-parity (stable, identity-anchored)
|
||
PARITY_QUERIES=( "who am I" "self identity core" "engram store durability" "keystone self anchor" "grounding honesty" )
|
||
|
||
C_RED=$'\033[31m'; C_GRN=$'\033[32m'; C_YEL=$'\033[33m'; C_DIM=$'\033[2m'; C_BLD=$'\033[1m'; C_0=$'\033[0m'
|
||
|
||
# ---------------------------------------------------------------- helpers ------
|
||
die(){ printf '%serror:%s %s\n' "$C_RED" "$C_0" "$*" >&2; exit 1; }
|
||
log(){ printf '%s==>%s %s\n' "$C_BLD" "$C_0" "$*" >&2; }
|
||
info(){ printf ' %s\n' "$*" >&2; }
|
||
ok(){ printf ' %s%s%s\n' "$C_GRN" "$*" "$C_0" >&2; }
|
||
warn(){ printf ' %s%s%s\n' "$C_YEL" "$*" "$C_0" >&2; }
|
||
need(){ command -v "$1" >/dev/null 2>&1 || die "missing dependency: $1"; }
|
||
now(){ date -u +%Y%m%dT%H%M%SZ; }
|
||
sha(){ shasum -a 256 "$1" 2>/dev/null | awk '{print $1}'; }
|
||
epoch(){ python3 -c 'import time;print(time.time())'; }
|
||
|
||
sdir(){ printf '%s/%s' "$SBX_ROOT" "$1"; }
|
||
manifest(){ printf '%s/manifest.json' "$(sdir "$1")"; }
|
||
mexists(){ [ -f "$(manifest "$1")" ]; }
|
||
mget(){ # mget <name> <jsonpath>
|
||
python3 -c "import json,sys; d=json.load(open('$(manifest "$1")')); print(d$2)" 2>/dev/null
|
||
}
|
||
|
||
port_free(){ ! (exec 3<>"/dev/tcp/127.0.0.1/$1") 2>/dev/null; }
|
||
alloc_port(){
|
||
local p="$PORT_BASE"
|
||
while :; do
|
||
if [ "$p" = "$LIVE_BIND_PORT" ] || [ "$p" = "$SOUL_PORT" ]; then p=$((p+1)); continue; fi
|
||
if port_free "$p" && ! _port_claimed "$p"; then echo "$p"; return 0; fi
|
||
p=$((p+1)); [ "$p" -gt 9100 ] && die "no free sandbox port in range"
|
||
done
|
||
}
|
||
_port_claimed(){ # is another sandbox already assigned this port?
|
||
local p="$1" d
|
||
for d in "$SBX_ROOT"/*/manifest.json; do
|
||
[ -f "$d" ] || continue
|
||
[ "$(python3 -c "import json;print(json.load(open('$d'))['port'])" 2>/dev/null)" = "$p" ] && return 0
|
||
done
|
||
return 1
|
||
}
|
||
|
||
live_stats(){ curl -s -m5 "$LIVE_URL/api/stats" 2>/dev/null; }
|
||
api(){ # api <name> <path> [json-body]
|
||
local name="$1" path="$2" body="${3:-}"
|
||
local port; port="$(mget "$name" "['port']")"; [ -n "$port" ] || die "unknown sandbox: $name (run: nsbx list)"
|
||
local url="http://127.0.0.1:${port}${path}"
|
||
if [ -n "$body" ]; then curl -s -m30 -X POST -H 'Content-Type: application/json' -d "$body" "$url"
|
||
else curl -s -m30 "$url"; fi
|
||
}
|
||
sbx_stats(){ api "$1" "/api/stats"; }
|
||
stat_field(){ printf '%s' "$1" | sed -n "s/.*\"$2\":\([0-9]*\).*/\1/p"; }
|
||
|
||
daemon_pid(){ local f; f="$(sdir "$1")/daemon.pid"; [ -f "$f" ] && cat "$f" || true; }
|
||
daemon_alive(){ local p; p="$(daemon_pid "$1")"; [ -n "$p" ] && kill -0 "$p" 2>/dev/null; }
|
||
|
||
# daemon_health <name> : prints "stopped" | "running" | "unresponsive" (to stdout).
|
||
# "running" means the pid is alive AND /api/stats actually answered — process
|
||
# liveness alone (daemon_alive) is not proof the HTTP server is serving; a pegged
|
||
# or hung process still passes kill -0. Short timeout (2s) since this runs per-row
|
||
# in `nsbx list`.
|
||
daemon_health(){
|
||
local name="$1"
|
||
daemon_alive "$name" || { echo "stopped"; return 0; }
|
||
local port; port="$(mget "$name" "['port']")"
|
||
local s; s="$(curl -s -m2 "http://127.0.0.1:${port}/api/stats" 2>/dev/null)"
|
||
[ -n "$s" ] && echo "running" || echo "unresponsive"
|
||
}
|
||
|
||
# ---------------------------------------------------------------- elc/build ----
|
||
find_elc(){
|
||
command -v elc 2>/dev/null && return 0
|
||
local arch; arch="$(uname -m)"
|
||
case "$arch" in
|
||
arm64) echo "$EL_REPO/lang/dist/platform/elc-darwin-arm64";;
|
||
x86_64) echo "$EL_REPO/lang/dist/platform/elc-linux-amd64";;
|
||
*) echo "$EL_REPO/lang/dist/platform/elc";;
|
||
esac
|
||
}
|
||
|
||
# _build_binary <src_tree> <out_bin> <build_log_dir>
|
||
# Replicates the proven engram release recipe:
|
||
# elc engram/src/server.el > engram.c
|
||
# cc -std=c11 -O2 -I lang/runtime engram.c el_runtime.c engram_*.c -lcurl -lpthread
|
||
_build_binary(){
|
||
local src="$1" out="$2" blog="$3"
|
||
local elc server rt
|
||
elc="$(find_elc)"; [ -x "$elc" ] || die "elc not found/executable: $elc (set EL_REPO)"
|
||
server="$src/engram/src/server.el"; rt="$src/lang/runtime"
|
||
[ -f "$server" ] || die "no engram/src/server.el under source tree: $src"
|
||
[ -f "$rt/el_runtime.c" ] || die "no lang/runtime/el_runtime.c under source tree: $src (this branch may keep it generated/untracked)"
|
||
ls "$rt"/engram_*.c >/dev/null 2>&1 || die "no lang/runtime/engram_*.c engine sources under: $src"
|
||
mkdir -p "$blog"
|
||
log "build: elc transpile server.el -> engram.c"
|
||
"$elc" "$server" > "$blog/engram.c" 2>"$blog/elc.err" || { cat "$blog/elc.err" >&2; die "elc transpile failed"; }
|
||
info "engram.c: $(wc -c <"$blog/engram.c" | tr -d ' ') bytes"
|
||
log "build: cc link (el_runtime + engram_* engine)"
|
||
cc -std=c11 -O2 -w -I "$rt" -o "$out" \
|
||
"$blog/engram.c" "$rt/el_runtime.c" "$rt"/engram_*.c \
|
||
-lcurl -lpthread 2>"$blog/cc.err" \
|
||
|| { grep -i 'error:' "$blog/cc.err" | sort -u | head >&2; die "cc link failed (see $blog/cc.err)"; }
|
||
ok "built: $out ($(ls -lh "$out" | awk '{print $5}'), sha $(sha "$out" | cut -c1-12))"
|
||
}
|
||
|
||
# bin_built_at <path> : human-readable build timestamp. `cp -p` preserves mtime,
|
||
# so this is the ORIGINAL build time even for binaries copied stock-prod into a
|
||
# sandbox — not the copy time.
|
||
bin_built_at(){ stat -f '%Sm' -t '%Y-%m-%d %H:%M:%S' "$1" 2>/dev/null || echo "unknown"; }
|
||
|
||
# _binary_freshness <name> : best-effort staleness note (empty string if fresh/
|
||
# unknown — never guesses). Two cases:
|
||
# - stock-prod: compares the sha recorded at create time against the CURRENTLY
|
||
# configured live real binary's sha (recomputed now, not cached) — catches
|
||
# "live prod moved on since this sandbox was cloned".
|
||
# - branch/source/rebuilt: compares the recorded source_commit against the
|
||
# LOCAL origin/dev ref (no fetch — reads whatever the repo already has) —
|
||
# catches "built from a commit that predates current dev tip".
|
||
_binary_freshness(){
|
||
local name="$1" src; src="$(mget "$name" "['source']")"
|
||
case "$src" in
|
||
stock-prod:*)
|
||
local live_bin cur_sha rec_sha
|
||
live_bin="$(_live_real_bin)"
|
||
[ -n "$live_bin" ] && [ -x "$live_bin" ] || return 0
|
||
cur_sha="$(sha "$live_bin")"; rec_sha="$(mget "$name" "['binary_sha256']")"
|
||
[ -n "$cur_sha" ] && [ -n "$rec_sha" ] && [ "$cur_sha" != "$rec_sha" ] \
|
||
&& printf 'stale: live prod binary has moved on since this sandbox was cloned (live is now %s, sha %s) — nsbx build %s --binary %s to catch up' \
|
||
"$(basename "$live_bin")" "${cur_sha:0:12}" "$name" "$live_bin"
|
||
;;
|
||
branch:*|source:*|rebuilt:*)
|
||
local commit cur behind
|
||
commit="$(mget "$name" "['source_commit']")"
|
||
[ -n "$commit" ] || return 0
|
||
cur="$(git -C "$EL_REPO" rev-parse origin/dev 2>/dev/null)" || return 0
|
||
[ -n "$cur" ] && [ "$commit" != "$cur" ] || return 0
|
||
git -C "$EL_REPO" merge-base --is-ancestor "$commit" "$cur" 2>/dev/null || return 0
|
||
behind="$(git -C "$EL_REPO" rev-list --count "$commit..$cur" 2>/dev/null)"
|
||
printf 'stale: built from %s, %s commit(s) behind local origin/dev (%s) — nsbx build %s --branch origin/dev' \
|
||
"${commit:0:12}" "${behind:-?}" "${cur:0:12}" "$name"
|
||
;;
|
||
esac
|
||
}
|
||
|
||
# _source_commit <dir> : best-effort git HEAD of a source tree used to build a
|
||
# sandbox binary, empty if not a git repo (e.g. a prebuilt --binary path has none).
|
||
_source_commit(){ git -C "$1" rev-parse HEAD 2>/dev/null || true; }
|
||
|
||
# ---------------------------------------------------------------- daemon -------
|
||
# start_daemon <name> : boots the sandbox's real engram binary on its isolated
|
||
# port against its cloned data dir, with the SAME auto-remerge net the live soul
|
||
# uses (so the sandbox faithfully reaches the live edge population on boot).
|
||
start_daemon(){
|
||
local name="$1" d; d="$(sdir "$name")"
|
||
daemon_alive "$name" && { info "already running (pid $(daemon_pid "$name"))"; return 0; }
|
||
local port bin data export key
|
||
port="$(mget "$name" "['port']")"; bin="$d/bin/engram"; data="$d/data"
|
||
key="sbx-$name"; export="$data/.scan-export.reseed-clean.json"
|
||
[ -x "$bin" ] || die "sandbox binary missing: $bin (run: nsbx build $name --source DIR | --branch REF, or nsbx destroy $name && nsbx create $name to reclone stock-prod)"
|
||
[ "$port" != "$LIVE_BIND_PORT" ] && [ "$port" != "$SOUL_PORT" ] || die "refusing forbidden port $port"
|
||
[ -f "$data/neuron.egm" ] || die "sandbox has no cloned store: $data/neuron.egm (data dir is corrupt/incomplete — run: nsbx destroy $name && nsbx create $name)"
|
||
# HARD guard: never point a sandbox daemon at the live data dir.
|
||
[ "$(cd "$data" && pwd -P)" != "$(cd "$LIVE_DATA_DIR" && pwd -P)" ] || die "refusing: sandbox data dir resolves to LIVE store"
|
||
|
||
log "boot engram on isolated :$port (data=$data)"
|
||
(
|
||
ENGRAM_DATA_DIR="$data" ENGRAM_BIND=":$port" ENGRAM_API_KEY="$key" \
|
||
ENGRAM_STORE=1 ENGRAM_CHRONOCEPTION=1 ENGRAM_SELF_REIFY=1 ENGRAM_GC=1 \
|
||
ENGRAM_POOL_FRAMES=16384 ENGRAM_WRITE_BARRIER=1 \
|
||
exec "$bin"
|
||
) >"$d/logs/daemon.log" 2>&1 &
|
||
local pid=$!
|
||
echo "$pid" > "$d/daemon.pid"
|
||
# readiness poll
|
||
local url="http://127.0.0.1:$port" i s
|
||
for i in $(seq 1 "$READY_TICKS"); do
|
||
s="$(curl -s -m3 "$url/api/stats" 2>/dev/null)"
|
||
[ -n "$s" ] && break; sleep 0.5
|
||
done
|
||
[ -n "$s" ] || { warn "daemon did not become ready within ${NSBX_READY_TIMEOUT_SECS:-15}s (see $d/logs/daemon.log). pid $pid may still be alive and slow to boot under load — check: lsof -iTCP:$port -P, or retry with NSBX_READY_TIMEOUT_SECS=45"; return 1; }
|
||
ok "ready pid=$pid boot-stats: $s"
|
||
# auto-remerge net (idempotent): match live edge population if the export is present
|
||
if [ -f "$export" ]; then
|
||
local edges; edges="$(stat_field "$s" edge_count)"
|
||
if [ -n "$edges" ] && [ "$edges" -lt "$REMERGE_THRESHOLD" ]; then
|
||
log "auto-remerge: booted with $edges edges (< $REMERGE_THRESHOLD) — merging full edge export"
|
||
local r; r="$(curl -s -m300 -X POST -H 'Content-Type: application/json' \
|
||
-d "{\"_auth\":\"$key\",\"path\":\"$export\"}" "$url/api/load-merge" 2>/dev/null)"
|
||
info "remerge resp: ${r:0:120}"
|
||
ok "post-remerge stats: $(curl -s -m5 "$url/api/stats")"
|
||
fi
|
||
fi
|
||
return 0
|
||
}
|
||
|
||
# stop_daemon <name> : graceful TERM + settle-poll until the port is free.
|
||
# (Sandbox daemons are plain supervised bg processes — not launchd — so teardown
|
||
# is a signal + poll, never pkill of anything else.)
|
||
stop_daemon(){
|
||
local name="$1" pid port
|
||
pid="$(daemon_pid "$name")"; port="$(mget "$name" "['port']")"
|
||
[ -n "$pid" ] || { info "not running"; return 0; }
|
||
log "stop daemon pid=$pid, settle-poll until :$port frees"
|
||
kill "$pid" 2>/dev/null || true
|
||
local i
|
||
for i in $(seq 1 40); do
|
||
kill -0 "$pid" 2>/dev/null || { port_free "$port" && { ok "stopped, port $port free"; : >"$(sdir "$name")/daemon.pid"; return 0; }; }
|
||
printf '.' >&2; sleep 0.5
|
||
done
|
||
printf '\n' >&2
|
||
kill -9 "$pid" 2>/dev/null || true; sleep 1
|
||
: >"$(sdir "$name")/daemon.pid"
|
||
port_free "$port" && ok "stopped (after SIGKILL), port $port free" || warn "port $port still busy"
|
||
}
|
||
|
||
# ================================================================ create =======
|
||
cmd_create(){
|
||
local name="" port="" src="" branch="" repo="$EL_REPO" binpath=""
|
||
# first positional arg is the name unless it's a flag; default to "<user>-dev"
|
||
if [ $# -gt 0 ] && [ "${1#-}" = "$1" ]; then name="$1"; shift; else name="${USER:-dev}-dev"; fi
|
||
while [ $# -gt 0 ]; do case "$1" in
|
||
--port) port="$2"; shift 2;;
|
||
--source) src="$2"; shift 2;;
|
||
--branch) branch="$2"; shift 2;;
|
||
--repo) repo="$2"; shift 2;;
|
||
--binary) binpath="$2"; shift 2;;
|
||
*) die "unknown flag: $1";;
|
||
esac; done
|
||
mexists "$name" && die "sandbox '$name' already exists (destroy it first)"
|
||
need curl; need python3; need shasum
|
||
[ -f "$LIVE_DATA_DIR/neuron.egm" ] || die "live store not found: $LIVE_DATA_DIR/neuron.egm"
|
||
if [ -n "$port" ]; then
|
||
{ [ "$port" = "$LIVE_BIND_PORT" ] || [ "$port" = "$SOUL_PORT" ]; } && die "refusing forbidden port $port (live)"
|
||
port_free "$port" || die "port $port already in use"
|
||
else port="$(alloc_port)"; fi
|
||
|
||
local d; d="$(sdir "$name")"
|
||
mkdir -p "$d/data" "$d/bin" "$d/logs" "$d/build" "$d/baseline"
|
||
log "sandbox '$name' at $d (isolated port $port)"
|
||
|
||
# ---- CONSISTENT snapshot of the live mind (file-copy: same set the rails backup
|
||
# uses; WAL replay on sandbox boot reconciles the tail -> crash-consistent) ----
|
||
log "snapshot live store -> clone (store + WAL + config)"
|
||
local f
|
||
for f in neuron.egm neuron.wal conf meta.json self_anchor .scan-export.reseed-clean.json; do
|
||
if [ -e "$LIVE_DATA_DIR/$f" ]; then cp -p "$LIVE_DATA_DIR/$f" "$d/data/$f"; info "cloned $f ($(du -h "$d/data/$f" | awk '{print $1}'))"; fi
|
||
done
|
||
local egm_sha; egm_sha="$(sha "$d/data/neuron.egm")"
|
||
|
||
# ---- capture live baseline (READ only) ----
|
||
local lstats; lstats="$(live_stats)"
|
||
local base_nodes base_edges
|
||
base_nodes="$(stat_field "$lstats" node_count)"; base_edges="$(stat_field "$lstats" edge_count)"
|
||
info "live baseline stats: ${lstats:-<unavailable>}"
|
||
|
||
# ---- determine + place the runtime binary (versioned into the snapshot) ----
|
||
local source_desc live_bin source_commit=""
|
||
live_bin="$(_live_real_bin)"
|
||
if [ -n "$binpath" ]; then
|
||
[ -x "$binpath" ] || die "not an executable binary: $binpath"
|
||
cp -p "$binpath" "$d/bin/engram"; source_desc="prebuilt:$binpath"
|
||
elif [ -n "$src" ]; then
|
||
_build_binary "$src" "$d/bin/engram" "$d/build"; source_desc="source:$src"
|
||
source_commit="$(_source_commit "$src")"
|
||
elif [ -n "$branch" ]; then
|
||
log "worktree: $repo @ $branch -> $d/build/worktree"
|
||
git -C "$repo" worktree add --detach "$d/build/worktree" "$branch" >/dev/null 2>&1 \
|
||
|| die "git worktree add failed ($repo @ $branch)"
|
||
_build_binary "$d/build/worktree" "$d/bin/engram" "$d/build"; source_desc="branch:$branch@$repo"
|
||
source_commit="$(_source_commit "$d/build/worktree")"
|
||
else
|
||
[ -x "$live_bin" ] || die "cannot resolve live ENGRAM_REAL_BIN: $live_bin"
|
||
cp -p "$live_bin" "$d/bin/engram"; source_desc="stock-prod:$live_bin"
|
||
fi
|
||
local bin_sha; bin_sha="$(sha "$d/bin/engram")"
|
||
info "runtime: $source_desc (sha ${bin_sha:0:12}, built $(bin_built_at "$d/bin/engram"))"
|
||
|
||
# ---- write manifest ----
|
||
python3 - "$name" "$port" "$source_desc" "$bin_sha" "$egm_sha" "$base_nodes" "$base_edges" "$(sha "$live_bin" 2>/dev/null)" "$source_commit" <<'PY' > "$(manifest "$name")"
|
||
import json,sys,datetime
|
||
name,port,src,binsha,egmsha,bn,be,livebinsha,source_commit=sys.argv[1:10]
|
||
json.dump({
|
||
"name":name,"port":int(port),"created_at":datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||
"source":src,"binary_sha256":binsha,"clone_egm_sha256":egmsha,
|
||
"live_binary_sha256":livebinsha,"source_commit":source_commit,
|
||
"live_baseline":{"node_count":int(bn or 0),"edge_count":int(be or 0)},
|
||
"keystones":["kn-efeb4a5b-5aff-4759-8a97-7233099be6ee","kn-5b606390-a52d-4ca2-8e0e-eba141d13440"]
|
||
}, sys.stdout, indent=2)
|
||
PY
|
||
ok "manifest written"
|
||
|
||
# ---- boot + capture the sandbox's own settled baseline (reproducible target) ----
|
||
start_daemon "$name" || die "daemon failed to start"
|
||
local sstats; sstats="$(sbx_stats "$name")"
|
||
local sbn sbe; sbn="$(stat_field "$sstats" node_count)"; sbe="$(stat_field "$sstats" edge_count)"
|
||
# a boot immediately followed by an auto-remerge can leave the daemon briefly
|
||
# busy — retry rather than silently folding a 0/0 baseline into the manifest.
|
||
# `validate`'s zero-loss/reboot-prove checks compare current counts >= baseline,
|
||
# so a 0/0 baseline would make them trivially PASS regardless of real data loss.
|
||
local _bi
|
||
for _bi in 1 2 3 4 5; do
|
||
[ -n "$sbn" ] && [ "$sbn" != "0" ] && break
|
||
sleep 1
|
||
sstats="$(sbx_stats "$name")"; sbn="$(stat_field "$sstats" node_count)"; sbe="$(stat_field "$sstats" edge_count)"
|
||
done
|
||
[ -z "$sbn" ] || [ "$sbn" = "0" ] && warn "sandbox stats still empty/zero after retries — recording sbx_baseline 0/0. This makes 'nsbx validate $name' zero-loss checks trivially pass; investigate before trusting a validate PASS: nsbx status $name"
|
||
_capture_retrieval "$name" "$d/baseline/retrieval.json"
|
||
# fold sandbox baseline into manifest
|
||
python3 - "$(manifest "$name")" "$sbn" "$sbe" <<'PY'
|
||
import json,sys
|
||
mf,bn,be=sys.argv[1],sys.argv[2],sys.argv[3]
|
||
d=json.load(open(mf)); d["sbx_baseline"]={"node_count":int(bn or 0),"edge_count":int(be or 0)}
|
||
json.dump(d,open(mf,'w'),indent=2)
|
||
PY
|
||
log "created."
|
||
info "sandbox baseline (settled): nodes=$sbn edges=$sbe"
|
||
info "next: nsbx validate $name | nsbx run $name api /api/stats"
|
||
}
|
||
|
||
_live_real_bin(){
|
||
python3 - "$LIVE_PLIST" <<'PY' 2>/dev/null
|
||
import sys,plistlib
|
||
try:
|
||
d=plistlib.load(open(sys.argv[1],'rb'))
|
||
print(d.get("EnvironmentVariables",{}).get("ENGRAM_REAL_BIN",""))
|
||
except Exception: print("")
|
||
PY
|
||
}
|
||
|
||
_capture_retrieval(){ # <name> <outfile> : top-k ids for the fixed probe set
|
||
local name="$1" out="$2" q res
|
||
local port; port="$(mget "$name" "['port']")"; local key="sbx-$name"
|
||
{
|
||
echo "{"
|
||
local first=1
|
||
for q in "${PARITY_QUERIES[@]}"; do
|
||
res="$(curl -s -m10 -X POST -H 'Content-Type: application/json' \
|
||
-d "{\"_auth\":\"$key\",\"query\":\"$q\",\"limit\":5}" "http://127.0.0.1:$port/api/search" 2>/dev/null)"
|
||
local ids; ids="$(printf '%s' "$res" | python3 -c 'import sys,json
|
||
try:
|
||
d=json.load(sys.stdin)
|
||
rows=d if isinstance(d,list) else d.get("results",d.get("hits",[]))
|
||
print(json.dumps([r.get("id") for r in rows][:5]))
|
||
except Exception: print("[]")' 2>/dev/null)"
|
||
[ $first -eq 1 ] || echo ","; first=0
|
||
printf ' %s: %s' "$(python3 -c "import json,sys;print(json.dumps(sys.argv[1]))" "$q")" "${ids:-[]}"
|
||
done
|
||
echo ""; echo "}"
|
||
} > "$out"
|
||
}
|
||
|
||
# ================================================================ up ===========
|
||
# Dead-simple one-command dev environment: `nsbx up` gives you (or Tim, or anyone)
|
||
# a private, isolated copy of the live mind to build against. Creates it on first
|
||
# run with sane defaults (stock prod binary, auto-allocated port), just starts it
|
||
# thereafter. Prod on :$LIVE_BIND_PORT/:$SOUL_PORT is unreachable from here by design.
|
||
cmd_up(){
|
||
local name; if [ $# -gt 0 ] && [ "${1#-}" = "$1" ]; then name="$1"; shift; else name="${USER:-dev}-dev"; fi
|
||
if mexists "$name"; then
|
||
daemon_alive "$name" || start_daemon "$name" \
|
||
|| die "daemon did not become ready — see $(sdir "$name")/logs/daemon.log (try: nsbx up $name again once you've checked the log)"
|
||
else
|
||
cmd_create "$name" "$@"
|
||
fi
|
||
local port; port="$(mget "$name" "['port']")"
|
||
echo >&2
|
||
ok "your sandbox '$name' is ready at http://127.0.0.1:$port (a private copy of the mind — prod is untouchable)"
|
||
info "experiment: nsbx run $name api /api/stats"
|
||
info "prove it: nsbx validate $name"
|
||
info "tear down: nsbx destroy $name"
|
||
}
|
||
|
||
# ================================================================ build ========
|
||
# Rebuild an existing sandbox's runtime from a source tree/branch and hot-restart
|
||
# it on the SAME clone + port (the code-change dev loop, in place).
|
||
cmd_build(){
|
||
local name="$1"; shift || true
|
||
mexists "$name" || die "no such sandbox: $name (run: nsbx list — or nsbx create $name to make it)"
|
||
local src="" branch="" repo="$EL_REPO"
|
||
while [ $# -gt 0 ]; do case "$1" in
|
||
--source) src="$2"; shift 2;; --branch) branch="$2"; shift 2;; --repo) repo="$2"; shift 2;;
|
||
*) die "unknown flag: $1";; esac; done
|
||
local d; d="$(sdir "$name")"
|
||
stop_daemon "$name"
|
||
local source_commit=""
|
||
if [ -n "$src" ]; then _build_binary "$src" "$d/bin/engram" "$d/build"; source_commit="$(_source_commit "$src")"
|
||
elif [ -n "$branch" ]; then
|
||
rm -rf "$d/build/worktree" 2>/dev/null; git -C "$repo" worktree prune 2>/dev/null
|
||
git -C "$repo" worktree add --detach "$d/build/worktree" "$branch" >/dev/null 2>&1 || die "worktree add failed"
|
||
_build_binary "$d/build/worktree" "$d/bin/engram" "$d/build"
|
||
source_commit="$(_source_commit "$d/build/worktree")"
|
||
else die "usage: nsbx build <name> --source DIR | --branch REF [--repo R]"; fi
|
||
# record new binary sha
|
||
python3 - "$(manifest "$name")" "$(sha "$d/bin/engram")" "${src:-branch:$branch}" "$source_commit" <<'PY'
|
||
import json,sys; mf,s,src,source_commit=sys.argv[1:5]
|
||
d=json.load(open(mf)); d["binary_sha256"]=s; d["source"]="rebuilt:"+src; d["source_commit"]=source_commit
|
||
json.dump(d,open(mf,'w'),indent=2)
|
||
PY
|
||
start_daemon "$name" \
|
||
|| die "rebuilt binary did not become ready — see $d/logs/daemon.log (the old binary is gone; fix the code and re-run nsbx build $name ...)"
|
||
ok "rebuilt + restarted on :$(mget "$name" "['port']")"
|
||
}
|
||
|
||
# ================================================================ run ==========
|
||
cmd_run(){
|
||
local name="$1"; shift || true
|
||
mexists "$name" || die "no such sandbox: $name (run: nsbx list — or nsbx create $name to make it)"
|
||
daemon_alive "$name" || start_daemon "$name" || die "daemon not running and failed to start — see $(sdir "$name")/logs/daemon.log"
|
||
local d port; d="$(sdir "$name")"; port="$(mget "$name" "['port']")"
|
||
# direct API form: nsbx run <name> api <path> [json]
|
||
if [ "${1:-}" = "api" ]; then
|
||
api "$name" "$2" "${3:-}"; echo; return 0
|
||
fi
|
||
[ "${1:-}" = "--" ] && shift # allow an explicit separator: nsbx run <name> -- <cmd...>
|
||
[ $# -gt 0 ] || die "usage: nsbx run <name> <cmd...> | nsbx run <name> api <path> [json]"
|
||
local ts log0; ts="$(now)"; log0="$d/logs/run-$ts.log"
|
||
local s0 t0 t1 s1
|
||
s0="$(sbx_stats "$name")"; t0="$(epoch)"
|
||
log "run experiment against sandbox '$name' (:$port)"
|
||
info "cmd: $*"
|
||
( export SBX_NAME="$name" SBX_PORT="$port" SBX_URL="http://127.0.0.1:$port" \
|
||
SBX_KEY="sbx-$name" SBX_DATA="$d/data" SBX_BIN="$d/bin/engram"
|
||
"$@" ) 2>&1 | tee "$log0"
|
||
local rc=${PIPESTATUS[0]}
|
||
t1="$(epoch)"; s1="$(sbx_stats "$name")"
|
||
{
|
||
echo "--- nsbx run metrics ---"
|
||
echo "exit_code: $rc"
|
||
printf 'wall_secs: %.3f\n' "$(python3 -c "print($t1-$t0)")"
|
||
echo "stats_before: $s0"
|
||
echo "stats_after: $s1"
|
||
} | tee -a "$log0" >&2
|
||
return $rc
|
||
}
|
||
|
||
# ================================================================ validate =====
|
||
# The rails as first-class checks. Baseline = the sandbox's own settled state at
|
||
# create (reproducible). zero-loss through sustained load AND reboot; reboot-prove;
|
||
# RSS bound; retrieval parity; keystone integrity.
|
||
cmd_validate(){
|
||
local name="$1"; shift || true
|
||
mexists "$name" || die "no such sandbox: $name (run: nsbx list — or nsbx create $name to make it)"
|
||
daemon_alive "$name" || start_daemon "$name" || die "daemon not running and failed to start — see $(sdir "$name")/logs/daemon.log"
|
||
local d port key; d="$(sdir "$name")"; port="$(mget "$name" "['port']")"; key="sbx-$name"
|
||
local url="http://127.0.0.1:$port"
|
||
local bn be; bn="$(mget "$name" "['sbx_baseline']['node_count']")"; be="$(mget "$name" "['sbx_baseline']['edge_count']")"
|
||
log "validate '$name' against baseline nodes=$bn edges=$be"
|
||
local -a names=() results=() details=()
|
||
|
||
# 1) sustained load — no data loss under activity
|
||
local s cur_n cur_e i
|
||
log "check: sustained load (~15s: tick + reads) then zero-loss"
|
||
for i in $(seq 1 15); do
|
||
curl -s -m5 -X POST -H 'Content-Type: application/json' -d "{\"_auth\":\"$key\"}" "$url/api/tick" >/dev/null 2>&1
|
||
curl -s -m5 "$url/api/stats" >/dev/null 2>&1
|
||
done
|
||
s="$(sbx_stats "$name")"; cur_n="$(stat_field "$s" node_count)"; cur_e="$(stat_field "$s" edge_count)"
|
||
names+=("zero-loss-under-load"); if [ "${cur_n:-0}" -ge "${bn:-0}" ] && [ "${cur_e:-0}" -ge "${be:-0}" ]; then
|
||
results+=("PASS"); else results+=("FAIL"); fi
|
||
details+=("nodes $cur_n>=$bn, edges $cur_e>=$be")
|
||
|
||
# 2) reboot-prove — counts survive a real restart
|
||
log "check: reboot-prove (stop -> start -> compare)"
|
||
local pre_n pre_e; pre_n="$cur_n"; pre_e="$cur_e"
|
||
stop_daemon "$name"; start_daemon "$name" >/dev/null
|
||
s="$(sbx_stats "$name")"; cur_n="$(stat_field "$s" node_count)"; cur_e="$(stat_field "$s" edge_count)"
|
||
names+=("reboot-prove"); if [ "${cur_n:-0}" -ge "${bn:-0}" ] && [ "${cur_e:-0}" -ge "${be:-0}" ]; then
|
||
results+=("PASS"); else results+=("FAIL"); fi
|
||
details+=("post-reboot nodes=$cur_n edges=$cur_e (pre $pre_n/$pre_e)")
|
||
|
||
# 3) RSS bound
|
||
log "check: RSS bound (< ${RSS_BOUND_MB}MB)"
|
||
local pid rss_kb rss_mb; pid="$(daemon_pid "$name")"
|
||
rss_kb="$(ps -o rss= -p "$pid" 2>/dev/null | tr -d ' ')"; rss_mb=$(( ${rss_kb:-0} / 1024 ))
|
||
names+=("rss-bound"); if [ "$rss_mb" -lt "$RSS_BOUND_MB" ] && [ "$rss_mb" -gt 0 ]; then results+=("PASS"); else results+=("FAIL"); fi
|
||
details+=("RSS=${rss_mb}MB (bound ${RSS_BOUND_MB}MB)")
|
||
|
||
# 4) retrieval parity vs the create-time baseline
|
||
log "check: retrieval parity vs baseline probe set"
|
||
_capture_retrieval "$name" "$d/logs/retrieval-$( now ).json"
|
||
local latest; latest="$(ls -t "$d/logs"/retrieval-*.json 2>/dev/null | head -1)"
|
||
local parity; parity="$(python3 - "$d/baseline/retrieval.json" "$latest" <<'PY'
|
||
import json,sys
|
||
def load(p):
|
||
try: return json.load(open(p))
|
||
except Exception: return {}
|
||
b,c=load(sys.argv[1]),load(sys.argv[2])
|
||
tot=hit=0
|
||
for q,ids in b.items():
|
||
cb=set(ids or []); cc=set(c.get(q) or [])
|
||
if not cb: continue
|
||
tot+=len(cb); hit+=len(cb & cc)
|
||
print(f"{hit}/{tot}" if tot else "0/0")
|
||
PY
|
||
)"
|
||
local ph="${parity%/*}" pt="${parity#*/}"
|
||
names+=("retrieval-parity"); if [ "${pt:-0}" -gt 0 ] && [ "${ph:-0}" -eq "${pt:-0}" ]; then results+=("PASS"); else results+=("FAIL"); fi
|
||
details+=("top-k id overlap $parity vs baseline")
|
||
|
||
# 5) keystone integrity
|
||
log "check: keystone integrity"
|
||
local kfail=0 kid kres
|
||
for kid in "${KEYSTONES[@]}"; do
|
||
kres="$(curl -s -m5 "$url/api/node/$kid" 2>/dev/null)"
|
||
printf '%s' "$kres" | grep -q "\"$kid\"" || kfail=1
|
||
done
|
||
names+=("keystone-integrity"); [ "$kfail" -eq 0 ] && results+=("PASS") || results+=("FAIL")
|
||
details+=("kn-efeb4a5b + kn-5b606390 present")
|
||
|
||
# ---- report + stamp ----
|
||
echo >&2
|
||
printf '%s VALIDATION — %s%s\n' "$C_BLD" "$name" "$C_0" >&2
|
||
local allpass=1 j
|
||
for j in "${!names[@]}"; do
|
||
local r="${results[$j]}" c="$C_GRN"; [ "$r" = FAIL ] && { c="$C_RED"; allpass=0; }
|
||
printf ' %s%-6s%s %-22s %s%s%s\n' "$c" "$r" "$C_0" "${names[$j]}" "$C_DIM" "${details[$j]}" "$C_0" >&2
|
||
done
|
||
local status; [ "$allpass" -eq 1 ] && status="PASS" || status="FAIL"
|
||
python3 - "$d/validate.json" "$status" "$(sha "$d/bin/engram")" "$(now)" "${names[*]}" "${results[*]}" <<'PY'
|
||
import json,sys
|
||
out,status,binsha,ts,ns,rs=sys.argv[1:7]
|
||
checks=[{"name":n,"result":r} for n,r in zip(ns.split(),rs.split())]
|
||
json.dump({"status":status,"binary_sha256":binsha,"ts":ts,"checks":checks},open(out,'w'),indent=2)
|
||
PY
|
||
printf ' %s==> %s%s\n' "$([ "$allpass" -eq 1 ] && echo "$C_GRN" || echo "$C_RED")" "$status" "$C_0" >&2
|
||
[ "$allpass" -eq 1 ]
|
||
}
|
||
|
||
# ================================================================ promote ======
|
||
# The ONLY prod-touching op. Explicit, gated, per-use Will-approved. Rails ONLY:
|
||
# snapshot-first -> additive binary swap -> launchctl bootout -> settle-poll ->
|
||
# bootstrap -> verify -> auto-rollback on failure. NEVER pkill, NEVER kickstart -k.
|
||
# Default is a DRY-RUN plan; requires --i-approve-prod-cutover to actually cut over.
|
||
cmd_promote(){
|
||
local name="$1"; shift || true
|
||
mexists "$name" || die "no such sandbox: $name (run: nsbx list — or nsbx create $name to make it)"
|
||
local approve=0 do_data=0
|
||
while [ $# -gt 0 ]; do case "$1" in
|
||
--i-approve-prod-cutover) approve=1; shift;;
|
||
--data) do_data=1; shift;;
|
||
*) die "unknown flag: $1";; esac; done
|
||
local d; d="$(sdir "$name")"
|
||
# GATE 1: validation must have passed for the CURRENT binary
|
||
[ -f "$d/validate.json" ] || die "GATE: no validation on record — run 'nsbx validate $name' first"
|
||
local vstatus vsha bsha
|
||
vstatus="$(python3 -c "import json;print(json.load(open('$d/validate.json'))['status'])")"
|
||
vsha="$(python3 -c "import json;print(json.load(open('$d/validate.json'))['binary_sha256'])")"
|
||
bsha="$(sha "$d/bin/engram")"
|
||
[ "$vstatus" = PASS ] || die "GATE: last validation status is $vstatus (must be PASS)"
|
||
[ "$vsha" = "$bsha" ] || die "GATE: validation is stale — binary changed since validate (re-run validate)"
|
||
|
||
local live_bin new_bin ts; ts="$(now)"
|
||
live_bin="$(_live_real_bin)"
|
||
new_bin="$HOME/.neuron/bin/engram.promote-$name-$ts" # additive: new file, old kept
|
||
local bkp="$BACKUP_ROOT/promote-$name-$ts"
|
||
|
||
log "PROMOTE PLAN for '$name' -> live :$LIVE_BIND_PORT"
|
||
info "current live ENGRAM_REAL_BIN : $live_bin"
|
||
info "sandbox binary (validated) : $d/bin/engram (sha ${bsha:0:12})"
|
||
info "will install as : $new_bin (additive; old binary retained)"
|
||
info "snapshot-first backup dir : $bkp (egm+wal+plist+rollback.txt)"
|
||
info "data promote : $([ $do_data -eq 1 ] && echo 'YES (--data: clone egm/wal -> live)' || echo 'no (binary only)')"
|
||
info "rails : launchctl bootout -> settle-poll -> bootstrap"
|
||
info "verify : /api/stats + edges>=baseline + keystones + retrieval; auto-rollback armed"
|
||
|
||
if [ "$approve" -ne 1 ]; then
|
||
warn "DRY-RUN — not touching prod. Re-run with --i-approve-prod-cutover to execute (per-use Will-approved)."
|
||
return 0
|
||
fi
|
||
|
||
need launchctl
|
||
local dom="gui/$(id -u)"
|
||
# ---- snapshot-first ----
|
||
log "snapshot-first backup -> $bkp"
|
||
mkdir -p "$bkp"
|
||
cp -p "$LIVE_DATA_DIR/neuron.egm" "$bkp/neuron.egm.bak"
|
||
cp -p "$LIVE_DATA_DIR/neuron.wal" "$bkp/neuron.wal.bak" 2>/dev/null || true
|
||
cp -p "$LIVE_PLIST" "$bkp/plist.bak"
|
||
printf 'rollback REAL_BIN=%s\nNEWBIN=%s\ndata_promote=%s\n' "$live_bin" "$new_bin" "$do_data" > "$bkp/rollback.txt"
|
||
ok "backup complete"
|
||
|
||
# ---- additive binary install + plist supersede ----
|
||
cp -p "$d/bin/engram" "$new_bin"
|
||
python3 - "$LIVE_PLIST" "$new_bin" <<'PY'
|
||
import sys,plistlib
|
||
p,new=sys.argv[1],sys.argv[2]
|
||
d=plistlib.load(open(p,'rb')); d.setdefault("EnvironmentVariables",{})["ENGRAM_REAL_BIN"]=new
|
||
plistlib.dump(d,open(p,'wb'))
|
||
PY
|
||
ok "installed $new_bin + updated plist ENGRAM_REAL_BIN"
|
||
|
||
# ---- optional data promote (after backup) ----
|
||
if [ $do_data -eq 1 ]; then
|
||
log "data promote: clone store -> live (backed up above)"
|
||
cp -p "$d/data/neuron.egm" "$LIVE_DATA_DIR/neuron.egm"
|
||
cp -p "$d/data/neuron.wal" "$LIVE_DATA_DIR/neuron.wal" 2>/dev/null || true
|
||
fi
|
||
|
||
# ---- rails cutover: bootout -> settle-poll -> bootstrap ----
|
||
log "rails: launchctl bootout $dom/$LIVE_LABEL"
|
||
launchctl bootout "$dom/$LIVE_LABEL" 2>/dev/null || true
|
||
local i
|
||
for i in $(seq 1 60); do
|
||
launchctl print "$dom/$LIVE_LABEL" >/dev/null 2>&1 || { ok "settle: job gone after ${i}x0.5s"; break; }
|
||
printf ' settle: job still present (%d)\n' "$i" >&2; sleep 0.5
|
||
done
|
||
log "rails: launchctl bootstrap $dom <plist>"
|
||
launchctl bootstrap "$dom" "$LIVE_PLIST" || warn "bootstrap returned nonzero"
|
||
|
||
# ---- verify ----
|
||
log "verify prod health"
|
||
local s="" ; for i in $(seq 1 60); do s="$(live_stats)"; [ -n "$s" ] && break; sleep 1; done
|
||
local ok_verify=1 le; le="$(stat_field "$s" edge_count)"
|
||
local base_e; base_e="$(mget "$name" "['live_baseline']['edge_count']")"
|
||
[ -n "$s" ] || ok_verify=0
|
||
[ "${le:-0}" -ge "${base_e:-0}" ] || ok_verify=0
|
||
local kid; for kid in "${KEYSTONES[@]}"; do curl -s -m5 "$LIVE_URL/api/node/$kid" 2>/dev/null | grep -q "\"$kid\"" || ok_verify=0; done
|
||
if [ "$ok_verify" -eq 1 ]; then
|
||
ok "PROMOTED. live stats: $s (rollback: $bkp)"; return 0
|
||
fi
|
||
|
||
# ---- auto-rollback ----
|
||
warn "verify FAILED — auto-rollback"
|
||
cp -p "$bkp/plist.bak" "$LIVE_PLIST"
|
||
[ $do_data -eq 1 ] && { cp -p "$bkp/neuron.egm.bak" "$LIVE_DATA_DIR/neuron.egm"; cp -p "$bkp/neuron.wal.bak" "$LIVE_DATA_DIR/neuron.wal" 2>/dev/null || true; }
|
||
launchctl bootout "$dom/$LIVE_LABEL" 2>/dev/null || true
|
||
for i in $(seq 1 60); do launchctl print "$dom/$LIVE_LABEL" >/dev/null 2>&1 || break; sleep 0.5; done
|
||
launchctl bootstrap "$dom" "$LIVE_PLIST" || true
|
||
die "ROLLED BACK to $live_bin. See $bkp"
|
||
}
|
||
|
||
# ================================================================ destroy ======
|
||
cmd_destroy(){
|
||
local name="$1"; shift || true
|
||
mexists "$name" || die "no such sandbox: $name (run: nsbx list — or nsbx create $name to make it)"
|
||
local d; d="$(sdir "$name")"
|
||
stop_daemon "$name"
|
||
if [ -d "$d/build/worktree" ]; then
|
||
log "removing git worktree"
|
||
git -C "$EL_REPO" worktree remove --force "$d/build/worktree" 2>/dev/null || true
|
||
git -C "$EL_REPO" worktree prune 2>/dev/null || true
|
||
fi
|
||
log "removing $d"
|
||
rm -rf "$d"
|
||
ok "destroyed '$name' (live untouched)"
|
||
}
|
||
|
||
# ================================================================ list/status ==
|
||
cmd_list(){
|
||
[ -d "$SBX_ROOT" ] || { echo "no sandboxes"; return 0; }
|
||
printf '%-16s %-6s %-13s %-9s %-19s %s\n' NAME PORT STATE PID "BUILT" SOURCE
|
||
local m
|
||
for m in "$SBX_ROOT"/*/manifest.json; do
|
||
[ -f "$m" ] || continue
|
||
local n p src pid state bpath built fresh
|
||
n="$(python3 -c "import json;print(json.load(open('$m'))['name'])")"
|
||
p="$(python3 -c "import json;print(json.load(open('$m'))['port'])")"
|
||
src="$(python3 -c "import json;print(json.load(open('$m'))['source'])")"
|
||
pid="$(daemon_pid "$n")"
|
||
case "$(daemon_health "$n")" in
|
||
running) state="running";;
|
||
unresponsive) state="running(!resp)";;
|
||
*) state="stopped";;
|
||
esac
|
||
bpath="$(sdir "$n")/bin/engram"; built="$([ -f "$bpath" ] && bin_built_at "$bpath" || echo unknown)"
|
||
fresh="$(_binary_freshness "$n")"; [ -n "$fresh" ] && src="[STALE] $src"
|
||
printf '%-16s %-6s %-13s %-9s %-19s %s\n' "$n" "$p" "$state" "${pid:-–}" "$built" "$src"
|
||
done
|
||
info "state 'running(!resp)' = process alive but /api/stats didn't answer — see: nsbx status <name>"
|
||
}
|
||
cmd_status(){
|
||
local name="$1"; mexists "$name" || die "no such sandbox: $name (run: nsbx list to see what exists)"
|
||
python3 -m json.tool "$(manifest "$name")"
|
||
local bpath; bpath="$(sdir "$name")/bin/engram"
|
||
if [ -f "$bpath" ]; then
|
||
echo "binary: sha=$(sha "$bpath" | cut -c1-12) built=$(bin_built_at "$bpath")"
|
||
local fresh; fresh="$(_binary_freshness "$name")"
|
||
[ -n "$fresh" ] && printf '%s%s%s\n' "$C_YEL" "$fresh" "$C_0"
|
||
fi
|
||
case "$(daemon_health "$name")" in
|
||
running)
|
||
echo "state: running (pid $(daemon_pid "$name")) stats: $(sbx_stats "$name")";;
|
||
unresponsive)
|
||
printf '%sstate: running but NOT RESPONDING%s (pid %s) — process alive, /api/stats returned nothing.\n' "$C_RED" "$C_0" "$(daemon_pid "$name")"
|
||
info "check: tail -50 $(sdir "$name")/logs/daemon.log | next: kill -9 $(daemon_pid "$name") && nsbx up $name"
|
||
;;
|
||
*) echo "state: stopped";;
|
||
esac
|
||
[ -f "$(sdir "$name")/validate.json" ] && { echo "--- last validation ---"; python3 -m json.tool "$(sdir "$name")/validate.json"; }
|
||
}
|
||
|
||
# ================================================================ dev ==========
|
||
# ONE-COMMAND isolated dev environment. Everything a newcomer (Tim, any agent)
|
||
# needs to go from clone -> coding on an isolated running mind, in a single shot:
|
||
# 1) a git BRANCH (dev/<name>, or --prefix)
|
||
# 2) a git WORKTREE for it, at a visible path they can open + edit
|
||
# 3) an ISOLATED engram bound to a NON-default port, on a clone of the live store
|
||
# (stock prod binary by default — instant + safe; --build to compile the
|
||
# worktree's own runtime instead). Prod :$LIVE_BIND_PORT/:$SOUL_PORT is untouchable.
|
||
#
|
||
# This is additive sugar over the proven primitives (git worktree + cmd_create).
|
||
# It never binds a forbidden port and never touches ~/.neuron/engram (the live store)
|
||
# except the same READ-only snapshot cmd_create already performs.
|
||
#
|
||
# nsbx dev <name> [--repo R] [--base REF] [--worktree DIR] [--port N]
|
||
# [--prefix P] [--build] [--no-engram]
|
||
cmd_dev(){
|
||
local name="" repo="$EL_REPO" base="" wt="" port="" prefix="dev/" build=0 no_engram=0
|
||
[ $# -gt 0 ] && [ "${1#-}" = "$1" ] && { name="$1"; shift; } || die "usage: nsbx dev <name> [flags]"
|
||
while [ $# -gt 0 ]; do case "$1" in
|
||
--repo) repo="$2"; shift 2;;
|
||
--base) base="$2"; shift 2;;
|
||
--worktree|--wt) wt="$2"; shift 2;;
|
||
--port) port="$2"; shift 2;;
|
||
--prefix) prefix="$2"; shift 2;;
|
||
--build) build=1; shift;;
|
||
--no-engram) no_engram=1; shift;;
|
||
*) die "unknown flag: $1";;
|
||
esac; done
|
||
need git
|
||
git -C "$repo" rev-parse --git-dir >/dev/null 2>&1 || die "not a git repo: $repo"
|
||
|
||
local branch="${prefix}${name}"
|
||
local sbx="dev-${name}"
|
||
# default worktree path: a PERSISTENT, git-managed dir — NEVER /tmp (which is
|
||
# ablated on compaction). Default root = <repo-grandparent>/el-worktrees, i.e.
|
||
# ~/Development/neuron-technologies/el-worktrees/<name>. Override with NSBX_DEV_WT_ROOT.
|
||
local wt_root="${NSBX_DEV_WT_ROOT:-$(cd "$(dirname "$(dirname "$repo")")" && pwd -P)/el-worktrees}"
|
||
[ -n "$wt" ] || wt="${wt_root}/${name}"
|
||
case "$wt" in /tmp/*|/private/tmp/*|/var/tmp/*)
|
||
die "refusing worktree under a temp dir ($wt) — temp dirs are ablated on compaction; set NSBX_DEV_WT_ROOT to a persistent path";;
|
||
esac
|
||
# default base: whatever the repo's working checkout is on now
|
||
[ -n "$base" ] || base="$(git -C "$repo" rev-parse --abbrev-ref HEAD 2>/dev/null)"
|
||
|
||
# pre-flight (fail before creating anything)
|
||
[ "$no_engram" -eq 1 ] || ! mexists "$sbx" || die "engram sandbox '$sbx' already exists (nsbx dev-down $name first)"
|
||
[ -e "$wt" ] && die "worktree path already exists: $wt"
|
||
if [ -n "$port" ]; then
|
||
{ [ "$port" = "$LIVE_BIND_PORT" ] || [ "$port" = "$SOUL_PORT" ]; } && die "refusing forbidden port $port (live)"
|
||
fi
|
||
|
||
log "dev env '$name' (branch=$branch worktree=$wt base=$base)"
|
||
|
||
# ---- 1+2) branch + worktree in one shot ----
|
||
local gerr
|
||
if git -C "$repo" show-ref --verify --quiet "refs/heads/$branch"; then
|
||
info "branch $branch exists — checking it out into a new worktree"
|
||
gerr="$(git -C "$repo" worktree add "$wt" "$branch" 2>&1)" \
|
||
|| die "git worktree add failed for existing branch $branch:"$'\n'" $gerr"
|
||
else
|
||
gerr="$(git -C "$repo" worktree add -b "$branch" "$wt" "$base" 2>&1)" \
|
||
|| die "git worktree add -b $branch (base $base) failed:"$'\n'" $gerr"$'\n'" (a bare 'dev' branch blocks 'dev/*' names — try --prefix, e.g. nsbx dev $name --prefix wt/)"
|
||
fi
|
||
ok "worktree ready: $wt (branch $branch)"
|
||
|
||
# ---- 3) isolated engram ----
|
||
local eport="(none)"
|
||
if [ "$no_engram" -eq 1 ]; then
|
||
warn "--no-engram: skipped standing up an engram"
|
||
else
|
||
if [ "$build" -eq 1 ]; then
|
||
log "isolated engram: building the worktree's own runtime"
|
||
cmd_create "$sbx" ${port:+--port "$port"} --source "$wt" || die "engram create (--build) failed"
|
||
else
|
||
log "isolated engram: stock prod binary on a clone of the live store"
|
||
cmd_create "$sbx" ${port:+--port "$port"} || die "engram create failed"
|
||
fi
|
||
eport="$(mget "$sbx" "['port']")"
|
||
# record the dev linkage next to the sandbox so dev-down can clean up
|
||
python3 - "$(sdir "$sbx")/dev.json" "$name" "$branch" "$wt" "$repo" "$eport" <<'PY'
|
||
import json,sys
|
||
p,name,branch,wt,repo,port=sys.argv[1:7]
|
||
json.dump({"name":name,"branch":branch,"worktree":wt,"repo":repo,"port":int(port)},
|
||
open(p,'w'),indent=2)
|
||
PY
|
||
# ---- pin the WHOLE worktree to the CLONE ----
|
||
# Every var any El tooling in this worktree might read for an engram target now
|
||
# points at the isolated clone. Sourcing .nsbx-env makes hitting live :$LIVE_BIND_PORT
|
||
# or ~/.neuron/engram by accident structurally impossible from this shell.
|
||
local edata ekey eurl ebin
|
||
edata="$(sdir "$sbx")/data"; ekey="sbx-$sbx"; eurl="http://127.0.0.1:$eport"; ebin="$(sdir "$sbx")/bin/engram"
|
||
cat > "$wt/.nsbx-env" <<ENV
|
||
# nsbx dev env for '$name' — SOURCE this to pin THIS shell to the isolated clone.
|
||
# The live mind (:$LIVE_BIND_PORT engram / :$SOUL_PORT soul / $LIVE_DATA_DIR) is deliberately
|
||
# NOT referenced here. Regenerated by 'nsbx dev'. -> source .nsbx-env
|
||
export NSBX_NAME="$sbx"
|
||
export ENGRAM_URL="$eurl"
|
||
export ENGRAM_BIND=":$eport"
|
||
export ENGRAM_PORT="$eport"
|
||
export ENGRAM_HOST="127.0.0.1"
|
||
export ENGRAM_DATA_DIR="$edata"
|
||
export ENGRAM_API_KEY="$ekey"
|
||
export NEURON_ENGRAM_URL="$eurl"
|
||
export NEURON_ENGRAM_KEY="$ekey"
|
||
# nsbx run compatibility (same names 'nsbx run' exports)
|
||
export SBX_NAME="$sbx" SBX_PORT="$eport" SBX_URL="$eurl" SBX_KEY="$ekey" SBX_DATA="$edata" SBX_BIN="$ebin"
|
||
ENV
|
||
# direnv users get it automatically on cd; everyone else runs 'source .nsbx-env'
|
||
[ -e "$wt/.envrc" ] || printf 'source_env .nsbx-env 2>/dev/null || source .nsbx-env\n' > "$wt/.envrc"
|
||
ok "wrote $wt/.nsbx-env (pins this worktree to the clone)"
|
||
fi
|
||
|
||
# ---- summary ----
|
||
echo >&2
|
||
printf '%s DEV ENV READY — %s%s\n' "$C_BLD" "$name" "$C_0" >&2
|
||
printf ' %-10s %s\n' "worktree" "$wt" >&2
|
||
printf ' %-10s %s\n' "branch" "$branch" >&2
|
||
if [ "$no_engram" -ne 1 ]; then
|
||
printf ' %-10s %s\n' "engram" "http://127.0.0.1:$eport (isolated clone; prod :$LIVE_BIND_PORT untouchable)" >&2
|
||
printf ' %-10s %s\n' "sandbox" "$sbx" >&2
|
||
echo >&2
|
||
printf '%s env exported into %s/.nsbx-env (source it -> pinned to the clone):%s\n' "$C_DIM" "$wt" "$C_0" >&2
|
||
grep '^export' "$wt/.nsbx-env" | sed 's/^/ /' >&2
|
||
fi
|
||
echo >&2
|
||
info "code in: cd $wt && source .nsbx-env # now every engram var points at the clone"
|
||
[ "$no_engram" -ne 1 ] && info "poke it: nsbx run $sbx api /api/stats (or: make run NAME=$name)"
|
||
[ "$no_engram" -ne 1 ] && info "edit->test: make build NAME=$name && make run NAME=$name # El change -> clone, seconds"
|
||
info "tear down: nsbx dev-down $name (destroys engram + removes worktree; branch kept)"
|
||
}
|
||
|
||
# nsbx dev-down <name> [--delete-branch] [--repo R]
|
||
# Teardown counterpart: destroy the isolated engram, remove the git worktree,
|
||
# and (optionally) delete the branch. Live prod is never touched.
|
||
cmd_dev_down(){
|
||
local name="" repo="$EL_REPO" del_branch=0
|
||
[ $# -gt 0 ] && [ "${1#-}" = "$1" ] && { name="$1"; shift; } || die "usage: nsbx dev-down <name> [--delete-branch]"
|
||
while [ $# -gt 0 ]; do case "$1" in
|
||
--repo) repo="$2"; shift 2;;
|
||
--delete-branch) del_branch=1; shift;;
|
||
*) die "unknown flag: $1";;
|
||
esac; done
|
||
local sbx="dev-${name}" wt="" branch="dev/${name}"
|
||
# recover worktree/branch/repo from the dev linkage if present
|
||
if mexists "$sbx" && [ -f "$(sdir "$sbx")/dev.json" ]; then
|
||
local dj; dj="$(sdir "$sbx")/dev.json"
|
||
wt="$(python3 -c "import json;print(json.load(open('$dj'))['worktree'])" 2>/dev/null)"
|
||
branch="$(python3 -c "import json;print(json.load(open('$dj'))['branch'])" 2>/dev/null)"
|
||
repo="$(python3 -c "import json;print(json.load(open('$dj'))['repo'])" 2>/dev/null)"
|
||
fi
|
||
# 1) engram
|
||
if mexists "$sbx"; then cmd_destroy "$sbx"; else info "no engram sandbox '$sbx'"; fi
|
||
# 2) worktree
|
||
if [ -n "$wt" ] && [ -d "$wt" ]; then
|
||
log "removing git worktree $wt"
|
||
git -C "$repo" worktree remove --force "$wt" 2>/dev/null || rm -rf "$wt"
|
||
git -C "$repo" worktree prune 2>/dev/null || true
|
||
ok "worktree removed"
|
||
else info "no worktree to remove"; fi
|
||
# 3) branch (opt-in)
|
||
if [ "$del_branch" -eq 1 ]; then
|
||
git -C "$repo" branch -D "$branch" 2>/dev/null && ok "deleted branch $branch" || warn "could not delete branch $branch"
|
||
else info "branch $branch kept (use --delete-branch to remove)"; fi
|
||
ok "dev-down '$name' complete (live untouched)"
|
||
}
|
||
|
||
usage(){ cat >&2 <<EOF
|
||
${C_BLD}nsbx${C_0} — Neuron Sandbox: experiments + code changes against the REAL engram
|
||
runtime on an isolated snapshot of the live mind, with a gated promote-to-prod path.
|
||
|
||
nsbx dev <name> [--base REF] [--worktree DIR] ONE command onboarding: new branch (dev/<name>) + git
|
||
[--port N] [--prefix P] [--build] worktree (persistent, never /tmp) + isolated engram on a
|
||
[--no-engram] [--repo R] non-default port. clone -> coding on the mind in one shot.
|
||
nsbx dev-down <name> [--delete-branch] [--repo R] teardown: destroy the engram + remove the worktree
|
||
(branch kept unless --delete-branch). live untouched.
|
||
nsbx up [name] [flags…] one command: your private, isolated copy of the mind
|
||
(creates on first run, starts thereafter; prod untouchable)
|
||
nsbx create [name] [--port N] [--source DIR | --branch REF [--repo R] | --binary PATH]
|
||
clone live store+WAL+config, place/build the runtime, boot on an
|
||
isolated port (never :$LIVE_BIND_PORT/:$SOUL_PORT). Default runtime = stock prod binary.
|
||
nsbx build <name> --source DIR | --branch REF rebuild the runtime from a code change + hot-restart
|
||
nsbx run <name> <cmd...> | api <path> [json] run an experiment; capture output + metrics
|
||
nsbx validate <name> rails as checks: zero-loss(load+reboot), reboot-prove,
|
||
RSS bound, retrieval parity, keystone integrity
|
||
nsbx promote <name> [--data] [--i-approve-prod-cutover] GATED rails cutover to prod (DRY-RUN without approval)
|
||
nsbx destroy <name> stop daemon, free port, remove clone (live untouched)
|
||
nsbx list | nsbx status <name>
|
||
|
||
Env in 'run' cmds: \$SBX_URL \$SBX_PORT \$SBX_KEY \$SBX_DATA \$SBX_BIN \$SBX_NAME
|
||
EOF
|
||
}
|
||
|
||
main(){
|
||
local cmd="${1:-}"; shift || true
|
||
case "$cmd" in
|
||
dev) cmd_dev "$@";;
|
||
dev-down) cmd_dev_down "$@";;
|
||
up) cmd_up "$@";;
|
||
create) cmd_create "$@";;
|
||
build) cmd_build "$@";;
|
||
run) cmd_run "$@";;
|
||
validate) cmd_validate "$@";;
|
||
promote) cmd_promote "$@";;
|
||
destroy) cmd_destroy "$@";;
|
||
list|ls) cmd_list "$@";;
|
||
status) cmd_status "$@";;
|
||
""|-h|--help|help) usage;;
|
||
*) die "unknown command: $cmd (try: nsbx help)";;
|
||
esac
|
||
}
|
||
main "$@"
|