#!/usr/bin/env bash
# Moraine dev sandbox — host CLI. See RFC #232.
#
# Orchestrates a per-developer containerized moraine stack. The container
# image, compose files, and entrypoint live next to this script; this CLI
# picks ports, generates moraine.toml, builds host binaries (or delegates to
# a builder image), and drives `docker compose` for up/down/shell/etc.

set -euo pipefail

# ---------------------------------------------------------------------------
# Paths & constants
# ---------------------------------------------------------------------------

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)"
COMPOSE_FILE="${SCRIPT_DIR}/compose.yaml"
SESSIONS_COMPOSE_FILE="${SCRIPT_DIR}/compose.sessions.yaml"
PERFORMANCE_COMPOSE_FILE="${REPO_ROOT}/scripts/bench/compose.performance.yaml"
LOCAL_PERFORMANCE_COMPOSE_FILE="${REPO_ROOT}/scripts/bench/compose.performance.local.yaml"
DOCKERFILE="${SCRIPT_DIR}/Dockerfile"
PERFORMANCE_ENTRYPOINT="${SCRIPT_DIR}/performance-entrypoint.sh"
DEFAULT_CONFIG_PATH="${REPO_ROOT}/config/moraine.toml"
PYTHON_BIN="${PYTHON_BIN:-python3}"

SANDBOX_ID_REGEX='^sb-[a-f0-9]{6}$'
PROJECT_PREFIX="moraine-sandbox-"
OWNERSHIP_LABEL="io.moraine.sandbox.ownership-token"
OWNERSHIP_TOKEN_REGEX='^[a-f0-9]{64}$'
SANDBOX_STATE_ROOT="${MORAINE_SANDBOX_STATE_ROOT:-/tmp}"

# compose.yaml / compose.sessions.yaml reference these at parse time, even
# for read-only commands like `docker compose ps|logs|port|down`. When a
# subcommand is invoked outside of `up` (fresh shell, agent teardown, etc.)
# the caller has not set them, which breaks compose var interpolation. Seed
# benign placeholders here; `cmd_up` overrides them with real values before
# building / starting the stack.
: "${SANDBOX_REPO_ROOT:=/dev/null}"
: "${SANDBOX_BIN_DIR:=/dev/null}"
: "${SANDBOX_WEB_DIR:=/dev/null}"
: "${SANDBOX_CONFIG_DIR:=/dev/null}"
: "${SANDBOX_CODEX_SESSIONS_DIR:=/dev/null}"
: "${SANDBOX_CLAUDE_PROJECTS_DIR:=/dev/null}"
: "${SANDBOX_HERMES_SESSIONS_DIR:=/dev/null}"
: "${SANDBOX_KIMI_SESSIONS_DIR:=/dev/null}"
: "${SANDBOX_KIRO_SESSIONS_DIR:=/dev/null}"
: "${SANDBOX_CURSOR_PROJECTS_DIR:=/dev/null}"
: "${SANDBOX_CURSOR_STATE_DIR:=/dev/null}"
: "${SANDBOX_MONITOR_HOST_PORT:=0}"
: "${SANDBOX_CLICKHOUSE_HTTP_HOST_PORT:=0}"
: "${SANDBOX_CLICKHOUSE_TCP_HOST_PORT:=0}"
: "${SANDBOX_CLICKHOUSE_IMAGE:=clickhouse/clickhouse-server:latest}"
# Internal Compose selectors are never accepted from the caller environment.
# Lifecycle commands derive them only from ownership-checked local metadata.
SANDBOX_PERFORMANCE=0
SANDBOX_LOCAL_PERFORMANCE=0
SANDBOX_OWNERSHIP_COMPOSE_FILE=""
MORAINE_PERFORMANCE_CGROUP_PARENT=""
export SANDBOX_REPO_ROOT SANDBOX_BIN_DIR SANDBOX_WEB_DIR SANDBOX_CONFIG_DIR
export SANDBOX_CODEX_SESSIONS_DIR SANDBOX_CLAUDE_PROJECTS_DIR SANDBOX_HERMES_SESSIONS_DIR
export SANDBOX_KIMI_SESSIONS_DIR SANDBOX_KIRO_SESSIONS_DIR SANDBOX_CURSOR_PROJECTS_DIR
export SANDBOX_CURSOR_STATE_DIR
export SANDBOX_MONITOR_HOST_PORT SANDBOX_CLICKHOUSE_HTTP_HOST_PORT SANDBOX_CLICKHOUSE_TCP_HOST_PORT
export SANDBOX_CLICKHOUSE_IMAGE
export MORAINE_PERFORMANCE_CGROUP_PARENT SANDBOX_PERFORMANCE SANDBOX_LOCAL_PERFORMANCE SANDBOX_OWNERSHIP_COMPOSE_FILE

# ---------------------------------------------------------------------------
# Logging helpers
# ---------------------------------------------------------------------------

# All three write to stderr so they never contaminate command-substitution
# captures. Stdout is reserved for values (e.g. prepare_binaries returning a
# path, list_sandbox_ids emitting ids).
log()  { printf '[sandbox] %s\n' "$*" >&2; }
warn() { printf '[sandbox] WARN: %s\n' "$*" >&2; }
die()  { printf '[sandbox] ERROR: %s\n' "$*" >&2; exit 1; }

# ---------------------------------------------------------------------------
# Generic helpers
# ---------------------------------------------------------------------------

need_cmd() {
    command -v "$1" >/dev/null 2>&1 || die "required command not found: $1"
}

have_cmd() {
    command -v "$1" >/dev/null 2>&1
}

detect_os() {
    case "$(uname -s)" in
        Linux)  printf 'linux' ;;
        Darwin) printf 'macos' ;;
        *)      printf 'other' ;;
    esac
}

detect_linux_triple() {
    # Used when cross-compiling from macOS to a Linux container target.
    case "$(uname -m)" in
        arm64|aarch64) printf 'aarch64-unknown-linux-gnu' ;;
        x86_64|amd64)  printf 'x86_64-unknown-linux-gnu' ;;
        *) die "unsupported host arch for cross build: $(uname -m)" ;;
    esac
}

generate_sandbox_id() {
    printf 'sb-%s' "$(head -c 3 /dev/urandom | od -An -tx1 | tr -d ' \n')"
}

validate_sandbox_id() {
    local id="$1"
    [[ "$id" =~ $SANDBOX_ID_REGEX ]] || \
        die "invalid sandbox id '${id}' (expected ${SANDBOX_ID_REGEX})"
}

project_name_for() {
    printf '%s%s' "$PROJECT_PREFIX" "${1#sb-}"
    # NOTE: we intentionally keep the full sb- prefix-stripped form out of the
    # project name path: the spec calls for project `moraine-sandbox-<id>`
    # where <id> is the full `sb-xxxxxx`. Override below.
}

# The spec says: COMPOSE_PROJECT_NAME = moraine-sandbox-<id> where <id> is
# the full `sb-xxxxxx`. Implement that directly.
project_name_for() {
    printf '%s%s' "$PROJECT_PREFIX" "$1"
}

config_dir_for() {
    printf '%s/%s%s' "${SANDBOX_STATE_ROOT%/}" "$PROJECT_PREFIX" "$1"
}

check_docker() {
    need_cmd docker
    if ! docker compose version >/dev/null 2>&1; then
        die "'docker compose' plugin not available — install Docker Desktop or compose v2"
    fi
}

check_repo_root() {
    local cargo_toml="${REPO_ROOT}/Cargo.toml"
    [[ -f "$cargo_toml" ]] || \
        die "repo root missing Cargo.toml at ${cargo_toml}"
    if ! grep -q 'moraine-ingest' "$cargo_toml"; then
        die "repo root does not look like moraine workspace (no moraine-ingest in Cargo.toml)"
    fi
}

pick_open_port() {
    "$PYTHON_BIN" -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1]); s.close()'
}

pick_clickhouse_http_port() {
    "$PYTHON_BIN" -c '
import random
import socket
import sys

TCP_OFFSET = 877
INTERSERVER_OFFSET = 886

for _ in range(500):
    http_port = random.randint(20000, 60000 - INTERSERVER_OFFSET)
    ports = [http_port, http_port + TCP_OFFSET, http_port + INTERSERVER_OFFSET]
    sockets = []
    try:
        for port in ports:
            sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
            sock.bind(("127.0.0.1", port))
            sockets.append(sock)
    except OSError:
        for sock in sockets:
            sock.close()
        continue
    for sock in sockets:
        sock.close()
    print(http_port)
    sys.exit(0)

print("failed to find available ClickHouse ports", file=sys.stderr)
sys.exit(1)
'
}

clickhouse_version_from_default_config() {
    local cfg="$DEFAULT_CONFIG_PATH"
    [[ -f "$cfg" ]] || die "default config not found at ${cfg}"
    local line
    line="$(grep -E '^[[:space:]]*clickhouse_version[[:space:]]*=' "$cfg" | head -n1 || true)"
    [[ -n "$line" ]] || die "clickhouse_version not found in ${cfg}"
    # Strip key + whitespace + quotes.
    local value
    value="${line#*=}"
    value="${value#"${value%%[![:space:]]*}"}"
    value="${value%"${value##*[![:space:]]}"}"
    value="${value%\"}"
    value="${value#\"}"
    [[ -n "$value" ]] || die "could not parse clickhouse_version from ${cfg}"
    printf '%s' "$value"
}

monitor_frontend_needs_build() {
    local dist_index="${REPO_ROOT}/web/monitor/dist/index.html"
    if [[ ! -f "$dist_index" ]]; then
        return 0
    fi
    local watch_paths=(
        "${REPO_ROOT}/web/monitor/src"
        "${REPO_ROOT}/web/monitor/index.html"
        "${REPO_ROOT}/web/monitor/package.json"
        "${REPO_ROOT}/web/monitor/tsconfig.app.json"
        "${REPO_ROOT}/web/monitor/tsconfig.node.json"
        "${REPO_ROOT}/web/monitor/vite.config.ts"
    )
    local path
    for path in "${watch_paths[@]}"; do
        if [[ -d "$path" ]]; then
            if find "$path" -type f -newer "$dist_index" -print -quit | grep -q .; then
                return 0
            fi
        elif [[ -f "$path" && "$path" -nt "$dist_index" ]]; then
            return 0
        fi
    done
    return 1
}

ensure_monitor_frontend() {
    local dist_dir="${REPO_ROOT}/web/monitor/dist"
    if ! monitor_frontend_needs_build; then
        log "monitor frontend up to date (${dist_dir})"
        return 0
    fi
    if ! have_cmd bun; then
        if [[ -f "${dist_dir}/index.html" ]]; then
            warn "bun not found but ${dist_dir}/index.html already exists; skipping rebuild"
            return 0
        fi
        die "monitor frontend needs a build but 'bun' is not installed (install bun or prebuild web/monitor/dist)"
    fi
    log "building monitor frontend (web/monitor)"
    (
        cd "${REPO_ROOT}/web/monitor"
        bun install --frozen-lockfile
        bun run build
    )
}

docker_compose_files_args() {
    local mount_sessions="$1"
    local args=(-f "$COMPOSE_FILE")
    if [[ "$mount_sessions" == "1" ]]; then
        args+=(-f "$SESSIONS_COMPOSE_FILE")
    fi
    if [[ "${SANDBOX_PERFORMANCE:-0}" == "1" ]]; then
        args+=(-f "$PERFORMANCE_COMPOSE_FILE")
    fi
    if [[ "${SANDBOX_LOCAL_PERFORMANCE:-0}" == "1" ]]; then
        args+=(-f "$LOCAL_PERFORMANCE_COMPOSE_FILE")
    fi
    if [[ -n "${SANDBOX_OWNERSHIP_COMPOSE_FILE:-}" ]]; then
        args+=(-f "$SANDBOX_OWNERSHIP_COMPOSE_FILE")
    fi
    printf '%s\n' "${args[@]}"
}

# Read lines from stdin into the named array. Portable replacement for
# mapfile/readarray (bash 3.2 — macOS /bin/bash). Usage:
#   read_lines_into arr < <(cmd)
read_lines_into() {
    local __arr_name="$1"
    local __line
    eval "$__arr_name=()"
    while IFS= read -r __line; do
        eval "$__arr_name+=(\"\$__line\")"
    done
}

# Run docker compose for a project with the right -f flags.
# Usage: dc <project> <mount_sessions 0|1> <args...>
# Placeholder env defaults so compose can always parse compose.yaml even for
# read-only/teardown subcommands that don't set real values. `up` overrides
# these with real values *before* calling any compose helper. Other commands
# (down, shell, logs, status, list) identify containers by project name and
# don't use the volume specs, but compose still needs to parse the file —
# without fallbacks it chokes on empty strings inside the volume specs
# ("invalid spec: :/repo:ro").
ensure_compose_env() {
    : "${SANDBOX_CLICKHOUSE_IMAGE:=clickhouse/clickhouse-server:latest}"
    : "${SANDBOX_CLICKHOUSE_HTTP_HOST_PORT:=0}"
    : "${SANDBOX_CLICKHOUSE_TCP_HOST_PORT:=0}"
    : "${SANDBOX_MONITOR_HOST_PORT:=0}"
    : "${SANDBOX_REPO_ROOT:=/tmp}"
    : "${SANDBOX_WEB_DIR:=/tmp}"
    : "${SANDBOX_CONFIG_DIR:=/tmp}"
    : "${SANDBOX_ENTRYPOINT:=/dev/null}"
    : "${SANDBOX_CODEX_SESSIONS_DIR:=/tmp}"
    : "${SANDBOX_CLAUDE_PROJECTS_DIR:=/tmp}"
    : "${SANDBOX_HERMES_SESSIONS_DIR:=/tmp}"
    : "${SANDBOX_KIMI_SESSIONS_DIR:=/tmp}"
    : "${SANDBOX_KIRO_SESSIONS_DIR:=/tmp}"
    : "${SANDBOX_CURSOR_PROJECTS_DIR:=/tmp}"
    : "${SANDBOX_CURSOR_STATE_DIR:=/tmp}"
    : "${SANDBOX_SCCACHE_HOST_DIR:=/tmp}"
    : "${SANDBOX_REBUILD:=0}"
    export SANDBOX_CLICKHOUSE_IMAGE SANDBOX_CLICKHOUSE_HTTP_HOST_PORT \
        SANDBOX_CLICKHOUSE_TCP_HOST_PORT SANDBOX_MONITOR_HOST_PORT \
        SANDBOX_REPO_ROOT SANDBOX_WEB_DIR SANDBOX_CONFIG_DIR \
        SANDBOX_ENTRYPOINT SANDBOX_CODEX_SESSIONS_DIR SANDBOX_CLAUDE_PROJECTS_DIR \
        SANDBOX_HERMES_SESSIONS_DIR SANDBOX_KIMI_SESSIONS_DIR \
        SANDBOX_KIRO_SESSIONS_DIR SANDBOX_CURSOR_PROJECTS_DIR \
        SANDBOX_CURSOR_STATE_DIR SANDBOX_SCCACHE_HOST_DIR SANDBOX_REBUILD
    : "${SANDBOX_BIN_DIR:=/dev/null}"
    : "${SANDBOX_PERFORMANCE:=0}"
    : "${SANDBOX_LOCAL_PERFORMANCE:=0}"
    : "${MORAINE_PERFORMANCE_CGROUP_PARENT:=}"
    : "${SANDBOX_OWNERSHIP_COMPOSE_FILE:=}"
    export SANDBOX_BIN_DIR SANDBOX_PERFORMANCE SANDBOX_LOCAL_PERFORMANCE \
        MORAINE_PERFORMANCE_CGROUP_PARENT SANDBOX_OWNERSHIP_COMPOSE_FILE
}

dc() {
    local project="$1"; shift
    local mount_sessions="$1"; shift
    local files=()
    read_lines_into files < <(docker_compose_files_args "$mount_sessions")
    ensure_compose_env
    docker compose -p "$project" "${files[@]}" "$@"
}

ownership_marker_for() {
    printf '%s_ownership' "$1"
}

generate_ownership_token() {
    head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n'
}

validate_ownership_token() {
    [[ "$1" =~ $OWNERSHIP_TOKEN_REGEX ]] || \
        die "invalid sandbox ownership token"
}

validate_performance_binary_dir() {
    local binary_dir="$1"
    local config_dir="$2"
    "$PYTHON_BIN" - "$binary_dir" "$config_dir" <<'PY'
import hashlib
import json
import os
import re
from pathlib import Path
import stat
import sys

binary_dir = Path(sys.argv[1])
config_dir = Path(sys.argv[2])
required = {"moraine", "moraine-ingest", "moraine-monitor", "moraine-mcp"}
if not binary_dir.is_absolute() or binary_dir.is_symlink() or not binary_dir.is_dir():
    raise SystemExit("performance binary directory must be an absolute real directory")
directory_status = binary_dir.stat()
if directory_status.st_uid != os.getuid() or directory_status.st_mode & 0o022:
    raise SystemExit("performance binary directory must be caller-owned and non-writable by group/other")
manifest = binary_dir / "manifest.json"
if manifest.is_symlink() or not manifest.is_file():
    raise SystemExit("performance binary manifest is missing or unsafe")
raw = manifest.read_bytes()
try:
    document = json.loads(raw)
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
    raise SystemExit(f"performance binary manifest is invalid: {exc}")
if set(document) != {"schema_version", "recipe", "binaries"} or \
        document.get("schema_version") != "moraine-performance-build-v1":
    raise SystemExit("performance binary manifest schema is invalid")
recipe = document.get("recipe")
if not isinstance(recipe, dict) or set(recipe) != {
    "command", "default_features", "target", "rustc_release",
    "toolchain_sha256", "linker_sha256", "environment_allowlist",
    "build_environment_sha256",
}:
    raise SystemExit("performance build recipe fields are invalid")
if recipe["command"] != ["cargo", "build", "--workspace", "--release", "--locked"] or \
        recipe["default_features"] is not True:
    raise SystemExit("performance build recipe command is not frozen")
if not isinstance(recipe["target"], str) or not re.fullmatch(r"[A-Za-z0-9_.-]+", recipe["target"]):
    raise SystemExit("performance build target is invalid")
if not isinstance(recipe["rustc_release"], str) or not recipe["rustc_release"]:
    raise SystemExit("performance build compiler identity is invalid")
for field in ("toolchain_sha256", "linker_sha256", "build_environment_sha256"):
    if not isinstance(recipe[field], str) or \
            not re.fullmatch(r"sha256:[0-9a-f]{64}", recipe[field]):
        raise SystemExit(f"performance {field} identity is invalid")
environment_allowlist = recipe["environment_allowlist"]
normalized_target = re.sub(r"[^A-Za-z0-9]", "_", recipe["target"]).upper()
allowed_environment = {
    "CARGO_BUILD_TARGET", "CC", "CXX", "RUSTFLAGS", "RUSTC_WRAPPER",
    "MACOSX_DEPLOYMENT_TARGET", "CARGO_ENCODED_RUSTFLAGS",
    f"CARGO_TARGET_{normalized_target}_LINKER",
    f"CARGO_TARGET_{normalized_target}_RUSTFLAGS",
}
if not isinstance(environment_allowlist, list) or \
        environment_allowlist != sorted(set(environment_allowlist)) or \
        not set(environment_allowlist).issubset(allowed_environment):
    raise SystemExit("performance build environment allowlist is invalid")
binaries = document.get("binaries")
if not isinstance(binaries, dict) or set(binaries) != required:
    raise SystemExit("performance binary manifest has the wrong binary set")
def sha256_file(path):
    digest = hashlib.sha256()
    with path.open("rb") as stream:
        while chunk := stream.read(1024 * 1024):
            digest.update(chunk)
    return "sha256:" + digest.hexdigest()

for name in sorted(required):
    path = binary_dir / name
    status = path.lstat()
    if path.is_symlink() or not stat.S_ISREG(status.st_mode) or status.st_nlink != 1:
        raise SystemExit(f"performance binary is not a single regular file: {name}")
    if status.st_mode & 0o222 or not status.st_mode & 0o111:
        raise SystemExit(f"performance binary is not immutable and executable: {name}")
    digest = sha256_file(path)
    entry = binaries[name]
    if not isinstance(entry, dict) or entry.get("sha256") != digest or entry.get("size_bytes") != status.st_size:
        raise SystemExit(f"performance binary manifest mismatch: {name}")
target = config_dir / ".binary-manifest.json"
target.write_bytes(raw)
target.chmod(0o600)
(config_dir / ".binary-manifest.sha256").write_text(
    "sha256:" + hashlib.sha256(raw).hexdigest() + "\n"
)
(config_dir / ".binary-manifest.sha256").chmod(0o600)
PY
}

maybe_failpoint() {
    local expected="$1"
    if [[ "${MORAINE_SANDBOX_FAILPOINT:-}" == "$expected" ]]; then
        die "sandbox failpoint reached: ${expected}"
    fi
}

private_config_dir_is_safe() {
    local dir="$1"
    local mode
    [[ -d "$dir" && ! -L "$dir" && -O "$dir" ]] || return 1
    mode="$(stat -f '%Lp' "$dir" 2>/dev/null || true)"
    if [[ ! "$mode" =~ ^[0-7]{3,4}$ ]]; then
        mode="$(stat -c '%a' "$dir" 2>/dev/null || true)"
    fi
    [[ "$mode" =~ ^[0-7]{3,4}$ ]] || return 1
    (( (8#$mode & 077) == 0 ))
}

sandbox_state_root_is_safe() {
    if [[ -n "${MORAINE_SANDBOX_STATE_ROOT+x}" ]]; then
        private_config_dir_is_safe "$SANDBOX_STATE_ROOT"
    else
        [[ "$SANDBOX_STATE_ROOT" == "/tmp" && -d "$SANDBOX_STATE_ROOT" ]]
    fi
}

project_has_resources() {
    local project="$1"
    local containers volumes networks
    containers="$(docker ps -aq --filter "label=com.docker.compose.project=${project}")" || return 2
    volumes="$(docker volume ls -q --filter "label=com.docker.compose.project=${project}")" || return 2
    networks="$(docker network ls -q --filter "label=com.docker.compose.project=${project}")" || return 2
    if [[ -n "$containers" || -n "$volumes" || -n "$networks" ]]; then
        return 0
    fi

    # Refuse legacy or foreign resources that use our deterministic names but
    # do not carry Compose labels.
    containers="$(docker ps -aq --filter "name=${project}")" || return 2
    volumes="$(docker volume ls -q --filter "name=${project}_")" || return 2
    networks="$(docker network ls -q --filter "name=${project}_")" || return 2
    [[ -n "$containers" || -n "$volumes" || -n "$networks" ]]
}

docker_label_for() {
    local kind="$1"
    local resource="$2"
    case "$kind" in
        container)
            docker container inspect --format "{{ index .Config.Labels \"${OWNERSHIP_LABEL}\" }}" "$resource" 2>/dev/null
            ;;
        volume|network)
            docker "$kind" inspect --format "{{ index .Labels \"${OWNERSHIP_LABEL}\" }}" "$resource" 2>/dev/null
            ;;
        *)
            return 1
            ;;
    esac
}

reserve_project_ownership() {
    local project="$1"
    local token="$2"
    local marker actual
    marker="$(ownership_marker_for "$project")"

    if project_has_resources "$project"; then
        return 1
    elif [[ $? -ne 1 ]]; then
        return 1
    fi
    docker volume create \
        --label "com.docker.compose.project=${project}" \
        --label "${OWNERSHIP_LABEL}=${token}" \
        "$marker" >/dev/null || return 1
    actual="$(docker_label_for volume "$marker" || true)"
    [[ "$actual" == "$token" ]]
}

release_project_ownership() {
    local project="$1"
    local token="$2"
    local marker actual
    marker="$(ownership_marker_for "$project")"
    actual="$(docker_label_for volume "$marker" || true)"
    [[ "$actual" == "$token" ]] || return 1
    docker volume rm "$marker" >/dev/null
}

write_ownership_evidence() {
    local config_dir="$1"
    local token="$2"
    local include_loadgen="${3:-0}"
    local token_path="${config_dir}/.ownership-token"
    local overlay_path="${config_dir}/ownership.compose.yaml"

    printf '%s\n' "$token" >"$token_path"
    chmod 0600 "$token_path"
    cat >"$overlay_path" <<EOF
services:
  clickhouse:
    labels:
      ${OWNERSHIP_LABEL}: "${token}"
  moraine:
    labels:
      ${OWNERSHIP_LABEL}: "${token}"
EOF
    if [[ "$include_loadgen" == "1" ]]; then
        cat >>"$overlay_path" <<EOF
  loadgen:
    labels:
      ${OWNERSHIP_LABEL}: "${token}"
EOF
    fi
    cat >>"$overlay_path" <<EOF
volumes:
  state:
    labels:
      ${OWNERSHIP_LABEL}: "${token}"
  clickhouse-data:
    labels:
      ${OWNERSHIP_LABEL}: "${token}"
  binaries:
    labels:
      ${OWNERSHIP_LABEL}: "${token}"
  cargo-home:
    labels:
      ${OWNERSHIP_LABEL}: "${token}"
  cargo-target:
    labels:
      ${OWNERSHIP_LABEL}: "${token}"
networks:
  default:
    labels:
      ${OWNERSHIP_LABEL}: "${token}"
EOF
    chmod 0600 "$overlay_path"
    printf '%s' "$overlay_path"
}

read_recorded_ownership_token() {
    local config_dir="$1"
    local token_path="${config_dir}/.ownership-token"
    local token
    private_config_dir_is_safe "$config_dir" || return 1
    [[ -f "$token_path" && ! -L "$token_path" && -O "$token_path" ]] || return 1
    IFS= read -r token <"$token_path" || return 1
    [[ "$token" =~ $OWNERSHIP_TOKEN_REGEX ]] || return 1
    printf '%s' "$token"
}

verify_project_ownership() {
    local project="$1"
    local token="$2"
    local resource actual resources
    local marker
    marker="$(ownership_marker_for "$project")"
    actual="$(docker_label_for volume "$marker" || true)"
    [[ "$actual" == "$token" ]] || return 1

    resources="$(docker ps -aq --filter "label=com.docker.compose.project=${project}")" || return 1
    while IFS= read -r resource; do
        [[ -n "$resource" ]] || continue
        actual="$(docker_label_for container "$resource" || true)"
        [[ "$actual" == "$token" ]] || return 1
    done <<<"$resources"
    resources="$(docker volume ls -q --filter "label=com.docker.compose.project=${project}")" || return 1
    while IFS= read -r resource; do
        [[ -n "$resource" ]] || continue
        actual="$(docker_label_for volume "$resource" || true)"
        [[ "$actual" == "$token" ]] || return 1
    done <<<"$resources"
    resources="$(docker network ls -q --filter "label=com.docker.compose.project=${project}")" || return 1
    while IFS= read -r resource; do
        [[ -n "$resource" ]] || continue
        actual="$(docker_label_for network "$resource" || true)"
        [[ "$actual" == "$token" ]] || return 1
    done <<<"$resources"
}

compose_resources_removed() {
    local project="$1"
    local marker containers volumes networks resource
    marker="$(ownership_marker_for "$project")"
    containers="$(docker ps -aq --filter "label=com.docker.compose.project=${project}")" || return 1
    networks="$(docker network ls -q --filter "label=com.docker.compose.project=${project}")" || return 1
    volumes="$(docker volume ls -q --filter "label=com.docker.compose.project=${project}")" || return 1
    [[ -z "$containers" && -z "$networks" ]] || return 1
    while IFS= read -r resource; do
        [[ -z "$resource" || "$resource" == "$marker" ]] || return 1
    done <<<"$volumes"
}

cleanup_local_config() {
    local config_dir="$1"
    private_config_dir_is_safe "$config_dir" || return 1
    rm -f \
        "${config_dir}/moraine.toml" \
        "${config_dir}/ownership.compose.yaml" \
        "${config_dir}/.performance-mode" \
        "${config_dir}/.local-performance-mode" \
        "${config_dir}/.cgroup-parent" \
        "${config_dir}/.lifecycle-phase" \
        "${config_dir}/.binary-manifest.json" \
        "${config_dir}/.binary-manifest.sha256" \
        "${config_dir}/owned-leaks.json"
    rm -rf "${config_dir}/fixtures"
}

write_owned_leak_inventory() {
    local project="$1"
    local config_dir="$2"
    local token="$3"
    local reason="$4"
    [[ -d "$config_dir" ]] || return 0
    local containers volumes networks
    containers="$(docker ps -aq --filter "label=com.docker.compose.project=${project}" 2>/dev/null || true)"
    volumes="$(docker volume ls -q --filter "label=com.docker.compose.project=${project}" 2>/dev/null || true)"
    networks="$(docker network ls -q --filter "label=com.docker.compose.project=${project}" 2>/dev/null || true)"
    INVENTORY_PROJECT="$project" INVENTORY_REASON="$reason" INVENTORY_TOKEN="$token" \
        INVENTORY_CONTAINERS="$containers" INVENTORY_VOLUMES="$volumes" INVENTORY_NETWORKS="$networks" \
        "$PYTHON_BIN" - "${config_dir}/owned-leaks.json" <<'PY'
import hashlib
import json
import os
from pathlib import Path
import sys

document = {
    "schema_version": "moraine-sandbox-owned-leaks-v1",
    "project": os.environ["INVENTORY_PROJECT"],
    "reason": os.environ["INVENTORY_REASON"],
    "ownership_token_sha256": "sha256:" + hashlib.sha256(
        os.environ["INVENTORY_TOKEN"].encode()
    ).hexdigest(),
    "resources": {
        "containers": sorted(os.environ["INVENTORY_CONTAINERS"].split()),
        "volumes": sorted(os.environ["INVENTORY_VOLUMES"].split()),
        "networks": sorted(os.environ["INVENTORY_NETWORKS"].split()),
    },
}
path = Path(sys.argv[1])
path.write_text(json.dumps(document, sort_keys=True, separators=(",", ":")) + "\n")
path.chmod(0o600)
PY
}

UP_CLEANUP_ACTIVE=0
UP_PROJECT=""
UP_CONFIG_DIR=""
UP_OWNERSHIP_TOKEN=""
UP_MOUNT_SESSIONS=0

cleanup_failed_up() {
    local rc=$?
    trap - EXIT TERM INT
    if (( UP_CLEANUP_ACTIVE )); then
        local cleanup_ok=1
        set +e
        if verify_project_ownership "$UP_PROJECT" "$UP_OWNERSHIP_TOKEN"; then
            dc "$UP_PROJECT" "$UP_MOUNT_SESSIONS" down -v --remove-orphans >/dev/null 2>&1
            if [[ $? -ne 0 ]] || ! compose_resources_removed "$UP_PROJECT"; then
                cleanup_ok=0
            elif ! release_project_ownership "$UP_PROJECT" "$UP_OWNERSHIP_TOKEN"; then
                cleanup_ok=0
            fi
        else
            cleanup_ok=0
        fi
        if (( cleanup_ok )); then
            cleanup_local_config "$UP_CONFIG_DIR" >/dev/null 2>&1 || cleanup_ok=0
            rm -f "${UP_CONFIG_DIR}/.ownership-token"
            rmdir "$UP_CONFIG_DIR" >/dev/null 2>&1 || cleanup_ok=0
        fi
        if (( ! cleanup_ok )); then
            write_owned_leak_inventory \
                "$UP_PROJECT" "$UP_CONFIG_DIR" "$UP_OWNERSHIP_TOKEN" \
                "automatic cleanup after interrupted or failed up was incomplete"
            warn "owned cleanup incomplete for ${UP_PROJECT}; inventory: ${UP_CONFIG_DIR}/owned-leaks.json"
        fi
        set -e
    fi
    exit "$rc"
}

arm_failed_up_cleanup() {
    UP_PROJECT="$1"
    UP_CONFIG_DIR="$2"
    UP_OWNERSHIP_TOKEN="$3"
    UP_MOUNT_SESSIONS="$4"
    UP_CLEANUP_ACTIVE=1
    trap cleanup_failed_up EXIT
    trap 'exit 143' TERM
    trap 'exit 130' INT
}

disarm_failed_up_cleanup() {
    UP_CLEANUP_ACTIVE=0
    trap - EXIT TERM INT
}

# Resolve <id>: if given validate; else find exactly one existing sandbox.
resolve_sandbox_id() {
    local given="${1-}"
    if [[ -n "$given" ]]; then
        validate_sandbox_id "$given"
        printf '%s' "$given"
        return 0
    fi
    local ids
    read_lines_into ids < <(list_sandbox_ids)
    if [[ "${#ids[@]}" -eq 0 ]]; then
        die "no running sandboxes; nothing to operate on"
    elif [[ "${#ids[@]}" -gt 1 ]]; then
        {
            echo "multiple sandboxes running; specify one explicitly:"
            for id in "${ids[@]}"; do
                echo "  ${id}"
            done
        } >&2
        exit 1
    fi
    printf '%s' "${ids[0]}"
}

list_sandbox_projects_json() {
    docker compose ls --all --format json 2>/dev/null || printf '[]'
}

list_sandbox_ids() {
    list_sandbox_projects_json | "$PYTHON_BIN" -c '
import json, sys
prefix = "'"$PROJECT_PREFIX"'"
try:
    data = json.load(sys.stdin)
except Exception:
    sys.exit(0)
for proj in data:
    name = proj.get("Name", "")
    if name.startswith(prefix):
        print(name[len(prefix):])
'
}

# ---------------------------------------------------------------------------
# Config generation
# ---------------------------------------------------------------------------

# Generate a moraine.toml in $1 (config dir) for the given id / options.
# Arguments:
#   $1 config_dir
#   $2 id
#   $3 mount_host_sessions (0/1)
#   $4 codex_sessions_dir (host) — may be ""
#   $5 claude_projects_dir (host) — may be ""
#   $6 hermes_sessions_dir (host) — may be ""
#   $7 kimi_sessions_dir (host) — may be ""
#   $8 kiro_sessions_dir (host) — may be ""
#   $9 cursor_projects_dir (host) — may be ""
#   $10 cursor_state_dir (host) — may be ""
#   $11 clickhouse_version
generate_moraine_toml() {
    local config_dir="$1"
    local id="$2"
    local mount_sessions="$3"
    local codex_dir="$4"
    local claude_dir="$5"
    local hermes_dir="$6"
    local kimi_dir="$7"
    local kiro_dir="$8"
    local cursor_dir="$9"
    local cursor_state_dir="${10}"
    local ch_version="${11}"

    mkdir -p "$config_dir"

    local codex_src_block=""
    local claude_src_block=""
    local hermes_src_block=""
    local kimi_src_block=""
    local kiro_src_block=""
    local cursor_src_block=""
    local cursor_sqlite_src_block=""

    if [[ "$mount_sessions" == "1" ]]; then
        if [[ -n "$codex_dir" && -d "$codex_dir" ]]; then
            codex_src_block=$'[[ingest.sources]]\nname = "host-codex"\nharness = "codex"\nenabled = true\nglob = "/host/codex/sessions/**/*.jsonl"\nwatch_root = "/host/codex/sessions"\n'
        else
            warn "host codex sessions dir not found (${codex_dir}); skipping that source"
        fi
        if [[ -n "$claude_dir" && -d "$claude_dir" ]]; then
            claude_src_block=$'[[ingest.sources]]\nname = "host-claude"\nharness = "claude-code"\nenabled = true\nglob = "/host/claude/projects/**/*.jsonl"\nwatch_root = "/host/claude/projects"\n'
        else
            warn "host claude projects dir not found (${claude_dir}); skipping that source"
        fi
        if [[ -n "$hermes_dir" && -d "$hermes_dir" ]]; then
            hermes_src_block=$'[[ingest.sources]]\nname = "host-hermes"\nharness = "hermes"\nenabled = true\nglob = "/host/hermes/sessions/session_*.json"\nwatch_root = "/host/hermes/sessions"\nformat = "session_json"\n'
        else
            warn "host hermes sessions dir not found (${hermes_dir}); skipping that source"
        fi
        if [[ -n "$kimi_dir" && -d "$kimi_dir" ]]; then
            kimi_src_block=$'[[ingest.sources]]\nname = "host-kimi"\nharness = "kimi-cli"\nenabled = true\nglob = "/host/kimi/sessions/**/wire.jsonl"\nwatch_root = "/host/kimi/sessions"\n'
        else
            warn "host kimi sessions dir not found (${kimi_dir}); skipping that source"
        fi
        if [[ -n "$kiro_dir" && -d "$kiro_dir" ]]; then
            kiro_src_block=$'[[ingest.sources]]\nname = "host-kiro"\nharness = "kiro-cli"\nenabled = true\nglob = "/host/kiro/sessions/*.jsonl"\nwatch_root = "/host/kiro/sessions"\nformat = "kiro_session"\n'
        else
            warn "host kiro sessions dir not found (${kiro_dir}); skipping that source"
        fi
        if [[ -n "$cursor_dir" && -d "$cursor_dir" ]]; then
            cursor_src_block=$'[[ingest.sources]]\nname = "host-cursor"\nharness = "cursor"\nenabled = true\nglob = "/host/cursor/projects/*/agent-transcripts/**/*.jsonl"\nwatch_root = "/host/cursor/projects"\n'
        else
            warn "host cursor projects dir not found (${cursor_dir}); skipping that source"
        fi
        if [[ -n "$cursor_state_dir" && -d "$cursor_state_dir" ]]; then
            # cursor_sqlite is disabled by default in production configs
            # (Cursor has no stable local-DB contract — RFC #361). The
            # sandbox exists to exercise the format, so enabled = true here
            # is intentional.
            cursor_sqlite_src_block=$'[[ingest.sources]]\nname = "host-cursor-sqlite"\nharness = "cursor"\nenabled = true\nglob = "/host/cursor/state/**/state.vscdb"\nwatch_root = "/host/cursor/state"\nformat = "cursor_sqlite"\n'
        else
            warn "host cursor state dir not found (${cursor_state_dir}); skipping that source"
        fi
    else
        # Fixture-based sources. Create empty dirs under config dir that will
        # be mounted at /sandbox inside the container.
        mkdir -p "${config_dir}/fixtures/codex/sessions"
        mkdir -p "${config_dir}/fixtures/claude/projects"
        mkdir -p "${config_dir}/fixtures/hermes/sessions"
        mkdir -p "${config_dir}/fixtures/kimi/sessions"
        mkdir -p "${config_dir}/fixtures/kiro/sessions"
        mkdir -p "${config_dir}/fixtures/cursor/projects"
        codex_src_block=$'[[ingest.sources]]\nname = "fixture-codex"\nharness = "codex"\nenabled = true\nglob = "/sandbox/fixtures/codex/sessions/**/*.jsonl"\nwatch_root = "/sandbox/fixtures/codex/sessions"\n'
        claude_src_block=$'[[ingest.sources]]\nname = "fixture-claude"\nharness = "claude-code"\nenabled = true\nglob = "/sandbox/fixtures/claude/projects/**/*.jsonl"\nwatch_root = "/sandbox/fixtures/claude/projects"\n'
        hermes_src_block=$'[[ingest.sources]]\nname = "fixture-hermes"\nharness = "hermes"\nenabled = true\nglob = "/sandbox/fixtures/hermes/sessions/session_*.json"\nwatch_root = "/sandbox/fixtures/hermes/sessions"\nformat = "session_json"\n'
        kimi_src_block=$'[[ingest.sources]]\nname = "fixture-kimi"\nharness = "kimi-cli"\nenabled = true\nglob = "/sandbox/fixtures/kimi/sessions/**/wire.jsonl"\nwatch_root = "/sandbox/fixtures/kimi/sessions"\n'
        kiro_src_block=$'[[ingest.sources]]\nname = "fixture-kiro"\nharness = "kiro-cli"\nenabled = true\nglob = "/sandbox/fixtures/kiro/sessions/*.jsonl"\nwatch_root = "/sandbox/fixtures/kiro/sessions"\nformat = "kiro_session"\n'
        cursor_src_block=$'[[ingest.sources]]\nname = "fixture-cursor"\nharness = "cursor"\nenabled = true\nglob = "/sandbox/fixtures/cursor/projects/*/agent-transcripts/**/*.jsonl"\nwatch_root = "/sandbox/fixtures/cursor/projects"\n'
    fi

    local toml_path="${config_dir}/moraine.toml"
    local guard_token
    guard_token="$(head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n')"
    (umask 077; : >"$toml_path")
    chmod 0600 "$toml_path"
    cat >"$toml_path" <<EOF
# Generated by scripts/dev/sandbox/moraine-sandbox for ${id}
# Do not edit — regenerated on each 'up'.

[clickhouse]
url = "http://clickhouse:8123"
database = "moraine"

[ingest]
backfill_on_start = true
reconcile_interval_seconds = 5.0
heartbeat_interval_seconds = 2.0
flush_interval_seconds = 0.5
state_dir = "/home/moraine/.moraine/ingestor"

${codex_src_block}
${claude_src_block}
${hermes_src_block}
${kimi_src_block}
${kiro_src_block}
${cursor_src_block}
${cursor_sqlite_src_block}
[backend]
bind = "0.0.0.0"
auth_token = "${guard_token}"
start_on_up = true

[monitor]
port = 8080

[runtime]
root_dir = "/home/moraine/.moraine"
service_bin_dir = "/opt/moraine/bin"
managed_clickhouse_dir = "/home/moraine/.moraine/clickhouse"
# ClickHouse is managed by docker compose as a sibling service (see
# scripts/dev/sandbox/compose.yaml). The entrypoint pre-registers a sentinel
# pid so 'moraine up' never attempts its own CH install/start.
clickhouse_auto_install = false
clickhouse_version = "${ch_version}"
EOF
    log "wrote config ${toml_path}"
}

generate_performance_toml() {
    local config_dir="$1"
    local id="$2"
    local ch_version="$3"
    local guard_token
    guard_token="$(head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n')"
    mkdir -p "${config_dir}/fixtures/codex/sessions"
    local toml_path="${config_dir}/moraine.toml"
    (umask 077; : >"$toml_path")
    chmod 0600 "$toml_path"
    cat >"$toml_path" <<EOF
# Fixed benchmark config generated for ${id}. All values are frozen inputs.
[clickhouse]
url = "http://clickhouse:8123"
database = "moraine"
timeout_seconds = 30.0
async_insert = true
wait_for_async_insert = true

[ingest]
batch_size = 4000
max_batch_bytes = 8388608
flush_interval_seconds = 0.5
state_dir = "/home/moraine/.moraine/ingestor"
backfill_on_start = false
max_file_workers = 8
max_inflight_batches = 16
debounce_ms = 50
reconcile_interval_seconds = 3600.0
heartbeat_interval_seconds = 5.0
ack_observation = true

[[ingest.sources]]
name = "benchmark-codex"
harness = "codex"
enabled = true
glob = "/sandbox/fixtures/codex/sessions/**/*.jsonl"
watch_root = "/sandbox/fixtures/codex/sessions"

[backend]
bind = "0.0.0.0"
auth_token = "${guard_token}"
start_on_up = true

[monitor]
port = 8080

[mcp]
use_central_server = true
central_socket_path = "/home/moraine/.moraine/run/mcp.sock"
central_connect_timeout_ms = 250

[runtime]
root_dir = "/home/moraine/.moraine"
service_bin_dir = "/opt/moraine/bin"
managed_clickhouse_dir = "/home/moraine/.moraine/clickhouse"
clickhouse_auto_install = false
clickhouse_version = "${ch_version}"
EOF
    log "wrote fixed performance config ${toml_path}"
}

wait_for_performance_ready() {
    local project="$1"
    local timeout_seconds="${2:-180}"
    local monitor_port="$3"
    local clickhouse_port="$4"
    local started now moraine_state clickhouse_state
    started="$(date +%s)"
    while true; do
        now="$(date +%s)"
        if (( now - started >= timeout_seconds )); then
            warn "performance services did not become ready within ${timeout_seconds}s"
            dc "$project" 0 logs --tail 80 >&2 || true
            return 1
        fi
        moraine_state="$(docker inspect --format='{{.State.Status}}' "$project" 2>/dev/null || true)"
        clickhouse_state="$(docker inspect --format='{{.State.Status}}' "${project}-clickhouse" 2>/dev/null || true)"
        if [[ "$moraine_state" == "running" && "$clickhouse_state" == "running" ]] && \
            curl -fsS --max-time 2 "http://127.0.0.1:${clickhouse_port}/ping" >/dev/null 2>&1 && \
            curl -fsS --max-time 2 "http://127.0.0.1:${monitor_port}/api/v1/health" >/dev/null 2>&1; then
            return 0
        fi
        if [[ "$moraine_state" == "exited" || "$moraine_state" == "dead" || \
              "$clickhouse_state" == "exited" || "$clickhouse_state" == "dead" ]]; then
            warn "performance service exited during readiness"
            dc "$project" 0 logs --tail 80 >&2 || true
            return 1
        fi
        sleep 1
    done
}

# ---------------------------------------------------------------------------
# Health wait
# ---------------------------------------------------------------------------

wait_for_healthy() {
    local project="$1"
    local mount_sessions="$2"
    local timeout_seconds="${3:-120}"
    local started
    started="$(date +%s)"
    while true; do
        local now
        now="$(date +%s)"
        if (( now - started >= timeout_seconds )); then
            warn "container did not report healthy within ${timeout_seconds}s; dumping last 80 lines of logs"
            dc "$project" "$mount_sessions" logs --tail 80 >&2 || true
            return 1
        fi
        local state
        state="$(docker inspect --format='{{.State.Health.Status}}' "$project" 2>/dev/null || true)"
        if [[ "$state" == "healthy" ]]; then
            return 0
        fi
        # Abort early if the container has exited.
        local running
        running="$(docker inspect --format='{{.State.Status}}' "$project" 2>/dev/null || true)"
        if [[ "$running" == "exited" || "$running" == "dead" ]]; then
            warn "container exited while waiting for health; dumping last 80 lines of logs"
            dc "$project" "$mount_sessions" logs --tail 80 >&2 || true
            return 1
        fi
        sleep 2
    done
}

# ---------------------------------------------------------------------------
# Summary printing
# ---------------------------------------------------------------------------

print_summary() {
    local id="$1"
    local monitor_port="$2"
    local ch_http_port="$3"
    local config_path="$4"
    printf '[sandbox] up: %s\n' "$id"
    printf '[sandbox] project: %s%s\n' "$PROJECT_PREFIX" "$id"
    printf '[sandbox] monitor: http://127.0.0.1:%s\n' "$monitor_port"
    printf '[sandbox] clickhouse: http://127.0.0.1:%s\n' "$ch_http_port"
    printf '[sandbox] config: %s\n' "$config_path"
    printf '[sandbox] stop with: scripts/dev/sandbox/moraine-sandbox down %s\n' "$id"
}

# Given a project, discover its host port for a given container service+port
# via `docker compose port`. Returns empty string if not found.
host_port_for() {
    local project="$1"
    local service="$2"
    local container_port="$3"
    ensure_compose_env
    # `docker compose port <service> <port>` prints e.g. 0.0.0.0:53421
    local line
    line="$(docker compose -p "$project" -f "$COMPOSE_FILE" port "$service" "$container_port" 2>/dev/null || true)"
    if [[ -n "$line" ]]; then
        printf '%s' "${line##*:}"
    fi
}

# ---------------------------------------------------------------------------
# Subcommand: up
# ---------------------------------------------------------------------------

cmd_up() {
    local id=""
    local mount_sessions=0
    local rebuild=0
    local quiet=0
    local performance=0
    local local_performance=0
    local cgroup_parent=""
    local binary_dir=""

    while [[ $# -gt 0 ]]; do
        case "$1" in
            --id)
                id="${2-}"
                [[ -n "$id" ]] || die "--id requires an argument"
                shift 2
                ;;
            --mount-host-sessions) mount_sessions=1; shift ;;
            --rebuild)             rebuild=1; shift ;;
            --performance)         performance=1; shift ;;
            --local-performance)   performance=1; local_performance=1; shift ;;
            --cgroup-parent)
                cgroup_parent="${2-}"
                [[ "$cgroup_parent" =~ ^[A-Za-z0-9_./-]+$ ]] || die "--cgroup-parent requires a safe non-empty cgroup name"
                shift 2
                ;;
            --binary-dir)
                binary_dir="${2-}"
                [[ -n "$binary_dir" ]] || die "--binary-dir requires an absolute path"
                shift 2
                ;;
            -q|--quiet)            quiet=1; shift ;;
            -h|--help)             print_help; exit 0 ;;
            *) die "unknown flag for 'up': $1" ;;
        esac
    done

    if (( performance )); then
        if (( local_performance )); then
            [[ -z "$cgroup_parent" ]] || die "--local-performance does not accept --cgroup-parent"
            cgroup_parent="local-comparative"
        else
            [[ -n "$cgroup_parent" ]] || die "--performance requires --cgroup-parent"
        fi
        [[ -n "$binary_dir" ]] || die "--performance requires --binary-dir"
        [[ "$cgroup_parent" != /* && "$cgroup_parent" != *".."* ]] || \
            die "--cgroup-parent must be a relative owned cgroup name"
        (( ! mount_sessions && ! rebuild )) || \
            die "--performance forbids --mount-host-sessions and --rebuild"
    elif [[ -n "$cgroup_parent" || -n "$binary_dir" ]]; then
        die "--cgroup-parent and --binary-dir require --performance"
    fi
    case "${MORAINE_SANDBOX_FAILPOINT:-}" in
        ""|after-reservation|after-config|after-services) ;;
        *) die "unknown MORAINE_SANDBOX_FAILPOINT: ${MORAINE_SANDBOX_FAILPOINT}" ;;
    esac

    if [[ -z "$id" ]]; then
        id="$(generate_sandbox_id)"
    fi
    validate_sandbox_id "$id"

    # In quiet mode, redirect all progress noise to stderr and only emit the
    # sandbox id to the real stdout at the very end — so callers can do:
    #   id=$(moraine-sandbox up --quiet)
    # and pipes/truncation like `| tail -30` can never swallow the id, which
    # was a real footgun that caused agents to leak orphan sandboxes.
    if (( quiet )); then
        exec 3>&1   # save original stdout
        exec 1>&2   # redirect stdout → stderr for the rest of this function
    fi

    check_docker
    check_repo_root
    sandbox_state_root_is_safe || \
        die "sandbox state root is not a private trusted directory: ${SANDBOX_STATE_ROOT}"

    local project
    project="$(project_name_for "$id")"
    local config_dir
    config_dir="$(config_dir_for "$id")"
    if [[ -e "$config_dir" || -L "$config_dir" ]]; then
        die "sandbox config path already exists; refusing collision: ${config_dir}"
    fi
    local ownership_token="${MORAINE_SANDBOX_OWNERSHIP_TOKEN:-}"
    if [[ -z "$ownership_token" ]]; then
        ownership_token="$(generate_ownership_token)"
    fi
    validate_ownership_token "$ownership_token"
    if ! reserve_project_ownership "$project" "$ownership_token"; then
        die "sandbox project already exists or could not be reserved: ${project}"
    fi
    if ! (umask 077; mkdir -m 700 "$config_dir"); then
        release_project_ownership "$project" "$ownership_token" || true
        die "could not create private sandbox config directory: ${config_dir}"
    fi
    arm_failed_up_cleanup "$project" "$config_dir" "$ownership_token" "$mount_sessions"
    maybe_failpoint after-reservation
    export SANDBOX_OWNERSHIP_COMPOSE_FILE
    SANDBOX_OWNERSHIP_COMPOSE_FILE="$(write_ownership_evidence "$config_dir" "$ownership_token" "$performance")"
    export SANDBOX_PERFORMANCE="$performance"
    export MORAINE_PERFORMANCE_CGROUP_PARENT="$cgroup_parent"
    export SANDBOX_LOCAL_PERFORMANCE="$local_performance"
    export SANDBOX_BIN_DIR="${binary_dir:-/dev/null}"
    if (( performance )); then
        validate_performance_binary_dir "$binary_dir" "$config_dir"
        printf '1\n' >"${config_dir}/.performance-mode"
        printf '%s\n' "$cgroup_parent" >"${config_dir}/.cgroup-parent"
        if (( local_performance )); then
            printf '1\n' >"${config_dir}/.local-performance-mode"
            chmod 0600 "${config_dir}/.local-performance-mode"
        fi
        printf 'reserved\n' >"${config_dir}/.lifecycle-phase"
        chmod 0600 \
            "${config_dir}/.performance-mode" \
            "${config_dir}/.cgroup-parent" \
            "${config_dir}/.lifecycle-phase"
    fi

    log "starting sandbox ${id} (project ${project})"

    local web_dir="${REPO_ROOT}/web/monitor/dist"
    if (( performance )); then
        [[ -f "${web_dir}/index.html" ]] || \
            die "performance monitor assets missing at ${web_dir}/index.html"
    else
        ensure_monitor_frontend
        [[ -f "${web_dir}/index.html" ]] || \
            warn "monitor assets missing at ${web_dir}/index.html — UI will 404"
    fi

    # Ports.
    local monitor_port ch_http_port ch_tcp_port
    monitor_port="$(pick_open_port)"
    ch_http_port="$(pick_clickhouse_http_port)"
    ch_tcp_port=$(( ch_http_port + 877 ))
    log "ports: monitor=${monitor_port} clickhouse-http=${ch_http_port} clickhouse-tcp=${ch_tcp_port}"

    # Config. The `:=/dev/null` placeholders at the top of this script keep
    # the compose files parseable when nothing is mounted; here we want the
    # user-facing defaults, so fall back whenever the env var is unset, empty,
    # or still holds the placeholder.
    local codex_dir="$SANDBOX_CODEX_SESSIONS_DIR"
    [[ -z "$codex_dir" || "$codex_dir" == "/dev/null" ]] && codex_dir="${HOME}/.codex/sessions"
    local claude_dir="$SANDBOX_CLAUDE_PROJECTS_DIR"
    [[ -z "$claude_dir" || "$claude_dir" == "/dev/null" ]] && claude_dir="${HOME}/.claude/projects"
    local hermes_dir="$SANDBOX_HERMES_SESSIONS_DIR"
    [[ -z "$hermes_dir" || "$hermes_dir" == "/dev/null" ]] && hermes_dir="${HOME}/.hermes/sessions"
    local kimi_dir="$SANDBOX_KIMI_SESSIONS_DIR"
    [[ -z "$kimi_dir" || "$kimi_dir" == "/dev/null" ]] && kimi_dir="${HOME}/.kimi/sessions"
    local kiro_dir="$SANDBOX_KIRO_SESSIONS_DIR"
    [[ -z "$kiro_dir" || "$kiro_dir" == "/dev/null" ]] && kiro_dir="${HOME}/.kiro/sessions/cli"
    local cursor_dir="$SANDBOX_CURSOR_PROJECTS_DIR"
    [[ -z "$cursor_dir" || "$cursor_dir" == "/dev/null" ]] && cursor_dir="${HOME}/.cursor/projects"
    local cursor_state_dir="$SANDBOX_CURSOR_STATE_DIR"
    if [[ -z "$cursor_state_dir" || "$cursor_state_dir" == "/dev/null" ]]; then
        # macOS keeps Cursor's state.vscdb under Application Support; Linux
        # under ~/.config. Prefer whichever exists on this host.
        if [[ -d "${HOME}/Library/Application Support/Cursor/User" ]]; then
            cursor_state_dir="${HOME}/Library/Application Support/Cursor/User"
        else
            cursor_state_dir="${HOME}/.config/Cursor/User"
        fi
    fi
    local ch_version
    ch_version="$(clickhouse_version_from_default_config)"
    if (( performance )); then
        generate_performance_toml "$config_dir" "$id" "$ch_version"
    else
        generate_moraine_toml "$config_dir" "$id" "$mount_sessions" \
            "$codex_dir" "$claude_dir" "$hermes_dir" "$kimi_dir" "$kiro_dir" \
            "$cursor_dir" "$cursor_state_dir" "$ch_version"
    fi
    maybe_failpoint after-config
    local config_path="${config_dir}/moraine.toml"

    # Translate moraine's clickhouse_version (e.g. "v25.12.5.44-stable") to
    # the docker tag format on Docker Hub ("25.12.5.44"). Strip the leading
    # "v" and any "-stable" / "-lts" suffix.
    local ch_tag="${ch_version#v}"
    ch_tag="${ch_tag%-stable}"
    ch_tag="${ch_tag%-lts}"

    # Export compose env.
    export COMPOSE_PROJECT_NAME="$project"
    export SANDBOX_CLICKHOUSE_IMAGE="clickhouse/clickhouse-server:${ch_tag}"
    export SANDBOX_MONITOR_HOST_PORT="$monitor_port"
    export SANDBOX_CLICKHOUSE_HTTP_HOST_PORT="$ch_http_port"
    export SANDBOX_CLICKHOUSE_TCP_HOST_PORT="$ch_tcp_port"
    export SANDBOX_REPO_ROOT="$REPO_ROOT"
    export SANDBOX_WEB_DIR="$web_dir"
    export SANDBOX_CONFIG_DIR="$config_dir"
    if (( performance )); then
        export SANDBOX_ENTRYPOINT="$PERFORMANCE_ENTRYPOINT"
        export SANDBOX_REBUILD=0
    else
        export SANDBOX_ENTRYPOINT="${REPO_ROOT}/scripts/dev/sandbox/entrypoint.sh"
        export SANDBOX_REBUILD="$rebuild"
    fi

    # sccache cache bind. Prefer the host's SCCACHE_DIR (what the developer
    # already uses), falling back to the XDG default. Created lazily so first
    # use inside the sandbox doesn't fail on a missing directory, and so
    # docker doesn't create it as root on the host.
    if (( ! performance )); then
        local host_sccache_dir="${SCCACHE_DIR:-${HOME}/.cache/sccache}"
        if [[ ! -d "$host_sccache_dir" ]]; then
            mkdir -p "$host_sccache_dir"
            log "created host sccache dir ${host_sccache_dir} (first use)"
        fi
        export SANDBOX_SCCACHE_HOST_DIR="$host_sccache_dir"
        log "sccache: sharing ${host_sccache_dir} with container (RUSTC_WRAPPER=sccache)"
    else
        export SANDBOX_SCCACHE_HOST_DIR="/dev/null"
    fi
    if [[ "$mount_sessions" == "1" ]]; then
        export SANDBOX_CODEX_SESSIONS_DIR="$codex_dir"
        export SANDBOX_CLAUDE_PROJECTS_DIR="$claude_dir"
        # compose.sessions.yaml requires a value even when the dir is absent;
        # fall back to a safe read-only placeholder.
        if [[ -d "$hermes_dir" ]]; then
            export SANDBOX_HERMES_SESSIONS_DIR="$hermes_dir"
        else
            export SANDBOX_HERMES_SESSIONS_DIR="/dev/null"
        fi
        if [[ -d "$kimi_dir" ]]; then
            export SANDBOX_KIMI_SESSIONS_DIR="$kimi_dir"
        else
            export SANDBOX_KIMI_SESSIONS_DIR="/dev/null"
        fi
        if [[ -d "$kiro_dir" ]]; then
            export SANDBOX_KIRO_SESSIONS_DIR="$kiro_dir"
        else
            export SANDBOX_KIRO_SESSIONS_DIR="/dev/null"
        fi
        if [[ -d "$cursor_dir" ]]; then
            export SANDBOX_CURSOR_PROJECTS_DIR="$cursor_dir"
        else
            export SANDBOX_CURSOR_PROJECTS_DIR="/dev/null"
        fi
        if [[ -d "$cursor_state_dir" ]]; then
            export SANDBOX_CURSOR_STATE_DIR="$cursor_state_dir"
        else
            export SANDBOX_CURSOR_STATE_DIR="/dev/null"
        fi
    fi

    # Performance suites prebuild one shared runtime image before any physical
    # reset, then pin the observed image ID in every artifact.
    if (( performance )); then
        log "reusing prebuilt performance image"
        docker image inspect moraine-sandbox-runtime:latest >/dev/null 2>&1 || \
            die "performance image is missing; run through performance_suite.py"
    else
        log "building runtime image"
        dc "$project" "$mount_sessions" build
    fi

    # Up. The ownership overlay labels every destructive Compose resource.
    log "starting container (docker compose up -d)"
    dc "$project" "$mount_sessions" up -d
    if ! verify_project_ownership "$project" "$ownership_token"; then
        die "sandbox ${id} ownership labels are incomplete or mismatched; refusing to continue"
    fi
    maybe_failpoint after-services

    if (( performance )); then
        log "waiting for one-time performance readiness"
        if ! wait_for_performance_ready "$project" 180 "$monitor_port" "$ch_http_port"; then
            die "performance sandbox ${id} did not become ready"
        fi
        printf 'services-ready\n' >"${config_dir}/.lifecycle-phase"
    else
        local health_timeout=900
        log "waiting for container health (timeout ${health_timeout}s; initial boot runs cargo build inside container)"
        log "follow live: scripts/dev/sandbox/moraine-sandbox logs ${id} -f"
        if ! wait_for_healthy "$project" "$mount_sessions" "$health_timeout"; then
            die "sandbox ${id} did not become healthy; inspect logs with 'moraine-sandbox logs ${id}'"
        fi
    fi

    disarm_failed_up_cleanup
    print_summary "$id" "$monitor_port" "$ch_http_port" "$config_path"

    # Only the id to the real stdout in quiet mode — everything above went
    # to stderr thanks to the exec 1>&2 up top.
    if (( quiet )); then
        printf '%s\n' "$id" >&3
    fi
}

# ---------------------------------------------------------------------------
# Subcommand: shell
# ---------------------------------------------------------------------------

cmd_shell() {
    local id
    id="$(resolve_sandbox_id "${1-}")"
    check_docker
    local project
    project="$(project_name_for "$id")"
    export COMPOSE_PROJECT_NAME="$project"
    # We don't know whether sessions overlay was used, but -f list only affects
    # service discovery, not exec against a running container. Use base file.
    ensure_compose_env
    exec docker compose -p "$project" -f "$COMPOSE_FILE" exec -u moraine moraine bash
}

# ---------------------------------------------------------------------------
# Subcommand: logs
# ---------------------------------------------------------------------------

cmd_logs() {
    local follow=0
    local id=""
    while [[ $# -gt 0 ]]; do
        case "$1" in
            -f|--follow) follow=1; shift ;;
            -h|--help)   print_help; exit 0 ;;
            *)
                if [[ -z "$id" ]]; then id="$1"; shift
                else die "unexpected argument to 'logs': $1"
                fi
                ;;
        esac
    done
    check_docker
    id="$(resolve_sandbox_id "$id")"
    local project
    project="$(project_name_for "$id")"
    export COMPOSE_PROJECT_NAME="$project"
    ensure_compose_env
    if (( follow )); then
        exec docker compose -p "$project" -f "$COMPOSE_FILE" logs -f
    else
        exec docker compose -p "$project" -f "$COMPOSE_FILE" logs
    fi
}

# ---------------------------------------------------------------------------
# Subcommand: down
# ---------------------------------------------------------------------------

down_one() {
    local id="$1"
    local project
    project="$(project_name_for "$id")"
    local config_dir
    config_dir="$(config_dir_for "$id")"
    if [[ ! -e "$config_dir" && ! -L "$config_dir" ]]; then
        if project_has_resources "$project"; then
            warn "refusing teardown for ${project}: resources exist without ownership evidence"
            return 1
        elif [[ $? -eq 1 ]]; then
            log "sandbox ${id} is already down"
            return 0
        else
            warn "could not determine whether ${project} still has resources"
            return 1
        fi
    fi
    local recorded_token expected_token

    recorded_token="$(read_recorded_ownership_token "$config_dir" || true)"
    if [[ -z "$recorded_token" ]]; then
        warn "refusing teardown for ${project}: private ownership evidence is missing or unsafe"
        return 1
    fi
    expected_token="${MORAINE_SANDBOX_OWNERSHIP_TOKEN:-$recorded_token}"
    if [[ ! "$expected_token" =~ $OWNERSHIP_TOKEN_REGEX || "$expected_token" != "$recorded_token" ]]; then
        warn "refusing teardown for ${project}: ownership token mismatch"
        return 1
    fi
    if ! verify_project_ownership "$project" "$expected_token"; then
        write_owned_leak_inventory "$project" "$config_dir" "$expected_token" \
            "teardown refused because Compose ownership labels do not match"
        warn "refusing teardown for ${project}: Compose ownership labels do not match"
        return 1
    fi

    log "tearing down ${id} (project ${project})"
    local compose_rc=0
    local marker_rc=0
    set +e
    dc "$project" 0 down -v --remove-orphans
    compose_rc=$?
    set -e

    # Preserve every ownership artifact until Compose and its owned resources
    # are both proven gone. A failed down is retryable with the same evidence.
    if (( compose_rc != 0 )); then
        warn "docker compose down failed for ${project} with exit ${compose_rc}; ownership evidence retained"
        return "$compose_rc"
    fi
    if ! compose_resources_removed "$project"; then
        warn "Compose reported success but owned resources remain for ${project}; ownership evidence retained"
        return 1
    fi
    if ! cleanup_local_config "$config_dir"; then
        write_owned_leak_inventory "$project" "$config_dir" "$expected_token" \
            "owned Docker resources were removed but local cleanup failed"
        warn "could not safely clean generated config files in ${config_dir}"
        return 1
    fi
    set +e
    release_project_ownership "$project" "$expected_token"
    marker_rc=$?
    set -e
    if (( marker_rc != 0 )); then
        warn "ownership marker volume removal failed for ${project} with exit ${marker_rc}; ownership evidence retained"
        return "$marker_rc"
    fi
    rm -f "${config_dir}/.ownership-token"
    if rmdir "$config_dir" 2>/dev/null; then
        log "removed ${config_dir}"
    else
        warn "sandbox resources were removed but local config directory is not empty: ${config_dir}"
        return 1
    fi
}

cmd_down() {
    check_docker
    sandbox_state_root_is_safe || \
        die "sandbox state root is not a private trusted directory: ${SANDBOX_STATE_ROOT}"
    if [[ "${1-}" == "--all" ]]; then
        local ids
        local result=0
        local item_rc
        read_lines_into ids < <(list_sandbox_ids)
        if [[ "${#ids[@]}" -eq 0 ]]; then
            log "no sandboxes to tear down"
        else
            for id in "${ids[@]}"; do
                set +e
                down_one "$id"
                item_rc=$?
                set -e
                if (( item_rc != 0 && result == 0 )); then
                    result=$item_rc
                fi
            done
        fi
        return "$result"
    fi

    local id
    id="$(resolve_sandbox_id "${1-}")"
    down_one "$id"
}

# ---------------------------------------------------------------------------
# Subcommand: list
# ---------------------------------------------------------------------------

cmd_list() {
    check_docker
    local ids
    read_lines_into ids < <(list_sandbox_ids)
    if [[ "${#ids[@]}" -eq 0 ]]; then
        log "no sandboxes running"
        return 0
    fi
    printf '%-14s %-12s %s\n' "ID" "STATUS" "MONITOR"
    local json
    json="$(list_sandbox_projects_json)"
    local id
    for id in "${ids[@]}"; do
        local project
        project="$(project_name_for "$id")"
        local status
        status="$(printf '%s' "$json" | "$PYTHON_BIN" -c "
import json, sys
name = sys.argv[1]
try:
    data = json.load(sys.stdin)
except Exception:
    sys.exit(0)
for proj in data:
    if proj.get('Name') == name:
        print(proj.get('Status', ''))
        break
" "$project")"
        local host_port
        host_port="$(host_port_for "$project" moraine 8080)"
        local url="-"
        if [[ -n "$host_port" ]]; then
            url="http://127.0.0.1:${host_port}"
        fi
        printf '%-14s %-12s %s\n' "$id" "${status:-unknown}" "$url"
    done
}

# ---------------------------------------------------------------------------
# Subcommand: status
# ---------------------------------------------------------------------------

container_id_for_service() {
    local project="$1"
    local service="$2"
    local ids=()
    read_lines_into ids < <(
        docker ps -aq \
            --filter "label=com.docker.compose.project=${project}" \
            --filter "label=com.docker.compose.service=${service}"
    )
    [[ "${#ids[@]}" -eq 1 ]] || return 1
    printf '%s' "${ids[0]}"
}

require_owned_performance() {
    local id="$1"
    validate_sandbox_id "$id"
    local project config_dir token
    project="$(project_name_for "$id")"
    config_dir="$(config_dir_for "$id")"
    token="$(read_recorded_ownership_token "$config_dir" || true)"
    [[ -n "$token" && -f "${config_dir}/.performance-mode" ]] || \
        die "sandbox ${id} is not an owned performance sandbox"
    verify_project_ownership "$project" "$token" || \
        die "sandbox ${id} ownership labels do not match"
    PERF_PROJECT="$project"
    PERF_CONFIG_DIR="$config_dir"
    PERF_TOKEN="$token"
}

print_machine_status() {
    local id="$1"
    local project config_dir token performance=0
    project="$(project_name_for "$id")"
    config_dir="$(config_dir_for "$id")"
    token="$(read_recorded_ownership_token "$config_dir" || true)"
    [[ -n "$token" ]] || die "sandbox ownership evidence is missing"
    verify_project_ownership "$project" "$token" || die "sandbox ownership labels do not match"
    [[ -f "${config_dir}/.performance-mode" ]] && performance=1
    local services=(clickhouse moraine)
    (( performance )) && services+=(loadgen)
    local ids=()
    local service container_id
    for service in "${services[@]}"; do
        container_id="$(container_id_for_service "$project" "$service" || true)"
        [[ -n "$container_id" ]] || die "sandbox service identity is ambiguous: ${service}"
        ids+=("$container_id")
    done
    local inspect_json monitor_port ch_http_port
    inspect_json="$(docker inspect "${ids[@]}")"
    monitor_port="$(host_port_for "$project" moraine 8080)"
    ch_http_port="$(host_port_for "$project" clickhouse 8123)"
    [[ "$monitor_port" =~ ^[0-9]+$ && "$ch_http_port" =~ ^[0-9]+$ ]] || \
        die "sandbox published ports are unavailable"
    STATUS_ID="$id" STATUS_PROJECT="$project" STATUS_CONFIG="$config_dir" \
        STATUS_TOKEN="$token" STATUS_PERFORMANCE="$performance" \
        STATUS_MONITOR_PORT="$monitor_port" STATUS_CLICKHOUSE_PORT="$ch_http_port" \
        "$PYTHON_BIN" - 3<<<"$inspect_json" <<'PY'
import hashlib
import json
import os
from pathlib import Path
import sys

objects = json.load(os.fdopen(3))
performance = os.environ["STATUS_PERFORMANCE"] == "1"
roles = {
    "clickhouse": "server_clickhouse",
    "moraine": "server_moraine",
    "loadgen": "loadgen",
}
containers = []
for value in objects:
    labels = value.get("Config", {}).get("Labels") or {}
    service = labels.get("com.docker.compose.service")
    if service not in roles:
        raise SystemExit("unexpected Compose service in status")
    state = value.get("State") or {}
    containers.append({
        "role": roles[service],
        "service": service,
        "container_id": value["Id"],
        "pid": int(state.get("Pid", 0)),
        "image_id": value["Image"],
        "running": bool(state.get("Running", False)),
    })
config_dir = Path(os.environ["STATUS_CONFIG"])
manifest_path = config_dir / ".binary-manifest.json"
if performance:
    raw = manifest_path.read_bytes()
    manifest = json.loads(raw)
    binary_sha256 = {
        name: entry["sha256"] for name, entry in manifest["binaries"].items()
    }
    manifest_sha256 = "sha256:" + hashlib.sha256(raw).hexdigest()
    cgroup_parent = (config_dir / ".cgroup-parent").read_text().strip()
else:
    binary_sha256 = {}
    manifest_sha256 = "sha256:" + hashlib.sha256(b"").hexdigest()
    cgroup_parent = ""
phase_path = config_dir / ".lifecycle-phase"
phase = phase_path.read_text().strip() if phase_path.exists() else "development"
document = {
    "schema_version": "moraine-sandbox-lifecycle-v1",
    "sandbox_id": os.environ["STATUS_ID"],
    "project": os.environ["STATUS_PROJECT"],
    "performance": performance,
    "lifecycle_phase": phase,
    "ports": {
        "monitor": int(os.environ["STATUS_MONITOR_PORT"]),
        "clickhouse_http": int(os.environ["STATUS_CLICKHOUSE_PORT"]),
    },
    "config_path": str(config_dir / "moraine.toml"),
    "cgroup_parent": cgroup_parent,
    "ownership_token_sha256": "sha256:" + hashlib.sha256(
        os.environ["STATUS_TOKEN"].encode()
    ).hexdigest(),
    "binary_manifest_sha256": manifest_sha256,
    "binary_sha256": binary_sha256,
    "containers": sorted(containers, key=lambda item: item["role"]),
}
print(json.dumps(document, sort_keys=True, separators=(",", ":")))
PY
}

print_central_status() {
    local id="$1"
    require_owned_performance "$id"
    local moraine_id loadgen_id lifecycle moraine_top loadgen_top local_mode=0
    moraine_id="$(container_id_for_service "$PERF_PROJECT" moraine)" || \
        die "moraine service identity is ambiguous"
    loadgen_id="$(container_id_for_service "$PERF_PROJECT" loadgen)" || \
        die "loadgen service identity is ambiguous"
    lifecycle="$(docker exec "$moraine_id" cat /home/moraine/.moraine/performance-central.json)"
    if [[ -f "${PERF_CONFIG_DIR}/.local-performance-mode" ]]; then
        local_mode=1
        moraine_top="$(docker exec "$moraine_id" sh -c 'for stat in /proc/[0-9]*/stat; do cat "$stat" 2>/dev/null || :; done')"
        loadgen_top="$(docker exec "$loadgen_id" sh -c 'for stat in /proc/[0-9]*/stat; do cat "$stat" 2>/dev/null || :; done')"
    else
        moraine_top="$(docker top "$moraine_id" -eo pid,ppid,comm)"
        loadgen_top="$(docker top "$loadgen_id" -eo pid,ppid,comm)"
    fi
    CENTRAL_LIFECYCLE="$lifecycle" CENTRAL_MORAINE_TOP="$moraine_top" \
        CENTRAL_LOADGEN_TOP="$loadgen_top" CENTRAL_LOCAL="$local_mode" "$PYTHON_BIN" - <<'PY'
import json
import os
from pathlib import Path

source = json.loads(os.environ["CENTRAL_LIFECYCLE"])
if source.get("schema_version") != "moraine-performance-central-v1":
    raise SystemExit("central lifecycle schema mismatch")

def rows(text):
    result = []
    if os.environ["CENTRAL_LOCAL"] == "1":
        lines = text.splitlines()
        for line in lines:
            closing = line.rfind(")")
            if closing < 0:
                continue
            prefix = line[:closing + 1]
            fields = line[closing + 2:].split()
            opening = prefix.find("(")
            if opening < 0 or len(fields) < 20:
                continue
            result.append((
                int(prefix[:opening].strip()),
                int(fields[1]),
                prefix[opening + 1:-1],
                int(fields[19]),
            ))
        return result
    for line in text.splitlines()[1:]:
        fields = line.split(None, 2)
        if len(fields) == 3:
            pid = int(fields[0])
            stat = (Path("/proc") / str(pid) / "stat").read_text()
            closing = stat.rfind(")")
            stat_fields = stat[closing + 2:].split()
            result.append((pid, int(fields[1]), fields[2], int(stat_fields[19])))
    return result

server = rows(os.environ["CENTRAL_MORAINE_TOP"])
loadgen = rows(os.environ["CENTRAL_LOADGEN_TOP"])
central = [row for row in server if row[2] == "moraine-mcp"]
ingest = [row for row in server if row[2] == "moraine-ingest"]
if len(central) != 1 or len(ingest) != 1:
    raise SystemExit("central or ingest host process identity is ambiguous")
roots = {central[0][0], ingest[0][0]}
document = {
    "schema_version": "moraine-performance-central-v1",
    "cache_generation": source["cache_generation"],
    "central": {"pid": central[0][0], "starttime": central[0][3]},
    "ingest": {"pid": ingest[0][0], "starttime": ingest[0][3]},
    "server_children": sorted(row[0] for row in server if row[0] not in roots),
    "route_processes": [
        {"pid": row[0], "starttime": row[3]}
        for row in loadgen if row[2] == "moraine-mcp"
    ],
}
print(json.dumps(document, sort_keys=True, separators=(",", ":")))
PY
}

cmd_checkpoint() {
    check_docker
    local id="${1-}" phase="${2-}"
    [[ $# -eq 2 ]] || die "checkpoint requires <id> <services-ready|seeded|artifact-created>"
    require_owned_performance "$id"
    case "$phase" in services-ready|seeded|artifact-created) ;; *) die "invalid checkpoint: ${phase}" ;; esac
    local current
    current="$(cat "${PERF_CONFIG_DIR}/.lifecycle-phase")"
    case "${current}:${phase}" in
        reserved:services-ready|services-ready:services-ready|services-ready:seeded|seeded:seeded|seeded:artifact-created|artifact-created:artifact-created) ;;
        *) die "invalid lifecycle transition: ${current} -> ${phase}" ;;
    esac
    printf '%s\n' "$phase" >"${PERF_CONFIG_DIR}/.lifecycle-phase"
}

cmd_benchmark_service() {
    check_docker
    local action="${1-}" id="${2-}"
    [[ $# -eq 2 ]] || die "benchmark-service requires <start|stop|status> <id>"
    require_owned_performance "$id"
    local container_id
    container_id="$(container_id_for_service "$PERF_PROJECT" moraine)" || \
        die "moraine service identity is ambiguous"
    case "$action" in
        start)
            docker kill --signal USR2 "$container_id" >/dev/null
            for _ in $(seq 1 200); do
                if docker exec "$container_id" test -f /home/moraine/.moraine/performance-central.json; then
                    return
                fi
                sleep 0.01
            done
            die "central service did not start"
            ;;
        stop)
            docker kill --signal USR1 "$container_id" >/dev/null
            for _ in $(seq 1 200); do
                if ! docker exec "$container_id" test -f /home/moraine/.moraine/performance-central.json; then
                    return
                fi
                sleep 0.01
            done
            die "central service did not stop"
            ;;
        status) print_central_status "$id" ;;
        *) die "unknown benchmark-service action: ${action}" ;;
    esac
}

cmd_exec_loadgen() {
    check_docker
    local id="${1-}"
    [[ -n "$id" ]] || die "exec-loadgen requires a sandbox id"
    shift
    require_owned_performance "$id"
    local cwd="/home/moraine"
    local docker_args=(-i)
    while [[ $# -gt 0 ]]; do
        case "$1" in
            --cwd)
                cwd="${2-}"
                [[ "$cwd" == /* ]] || die "--cwd must be an absolute container path"
                docker_args+=(-w "$cwd")
                shift 2
                ;;
            --env)
                [[ "${2-}" =~ ^[A-Z][A-Z0-9_]*=.*$ ]] || die "--env must be NAME=value"
                docker_args+=(-e "$2")
                shift 2
                ;;
            --) shift; break ;;
            *) die "unexpected exec-loadgen option: $1" ;;
        esac
    done
    [[ $# -gt 0 ]] || die "exec-loadgen requires a command after --"
    local container_id
    container_id="$(container_id_for_service "$PERF_PROJECT" loadgen)" || \
        die "loadgen service identity is ambiguous"
    exec docker exec "${docker_args[@]}" "$container_id" "$@"
}

cmd_benchmark_logs() {
    check_docker
    local id="${1-}"
    [[ $# -eq 1 ]] || die "benchmark-logs requires <id>"
    require_owned_performance "$id"
    local container_id
    container_id="$(container_id_for_service "$PERF_PROJECT" moraine)" || \
        die "moraine service identity is ambiguous"
    exec docker logs "$container_id"
}

cmd_status() {
    check_docker
    local json_output=0
    local id=""
    while [[ $# -gt 0 ]]; do
        case "$1" in
            --json) json_output=1; shift ;;
            -h|--help) print_help; return 0 ;;
            *)
                [[ -z "$id" ]] || die "unexpected status argument: $1"
                id="$1"
                shift
                ;;
        esac
    done
    id="$(resolve_sandbox_id "$id")"
    if (( json_output )); then
        print_machine_status "$id"
        return
    fi
    local project
    project="$(project_name_for "$id")"
    local config_dir
    config_dir="$(config_dir_for "$id")"
    local config_path="${config_dir}/moraine.toml"
    local monitor_port ch_http_port
    monitor_port="$(host_port_for "$project" moraine 8080)"
    ch_http_port="$(host_port_for "$project" clickhouse 8123)"
    if [[ -n "$monitor_port" && -n "$ch_http_port" ]]; then
        print_summary "$id" "$monitor_port" "$ch_http_port" "$config_path"
    else
        warn "could not read published ports for project ${project}; container may be stopped"
    fi
    echo
    ensure_compose_env
    docker compose -p "$project" -f "$COMPOSE_FILE" ps
}

# ---------------------------------------------------------------------------
# Help
# ---------------------------------------------------------------------------

print_help() {
    cat <<'EOF'
moraine-sandbox — per-developer containerized moraine stack (RFC #232)

A sandbox is a long-lived linux container that mounts your worktree at
/repo, cargo-builds the workspace on first boot (wrapped by sccache sharing
your host's cache dir), then runs the moraine stack. Agents iterate inside
via `moraine-sandbox shell` — cargo build / test / clippy all just work
against the volume-backed target dir.

USAGE:
    moraine-sandbox <command> [args]

COMMANDS:
    up [--id <id>] [--mount-host-sessions] [--rebuild] [--quiet|-q]
       [--performance --cgroup-parent <owned-name> --binary-dir <immutable-dir>]
         Bring up a new physical sandbox. Performance mode forbids host
         sessions and in-container builds, mounts a frozen release directory
         read-only, uses the minimal service overlay, and requires an owned
         aggregate cgroup parent. --quiet emits only the sandbox id on stdout.

    shell [<id>]       Exec an interactive bash as user 'moraine' inside
                       the container. If <id> is omitted and exactly one
                       sandbox exists, it is selected automatically.
                       cargo / rustc / rustup / sccache are all on PATH;
                       CARGO_TARGET_DIR points at a volume so builds are
                       incremental across exec sessions.

    logs [<id>] [-f]   Tail container logs (docker compose logs). Includes
                       the bootstrap cargo build output.

    down <id>          Stop + remove the container, named volumes, and
                       the /tmp/moraine-sandbox-<id>/ config dir.
    down --all         Do the same for every sandbox owned by this user.

    list               List sandboxes with their status and monitor URL.

    status [<id>]      Print the summary block + docker compose ps.
    status --json <id>  Print strict moraine-sandbox-lifecycle-v1 JSON.
    checkpoint <id> <services-ready|seeded|artifact-created>
                        Persist an ownership-checked benchmark phase.
    benchmark-service <start|stop|status> <id>
                        Control/read the minimal measured service. Status is
                        strict moraine-performance-central-v1 JSON.
    exec-loadgen <id> [--cwd <path>] [--env NAME=value] -- <command...>
                        Exec only in the ownership-checked external loadgen.
    benchmark-logs <id> Read ownership-checked measured-service logs. Runtime
                        exposes only allowlisted ingest-ack lines to scenarios.

    --help, -h         Show this help.

ENVIRONMENT:
    PYTHON_BIN                  python3 interpreter used for port picking
    SCCACHE_DIR                 host sccache cache dir (defaults to
                                ~/.cache/sccache); bind-mounted rw into the
                                container so container cargo builds share
                                the developer's existing cache
    SANDBOX_CODEX_SESSIONS_DIR  override host codex sessions dir (for
                                --mount-host-sessions; default ~/.codex/sessions)
    SANDBOX_CLAUDE_PROJECTS_DIR override host claude projects dir (default
                                ~/.claude/projects)
    SANDBOX_HERMES_SESSIONS_DIR override host hermes live-session dir (default
                                ~/.hermes/sessions). Files are
                                session_*.json rewritten in place — the
                                sandbox ingests them via the session_json
                                format path.
    SANDBOX_KIMI_SESSIONS_DIR   override host kimi-cli sessions dir (default
                                ~/.kimi/sessions). Ingests wire.jsonl files
                                written by the Kimi CLI.
    SANDBOX_KIRO_SESSIONS_DIR   override host Kiro CLI sessions dir (default
                                ~/.kiro/sessions/cli). Ingests paired JSONL and
                                JSON files via the kiro_session format.
    SANDBOX_CURSOR_PROJECTS_DIR  override host Cursor projects dir (default
                                ~/.cursor/projects). Ingests Agent JSONL
                                transcripts under agent-transcripts/.
    SANDBOX_CURSOR_STATE_DIR    override host Cursor SQLite state dir
                                (default "~/Library/Application Support/
                                Cursor/User" when it exists, else
                                ~/.config/Cursor/User). Ingests state.vscdb
                                databases via the cursor_sqlite format; the
                                format is opt-in elsewhere but enabled inside
                                the sandbox so it can be exercised.
EOF
}

# ---------------------------------------------------------------------------
# Dispatcher
# ---------------------------------------------------------------------------

main() {
    if [[ $# -lt 1 ]]; then
        print_help
        exit 1
    fi
    local cmd="$1"; shift
    case "$cmd" in
        up)      cmd_up "$@" ;;
        shell)   cmd_shell "$@" ;;
        logs)    cmd_logs "$@" ;;
        down)    cmd_down "$@" ;;
        list|ls) cmd_list "$@" ;;
        status)  cmd_status "$@" ;;
        checkpoint) cmd_checkpoint "$@" ;;
        benchmark-service) cmd_benchmark_service "$@" ;;
        exec-loadgen) cmd_exec_loadgen "$@" ;;
        benchmark-logs) cmd_benchmark_logs "$@" ;;
        -h|--help|help) print_help ;;
        *)       die "unknown subcommand: ${cmd} (try --help)" ;;
    esac
}

if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
    main "$@"
fi
