autoresearch-quantum/scripts/app.sh

631 lines
21 KiB
Bash
Raw Normal View History

#!/usr/bin/env bash
# ──────────────────────────────────────────────────────────────────────
# app.sh — Consumer lifecycle manager for autoresearch-quantum
#
# Usage:
# bash scripts/app.sh bootstrap Create venv, install deps, verify
# bash scripts/app.sh start Launch JupyterLab (opens browser)
# bash scripts/app.sh start --no-open Launch without opening browser
# bash scripts/app.sh start --foreground Run in foreground (Ctrl-C to stop)
# bash scripts/app.sh start --port 9999 Use a specific port
# bash scripts/app.sh stop Stop running JupyterLab
# bash scripts/app.sh restart Stop + start
# bash scripts/app.sh status Show service status
# bash scripts/app.sh validate Run full validation suite
# bash scripts/app.sh validate --quick Lint + unit tests only
# bash scripts/app.sh logs Tail JupyterLab logs
# bash scripts/app.sh logs -f Follow live output
# bash scripts/app.sh reset Reset learner progress files
# bash scripts/app.sh reset-state Reset Jupyter runtime + UI state
# ──────────────────────────────────────────────────────────────────────
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
VENV_DIR="$PROJECT_ROOT/.venv"
PYTHON="$VENV_DIR/bin/python"
JUPYTER="$VENV_DIR/bin/jupyter"
# ── Isolated Jupyter directories (prevents cross-project interference) ──
LOG_DIR="$PROJECT_ROOT/.logs"
PID_FILE="$LOG_DIR/jupyter.pid"
LOG_FILE="$LOG_DIR/jupyterlab.log"
JUPYTER_CONFIG_DIR="$PROJECT_ROOT/.jupyter_config"
JUPYTER_DATA_DIR="$PROJECT_ROOT/.jupyter_data"
JUPYTER_RUNTIME_DIR="$PROJECT_ROOT/.jupyter_runtime"
IPYTHONDIR="$PROJECT_ROOT/.ipython"
MPLCONFIGDIR="$PROJECT_ROOT/.cache/matplotlib"
# ── Colours ───────────────────────────────────────────────────────────
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
BOLD='\033[1m'
NC='\033[0m'
info() { echo -e "${BLUE}[info]${NC} $*"; }
ok() { echo -e "${GREEN}[ ok]${NC} $*"; }
warn() { echo -e "${YELLOW}[warn]${NC} $*"; }
fail() { echo -e "${RED}[FAIL]${NC} $*"; }
# ── Helpers ───────────────────────────────────────────────────────────
ensure_dirs() {
mkdir -p "$LOG_DIR" "$JUPYTER_CONFIG_DIR" "$JUPYTER_DATA_DIR" \
"$JUPYTER_RUNTIME_DIR" "$IPYTHONDIR" "$MPLCONFIGDIR"
}
set_jupyter_env() {
export JUPYTER_CONFIG_DIR
export JUPYTER_DATA_DIR
export JUPYTER_RUNTIME_DIR
export IPYTHONDIR
export MPLCONFIGDIR
}
# Check if a PID is alive
_pid_alive() {
local pid="$1"
kill -0 "$pid" 2>/dev/null
}
# Get the PID from our PID file, if any and alive
_get_running_pid() {
if [[ -f "$PID_FILE" ]]; then
local pid
pid=$(cat "$PID_FILE")
if [[ -n "$pid" ]] && _pid_alive "$pid"; then
echo "$pid"
return 0
fi
fi
return 1
}
# Find server URL from runtime JSON files (the Jupyter-native way)
_server_url() {
"$PYTHON" -c '
import json, sys
from pathlib import Path
runtime = Path(sys.argv[1])
root = Path(sys.argv[2]).resolve()
best = None
for path in sorted(runtime.glob("jpserver-*.json"), key=lambda p: p.stat().st_mtime, reverse=True):
try:
data = json.loads(path.read_text())
except Exception:
continue
pid = data.get("pid", -1)
try:
import os
os.kill(pid, 0)
except (ProcessLookupError, PermissionError, TypeError):
continue
url = data.get("url", "").rstrip("/")
token = data.get("token", "")
entry = "/lab/tree/00_START_HERE.ipynb"
if token:
print(f"{url}{entry}?token={token}")
else:
print(f"{url}{entry}")
sys.exit(0)
sys.exit(1)
' "$JUPYTER_RUNTIME_DIR" "$PROJECT_ROOT" 2>/dev/null
}
# Clean up stale runtime JSON files (process no longer running)
_cleanup_stale_runtime() {
"$PYTHON" -c '
import json, os
from pathlib import Path
runtime = Path("'"$JUPYTER_RUNTIME_DIR"'")
for path in runtime.glob("jpserver-*.json"):
try:
data = json.loads(path.read_text())
pid = int(data.get("pid", -1))
os.kill(pid, 0)
except (ProcessLookupError, ValueError, KeyError, json.JSONDecodeError):
path.unlink(missing_ok=True)
html = path.with_name(f"{path.stem}-open.html")
html.unlink(missing_ok=True)
except PermissionError:
pass # process exists but owned by another user
' 2>/dev/null || true
}
# ── Bootstrap ─────────────────────────────────────────────────────────
cmd_bootstrap() {
info "Bootstrapping autoresearch-quantum..."
# Python version check
local py_cmd
for candidate in python3.12 python3.11 python3; do
if command -v "$candidate" &>/dev/null; then
py_cmd="$candidate"
break
fi
done
if [[ -z "${py_cmd:-}" ]]; then
fail "Python 3.11+ not found. Install Python first."
exit 1
fi
local py_version
py_version=$("$py_cmd" -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')")
local py_major py_minor
py_major=$(echo "$py_version" | cut -d. -f1)
py_minor=$(echo "$py_version" | cut -d. -f2)
if (( py_major < 3 || py_minor < 11 )); then
fail "Python >= 3.11 required (found $py_version)"
exit 1
fi
ok "Python $py_version ($py_cmd)"
# Create venv
if [[ ! -d "$VENV_DIR" ]]; then
info "Creating virtual environment..."
"$py_cmd" -m venv "$VENV_DIR"
ok "Virtual environment created"
else
ok "Virtual environment exists"
fi
# Install package
info "Installing autoresearch-quantum + dependencies..."
"$PYTHON" -m pip install --upgrade pip --quiet
"$PYTHON" -m pip install -e "$PROJECT_ROOT[dev,notebooks]" --quiet
ok "Package installed"
# Create isolated Jupyter directories
ensure_dirs
# Install Jupyter kernel (into the venv, not user-global)
set_jupyter_env
"$PYTHON" -m ipykernel install \
--sys-prefix \
--name autoresearch-quantum \
--display-name "Autoresearch Quantum" \
--env IPYTHONDIR "$IPYTHONDIR" \
--env MPLCONFIGDIR "$MPLCONFIGDIR" \
2>/dev/null || true
ok "Jupyter kernel registered (venv-local)"
# Verify imports
if "$PYTHON" -c "from autoresearch_quantum.models import ExperimentSpec; print('Import OK')" &>/dev/null; then
ok "Import verification passed"
else
fail "Import verification failed — check installation"
exit 1
fi
echo ""
ok "${BOLD}Bootstrap complete!${NC}"
echo ""
echo " Next steps:"
echo " bash scripts/app.sh start # Launch JupyterLab"
echo " bash scripts/app.sh validate # Run validation suite"
}
# ── Start ─────────────────────────────────────────────────────────────
cmd_start() {
local open_browser=true
local foreground=false
local requested_port=""
while [[ $# -gt 0 ]]; do
case "$1" in
--no-open) open_browser=false; shift ;;
--foreground) foreground=true; shift ;;
--port)
[[ $# -ge 2 ]] || { fail "--port requires a value"; exit 1; }
requested_port="$2"; shift 2 ;;
*) fail "Unknown start option: $1"; exit 1 ;;
esac
done
if [[ ! -f "$PYTHON" ]]; then
fail "Not bootstrapped. Run: bash scripts/app.sh bootstrap"
exit 1
fi
ensure_dirs
set_jupyter_env
_cleanup_stale_runtime
# Check if already running
local existing_pid
if existing_pid=$(_get_running_pid); then
local url
url=$(_server_url || echo "http://localhost:8888/lab/tree/00_START_HERE.ipynb")
warn "JupyterLab already running (PID $existing_pid)"
echo " $url"
if $open_browser; then
if command -v open &>/dev/null; then
open "$url"
elif command -v xdg-open &>/dev/null; then
xdg-open "$url"
fi
fi
return 0
fi
# Find free port
local port="${requested_port:-8888}"
if [[ -z "$requested_port" ]]; then
while lsof -i :"$port" &>/dev/null; do
port=$((port + 1))
if (( port > 8899 )); then
fail "No free port in range 88888899"
fail "Check for orphan Jupyter processes: lsof -i :8888"
exit 1
fi
done
fi
# Foreground mode — Ctrl-C stops the server cleanly
if $foreground; then
info "Starting JupyterLab on port $port (foreground, Ctrl-C to stop)..."
local url="http://localhost:$port/lab/tree/00_START_HERE.ipynb"
echo ""
echo " ${BOLD}$url${NC}"
echo ""
if $open_browser; then
# Open browser after a short delay (in background)
(sleep 3 && {
if command -v open &>/dev/null; then
open "$url"
elif command -v xdg-open &>/dev/null; then
xdg-open "$url"
fi
}) &
fi
# exec replaces this shell — Ctrl-C goes straight to Jupyter
exec "$JUPYTER" lab \
--port="$port" \
--no-browser \
--ip=127.0.0.1 \
--notebook-dir="$PROJECT_ROOT/notebooks"
fi
# Background mode — PID tracked, survives shell close
info "Starting JupyterLab on port $port..."
cd "$PROJECT_ROOT"
: > "$LOG_FILE"
nohup "$JUPYTER" lab \
--port="$port" \
--no-browser \
--ip=127.0.0.1 \
--notebook-dir="$PROJECT_ROOT/notebooks" \
--ServerApp.token='' \
--ServerApp.password='' \
> "$LOG_FILE" 2>&1 &
local pid=$!
echo "$pid" > "$PID_FILE"
# Wait for server to start (check both port and runtime JSON)
local tries=0
while ! curl -s "http://localhost:$port/api" &>/dev/null; do
sleep 0.5
tries=$((tries + 1))
if (( tries > 40 )); then
fail "JupyterLab failed to start. Check: cat $LOG_FILE"
exit 1
fi
# Check if the process died
if ! _pid_alive "$pid"; then
fail "JupyterLab exited during startup. Recent log output:"
tail -n 20 "$LOG_FILE" >&2 || true
rm -f "$PID_FILE"
exit 1
fi
done
local url="http://localhost:$port/lab/tree/00_START_HERE.ipynb"
ok "JupyterLab running (PID $pid)"
echo ""
echo " ${BOLD}$url${NC}"
echo ""
if $open_browser; then
if command -v open &>/dev/null; then
open "$url"
elif command -v xdg-open &>/dev/null; then
xdg-open "$url"
fi
fi
}
# ── Stop ──────────────────────────────────────────────────────────────
cmd_stop() {
_cleanup_stale_runtime
if [[ -f "$PID_FILE" ]]; then
local pid
pid=$(cat "$PID_FILE")
if _pid_alive "$pid"; then
kill -TERM "$pid"
# Wait for graceful shutdown (up to 10 seconds)
local i=0
while _pid_alive "$pid" && (( i < 20 )); do
sleep 0.5
i=$((i + 1))
done
if _pid_alive "$pid"; then
warn "Process $pid did not stop gracefully, sending SIGKILL"
kill -KILL "$pid" 2>/dev/null || true
fi
ok "JupyterLab stopped (PID $pid)"
else
warn "PID $pid not running (stale pid file)"
fi
rm -f "$PID_FILE"
else
warn "No PID file — JupyterLab not managed by app.sh"
# Try to find and report any orphan Jupyter processes for this project
local orphans
orphans=$(ps aux | grep "[j]upyter.*--notebook-dir.*$PROJECT_ROOT" | awk '{print $2}' || true)
if [[ -n "$orphans" ]]; then
warn "Found possible orphan Jupyter process(es): $orphans"
echo " To stop them: kill $orphans"
fi
fi
_cleanup_stale_runtime
}
# ── Restart ───────────────────────────────────────────────────────────
cmd_restart() {
cmd_stop
sleep 1
cmd_start "$@"
}
# ── Status ────────────────────────────────────────────────────────────
cmd_status() {
echo ""
echo " ${BOLD}autoresearch-quantum${NC}"
echo " Root: $PROJECT_ROOT"
echo ""
# Git
if command -v git &>/dev/null && [[ -d "$PROJECT_ROOT/.git" ]]; then
local branch commit
branch=$(cd "$PROJECT_ROOT" && git branch --show-current 2>/dev/null || echo "?")
commit=$(cd "$PROJECT_ROOT" && git rev-parse --short HEAD 2>/dev/null || echo "?")
ok "Git: $branch @ $commit"
fi
# Venv
if [[ -f "$PYTHON" ]]; then
local py_ver
py_ver=$("$PYTHON" --version 2>&1)
ok "Virtual environment: $py_ver"
else
fail "Virtual environment: not found"
fi
# JupyterLab
_cleanup_stale_runtime
local running_pid
if running_pid=$(_get_running_pid); then
local url
url=$(_server_url || echo "(could not determine URL)")
ok "JupyterLab: running (PID $running_pid)"
echo " URL: $url"
echo " Log: $LOG_FILE"
else
warn "JupyterLab: not running"
# Check for orphans
local orphans
orphans=$(ps aux | grep "[j]upyter.*--notebook-dir.*$PROJECT_ROOT" | awk '{print $2}' || true)
if [[ -n "$orphans" ]]; then
warn " Orphan process(es) detected: $orphans"
echo " Stop them: kill $orphans"
fi
fi
# Port scan
local ports_in_use=""
for p in $(seq 8888 8899); do
if lsof -i :"$p" &>/dev/null; then
local owner
owner=$(lsof -ti :"$p" 2>/dev/null | head -1)
ports_in_use="$ports_in_use $p(pid:$owner)"
fi
done
if [[ -n "$ports_in_use" ]]; then
info "Ports in use:$ports_in_use"
fi
# Notebooks
local nb_count
nb_count=$(find "$PROJECT_ROOT/notebooks" -name "*.ipynb" | wc -l | tr -d ' ')
ok "Notebooks: $nb_count found"
# Learner progress
local progress_count
progress_count=$(find "$PROJECT_ROOT" -name "*_progress.json" 2>/dev/null | wc -l | tr -d ' ')
if (( progress_count > 0 )); then
ok "Learner progress files: $progress_count"
else
info "Learner progress files: none (fresh start)"
fi
echo ""
}
# ── Validate ──────────────────────────────────────────────────────────
cmd_validate() {
local mode="${1:---standard}"
if [[ ! -f "$PYTHON" ]]; then
fail "Not bootstrapped. Run: bash scripts/app.sh bootstrap"
exit 1
fi
echo ""
info "${BOLD}Running validation ($mode)...${NC}"
echo ""
local failed=0
# Ruff
info "Ruff lint..."
if "$VENV_DIR/bin/ruff" check src/ tests/ scripts/ --quiet; then
ok "Ruff: clean"
else
fail "Ruff: errors found"
failed=1
fi
# Mypy
info "Mypy type check..."
if "$PYTHON" -m mypy src/autoresearch_quantum/ --no-error-summary 2>/dev/null; then
ok "Mypy: clean"
else
fail "Mypy: type errors found"
failed=1
fi
if [[ "$mode" == "--quick" ]]; then
# Quick: unit tests only (no notebook execution)
info "Unit tests (quick)..."
if "$PYTHON" -m pytest tests/ -k "not test_notebook_executes and not test_browser" -q --tb=short --no-header 2>&1; then
ok "Unit tests: passed"
else
fail "Unit tests: failures"
failed=1
fi
else
# Standard: all tests except browser UX
info "Full test suite..."
if "$PYTHON" -m pytest tests/ -k "not test_browser" -v --tb=short --no-header 2>&1; then
ok "Tests: passed"
else
fail "Tests: failures"
failed=1
fi
fi
echo ""
if (( failed == 0 )); then
ok "${BOLD}All validation checks passed.${NC}"
else
fail "${BOLD}Some checks failed — see above.${NC}"
exit 1
fi
}
# ── Logs ──────────────────────────────────────────────────────────────
cmd_logs() {
if [[ -f "$LOG_FILE" ]]; then
if [[ "${1:-}" == "-f" || "${1:-}" == "--follow" ]]; then
tail -f "$LOG_FILE"
else
tail -n 80 "$LOG_FILE"
fi
else
warn "No log file found. Start JupyterLab first."
fi
}
# ── Reset (learner progress) ─────────────────────────────────────────
cmd_reset() {
info "Resetting learner progress..."
local count=0
while IFS= read -r -d '' f; do
rm "$f"
count=$((count + 1))
done < <(find "$PROJECT_ROOT" -name "*_progress.json" -print0 2>/dev/null)
ok "Removed $count progress file(s)"
info "Notebook outputs are preserved (use nbstripout to clear them)"
}
# ── Reset State (Jupyter runtime + UI state) ─────────────────────────
cmd_reset_state() {
# Stop server first if running
local running_pid
if running_pid=$(_get_running_pid 2>/dev/null); then
info "Stopping running server first..."
cmd_stop
fi
info "Cleaning Jupyter runtime state..."
local count=0
# Runtime JSON files
for f in "$JUPYTER_RUNTIME_DIR"/jpserver-*.json "$JUPYTER_RUNTIME_DIR"/jpserver-*-open.html \
"$JUPYTER_RUNTIME_DIR"/kernel-*.json; do
if [[ -f "$f" ]]; then
rm "$f"
count=$((count + 1))
fi
done
# Saved UI state (workspaces, trust DB)
for f in "$JUPYTER_DATA_DIR/nbsignatures.db" "$JUPYTER_DATA_DIR/notebook_secret"; do
if [[ -f "$f" ]]; then
rm "$f"
count=$((count + 1))
fi
done
if [[ -d "$JUPYTER_DATA_DIR/lab/workspaces" ]]; then
local ws_count
ws_count=$(find "$JUPYTER_DATA_DIR/lab/workspaces" -name "*.jupyterlab-workspace" 2>/dev/null | wc -l | tr -d ' ')
if (( ws_count > 0 )); then
find "$JUPYTER_DATA_DIR/lab/workspaces" -name "*.jupyterlab-workspace" -delete
count=$((count + ws_count))
fi
fi
# PID file
rm -f "$PID_FILE"
ok "Removed $count runtime/state file(s)"
info "JupyterLab will start fresh on next launch"
}
# ── Main dispatch ─────────────────────────────────────────────────────
case "${1:-help}" in
bootstrap) cmd_bootstrap ;;
start) shift; cmd_start "$@" ;;
stop) cmd_stop ;;
restart) shift; cmd_restart "$@" ;;
status) cmd_status ;;
validate) cmd_validate "${2:-}" ;;
logs) cmd_logs "${2:-}" ;;
reset) cmd_reset ;;
reset-state) cmd_reset_state ;;
help|--help|-h)
echo ""
echo " ${BOLD}autoresearch-quantum${NC} — lifecycle manager"
echo ""
echo " Usage: bash scripts/app.sh <command>"
echo ""
echo " Commands:"
echo " bootstrap Create venv, install deps, verify imports"
echo " start [options] Launch JupyterLab"
echo " --no-open Don't open browser"
echo " --foreground Run in foreground (Ctrl-C to stop)"
echo " --port PORT Use a specific port"
echo " stop Stop JupyterLab"
echo " restart [options] Stop + start (same options as start)"
echo " status Show service and project status"
echo " validate [--quick] Run lint, type check, and tests"
echo " logs [-f] Show or follow JupyterLab output"
echo " reset Delete learner progress files"
echo " reset-state Reset Jupyter runtime + UI state"
echo ""
;;
*)
fail "Unknown command: $1"
echo " Run 'bash scripts/app.sh help' for usage."
exit 1
;;
esac