autoresearch-quantum/scripts/app.sh
saymrwulf a16f99e2ed Harden Jupyter lifecycle and enhance E2E browser UX tests
app.sh: full Jupyter env isolation (CONFIG_DIR, DATA_DIR, RUNTIME_DIR,
IPYTHONDIR), auto port allocation, foreground mode, restart command,
graceful stop (SIGTERM → wait → SIGKILL), orphan detection, stale
runtime JSON cleanup, reset-state command.

test_browser_ux.py: add TestNavigationClickThrough (click Plan A link,
verify target opens; click forward-link in Plan D), TestWidgetInteraction
(run all cells, verify output areas, click quiz Submit and verify feedback),
TestProgressPersistence (kernel API session, progress file schema validation).

.gitignore: add Jupyter isolation directories.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 21:20:34 +02:00

630 lines
21 KiB
Bash
Executable file
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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