QuantumLearning/scripts/app.sh

704 lines
17 KiB
Bash
Executable file

#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
LOG_DIR="$ROOT_DIR/.logs"
LOG_FILE="$LOG_DIR/jupyterlab.log"
usage() {
cat <<'EOF'
Usage: bash ./scripts/app.sh <command> [options]
Consumer-facing control surface for QuantumLearning.
Commands:
bootstrap [--skip-browser] [--run-validation]
Create or refresh the repo-local environment.
start [--port PORT] [--open] [--foreground] [--restart]
Start the repo-local Jupyter server.
Default mode is background service mode.
stop
Stop the repo-local Jupyter server for this project.
reset-state
Remove repo-local Jupyter runtime and saved UI state files.
restart [--port PORT] [--open] [--foreground]
Restart the repo-local Jupyter server.
status
Print the current project and Jupyter status.
url
Print the current Jupyter URL for this project.
open
Open the current Jupyter URL in the default browser.
logs [-f|--follow]
Show the Jupyter log file. Use -f to follow live output.
validate [--quick|--standard|--full]
Run the validation suite.
Examples:
bash ./scripts/app.sh bootstrap
bash ./scripts/app.sh start --open
bash ./scripts/app.sh restart
bash ./scripts/app.sh status
bash ./scripts/app.sh stop
bash ./scripts/app.sh reset-state
bash ./scripts/app.sh validate --quick
EOF
}
die() {
echo "$*" >&2
exit 1
}
ensure_dirs() {
mkdir -p \
"$ROOT_DIR/.cache/matplotlib" \
"$ROOT_DIR/.ipython" \
"$ROOT_DIR/.jupyter_config" \
"$ROOT_DIR/.jupyter_data" \
"$ROOT_DIR/.jupyter_runtime" \
"$LOG_DIR"
}
ensure_venv() {
if [[ ! -x "$ROOT_DIR/.venv/bin/python" ]]; then
die "Missing repo-local .venv. Run: bash ./scripts/app.sh bootstrap"
fi
}
set_jupyter_env() {
export PATH="$ROOT_DIR/.venv/bin:/usr/bin:/bin:/usr/sbin:/sbin"
export JUPYTER_CONFIG_DIR="$ROOT_DIR/.jupyter_config"
export JUPYTER_DATA_DIR="$ROOT_DIR/.jupyter_data"
export JUPYTER_RUNTIME_DIR="$ROOT_DIR/.jupyter_runtime"
export IPYTHONDIR="$ROOT_DIR/.ipython"
export MPLCONFIGDIR="$ROOT_DIR/.cache/matplotlib"
}
ensure_kernel() {
local kernel_dir="$ROOT_DIR/.venv/share/jupyter/kernels/quantum-learning"
if [[ ! -f "$kernel_dir/kernel.json" ]]; then
"$ROOT_DIR/.venv/bin/python" -m ipykernel install \
--sys-prefix \
--name quantum-learning \
--display-name "QuantumLearning (.venv)" \
--env IPYTHONDIR "$IPYTHONDIR" \
--env MPLCONFIGDIR "$MPLCONFIGDIR"
fi
}
server_json_for_root() {
"$ROOT_DIR/.venv/bin/python" - <<'PY' "$ROOT_DIR"
from __future__ import annotations
import json
import sys
from pathlib import Path
root = Path(sys.argv[1]).resolve()
runtime_dir = root / ".jupyter_runtime"
matches = []
for path in runtime_dir.glob("jpserver-*.json"):
try:
payload = json.loads(path.read_text())
except Exception:
continue
if Path(payload.get("root_dir", "")).resolve() == root:
matches.append((path.stat().st_mtime, path))
if matches:
print(max(matches)[1])
PY
}
server_json_for_pid() {
local pid="$1"
"$ROOT_DIR/.venv/bin/python" - <<'PY' "$ROOT_DIR" "$pid"
from __future__ import annotations
import json
import sys
from pathlib import Path
root = Path(sys.argv[1]).resolve()
target_pid = int(sys.argv[2])
runtime_dir = root / ".jupyter_runtime"
matches = []
for path in runtime_dir.glob("jpserver-*.json"):
try:
payload = json.loads(path.read_text())
except Exception:
continue
if Path(payload.get("root_dir", "")).resolve() == root and int(payload.get("pid", -1)) == target_pid:
matches.append((path.stat().st_mtime, path))
if matches:
print(max(matches)[1])
PY
}
server_field() {
local json_path="$1"
local field_name="$2"
"$ROOT_DIR/.venv/bin/python" - <<'PY' "$json_path" "$field_name"
from __future__ import annotations
import json
import sys
from pathlib import Path
path = Path(sys.argv[1])
field_name = sys.argv[2]
payload = json.loads(path.read_text())
value = payload[field_name]
print(value)
PY
}
server_url_from_json() {
local json_path="$1"
"$ROOT_DIR/.venv/bin/python" - <<'PY' "$json_path"
from __future__ import annotations
import json
import sys
from pathlib import Path
path = Path(sys.argv[1])
payload = json.loads(path.read_text())
print(f"{payload['url']}lab/tree/notebooks/START_HERE.ipynb?token={payload['token']}")
PY
}
cleanup_stale_runtime_files() {
"$ROOT_DIR/.venv/bin/python" - <<'PY' "$ROOT_DIR" "${1:-0}"
from __future__ import annotations
import json
import os
import sys
from pathlib import Path
root = Path(sys.argv[1]).resolve()
verbose = sys.argv[2] == "1"
runtime_dir = root / ".jupyter_runtime"
removed = []
for path in sorted(runtime_dir.glob("jpserver-*.json")):
try:
payload = json.loads(path.read_text())
except Exception:
continue
if Path(payload.get("root_dir", "")).resolve() != root:
continue
pid = int(payload.get("pid", -1))
alive = False
if pid > 0:
try:
os.kill(pid, 0)
except ProcessLookupError:
alive = False
except PermissionError:
alive = True
else:
alive = True
if alive:
continue
for candidate in (path, path.with_name(f"{path.stem}-open.html")):
if candidate.exists():
candidate.unlink()
removed.append(candidate)
if verbose:
for path in removed:
print(path)
PY
}
purge_runtime_artifacts() {
"$ROOT_DIR/.venv/bin/python" - <<'PY' "$ROOT_DIR" "${1:-0}"
from __future__ import annotations
import sys
from pathlib import Path
root = Path(sys.argv[1]).resolve()
verbose = sys.argv[2] == "1"
runtime_dir = root / ".jupyter_runtime"
patterns = (
"jpserver-*.json",
"jpserver-*-open.html",
"nbserver-*.json",
"nbserver-*-open.html",
"kernel-*.json",
)
removed = []
for pattern in patterns:
for path in sorted(runtime_dir.glob(pattern)):
if path.exists():
path.unlink()
removed.append(path)
if verbose:
for path in removed:
print(path)
PY
}
purge_saved_ui_state() {
"$ROOT_DIR/.venv/bin/python" - <<'PY' "$ROOT_DIR" "${1:-0}"
from __future__ import annotations
import sys
from pathlib import Path
root = Path(sys.argv[1]).resolve()
verbose = sys.argv[2] == "1"
data_dir = root / ".jupyter_data"
paths = [
data_dir / "nbsignatures.db",
data_dir / "notebook_secret",
]
workspace_dir = data_dir / "lab" / "workspaces"
removed = []
for path in paths:
if path.exists():
path.unlink()
removed.append(path)
for path in sorted(workspace_dir.glob("*.jupyterlab-workspace")):
if path.exists():
path.unlink()
removed.append(path)
if verbose:
for path in removed:
print(path)
PY
}
print_ui_state_summary() {
local workspace_count="0"
if compgen -G "$ROOT_DIR/.jupyter_data/lab/workspaces/*.jupyterlab-workspace" >/dev/null; then
workspace_count="$(find "$ROOT_DIR/.jupyter_data/lab/workspaces" -maxdepth 1 -name '*.jupyterlab-workspace' | wc -l | tr -d ' ')"
fi
local trust_db="absent"
local notebook_secret="absent"
[[ -f "$ROOT_DIR/.jupyter_data/nbsignatures.db" ]] && trust_db="present"
[[ -f "$ROOT_DIR/.jupyter_data/notebook_secret" ]] && notebook_secret="present"
if [[ "$workspace_count" == "0" && "$trust_db" == "absent" && "$notebook_secret" == "absent" ]]; then
echo "Jupyter UI state: clean"
return 0
fi
echo "Jupyter UI state: workspaces=$workspace_count, trust-db=$trust_db, notebook-secret=$notebook_secret"
echo " Clean slate: bash ./scripts/app.sh reset-state"
}
have_running_server() {
local json_path
cleanup_stale_runtime_files >/dev/null
json_path="$(server_json_for_root || true)"
if [[ -z "$json_path" ]]; then
return 1
fi
local pid
pid="$(server_field "$json_path" "pid")"
kill -0 "$pid" >/dev/null 2>&1
}
print_running_server() {
local json_path url pid
cleanup_stale_runtime_files >/dev/null
json_path="$(server_json_for_root || true)"
if [[ -z "$json_path" ]]; then
die "No repo-local Jupyter runtime file found. Run: bash ./scripts/app.sh start"
fi
url="$(server_url_from_json "$json_path")"
pid="$(server_field "$json_path" "pid")"
echo "Jupyter URL: $url"
echo "PID: $pid"
echo "Runtime file: $json_path"
echo "Log file: $LOG_FILE"
}
stop_server() {
ensure_venv
ensure_dirs
local json_path pid removed_paths
cleanup_stale_runtime_files >/dev/null
json_path="$(server_json_for_root || true)"
if [[ -z "$json_path" ]]; then
echo "No repo-local Jupyter runtime file found."
removed_paths="$(purge_runtime_artifacts 1 || true)"
if [[ -n "$removed_paths" ]]; then
echo "Removed stale runtime files:"
echo "$removed_paths"
fi
return 0
fi
pid="$(server_field "$json_path" "pid")"
if ! kill -0 "$pid" >/dev/null 2>&1; then
removed_paths="$(cleanup_stale_runtime_files 1 || true)"
local extra_removed
extra_removed="$(purge_runtime_artifacts 1 || true)"
echo "Jupyter runtime file exists, but the process is not running."
echo "Runtime file: $json_path"
if [[ -n "$removed_paths" ]]; then
echo "Removed stale runtime files:"
echo "$removed_paths"
fi
if [[ -n "$extra_removed" ]]; then
echo "Removed leftover runtime artifacts:"
echo "$extra_removed"
fi
return 0
fi
kill -TERM "$pid"
for _ in $(seq 1 30); do
if ! kill -0 "$pid" >/dev/null 2>&1; then
cleanup_stale_runtime_files >/dev/null
purge_runtime_artifacts >/dev/null
echo "Stopped Jupyter (pid $pid)."
return 0
fi
sleep 1
done
die "Jupyter did not stop cleanly after 30 seconds. Inspect: $LOG_FILE"
}
start_server() {
local port="$1"
local foreground="$2"
local open_browser="$3"
local restart="$4"
ensure_venv
ensure_dirs
set_jupyter_env
ensure_kernel
cleanup_stale_runtime_files >/dev/null
if have_running_server; then
if [[ "$restart" == "1" ]]; then
stop_server
else
echo "Jupyter is already running for this project."
print_running_server
if [[ "$open_browser" == "1" ]]; then
local existing_url
existing_url="$(server_url_from_json "$(server_json_for_root)")"
open "$existing_url" >/dev/null 2>&1 || true
fi
return 0
fi
fi
purge_runtime_artifacts >/dev/null
if [[ "$foreground" == "1" ]]; then
exec "$ROOT_DIR/.venv/bin/jupyter" lab \
--no-browser \
--ip=127.0.0.1 \
--port="$port"
fi
: > "$LOG_FILE"
nohup "$ROOT_DIR/.venv/bin/jupyter" lab \
--no-browser \
--ip=127.0.0.1 \
--port="$port" \
< /dev/null \
>>"$LOG_FILE" 2>&1 &
local launch_pid=$!
local json_path=""
for _ in $(seq 1 60); do
if ! kill -0 "$launch_pid" >/dev/null 2>&1; then
echo "Jupyter exited during startup. Recent log output:" >&2
tail -n 40 "$LOG_FILE" >&2 || true
exit 1
fi
json_path="$(server_json_for_pid "$launch_pid" || true)"
if [[ -n "$json_path" ]]; then
local url
url="$(server_url_from_json "$json_path")"
echo "Jupyter started."
echo "URL: $url"
echo "PID: $launch_pid"
echo "Log: $LOG_FILE"
if [[ "$open_browser" == "1" ]]; then
open "$url" >/dev/null 2>&1 || true
fi
return 0
fi
sleep 1
done
die "Timed out waiting for Jupyter startup. Inspect: $LOG_FILE"
}
show_logs() {
ensure_dirs
if [[ ! -f "$LOG_FILE" ]]; then
echo "No Jupyter log file exists yet."
return 0
fi
if [[ "${1:-}" == "--follow" || "${1:-}" == "-f" ]]; then
tail -f "$LOG_FILE"
else
tail -n 80 "$LOG_FILE"
fi
}
validate_command() {
bash "$ROOT_DIR/scripts/run_validation.sh" "$@"
}
reset_state_command() {
ensure_venv
ensure_dirs
if have_running_server; then
stop_server >/dev/null
else
cleanup_stale_runtime_files >/dev/null
purge_runtime_artifacts >/dev/null
fi
local removed_runtime removed_ui
removed_runtime="$(purge_runtime_artifacts 1 || true)"
removed_ui="$(purge_saved_ui_state 1 || true)"
if [[ -z "$removed_runtime" && -z "$removed_ui" ]]; then
echo "Jupyter state was already clean."
return 0
fi
if [[ -n "$removed_runtime" ]]; then
echo "Removed runtime artifacts:"
echo "$removed_runtime"
fi
if [[ -n "$removed_ui" ]]; then
echo "Removed saved UI state:"
echo "$removed_ui"
fi
}
status_command() {
cd "$ROOT_DIR"
ensure_dirs
local branch commit git_state
branch="$(git branch --show-current)"
commit="$(git rev-parse --short HEAD)"
if [[ -n "$(git status --short)" ]]; then
git_state="dirty"
else
git_state="clean"
fi
echo "Root: $ROOT_DIR"
echo "Git: $branch @ $commit ($git_state)"
if [[ -x "$ROOT_DIR/.venv/bin/python" ]]; then
local python_version removed_runtime json_path pid url
python_version="$("$ROOT_DIR/.venv/bin/python" -c 'import sys; print(sys.version.split()[0])')"
echo "Venv: present (Python $python_version)"
cleanup_stale_runtime_files >/dev/null
if ! have_running_server; then
removed_runtime="$(purge_runtime_artifacts 1 || true)"
else
removed_runtime=""
fi
json_path="$(server_json_for_root || true)"
if compgen -G "$ROOT_DIR/.playwright-browsers/chromium-*" >/dev/null; then
echo "Browser runtime: installed"
else
echo "Browser runtime: missing"
fi
if [[ -f "$LOG_FILE" ]]; then
echo "Jupyter log: $LOG_FILE"
else
echo "Jupyter log: none yet"
fi
if [[ -n "$json_path" ]]; then
pid="$(server_field "$json_path" "pid")"
url="$(server_url_from_json "$json_path")"
if kill -0 "$pid" >/dev/null 2>&1; then
echo "Jupyter: running (pid $pid)"
echo " URL: $url"
echo " Runtime file: $json_path"
else
echo "Jupyter: runtime file exists but process is not running"
echo " Runtime file: $json_path"
fi
else
echo "Jupyter: no repo-local runtime file found"
if [[ -n "${removed_runtime:-}" ]]; then
echo " Purged stale runtime artifacts:"
echo "$removed_runtime"
fi
fi
print_ui_state_summary
else
echo "Venv: missing"
if compgen -G "$ROOT_DIR/.playwright-browsers/chromium-*" >/dev/null; then
echo "Browser runtime: installed"
else
echo "Browser runtime: missing"
fi
if [[ -f "$LOG_FILE" ]]; then
echo "Jupyter log: $LOG_FILE"
else
echo "Jupyter log: none yet"
fi
echo "Jupyter: repo-local environment is missing"
print_ui_state_summary
fi
echo "Consumer:"
echo " Start: bash ./scripts/app.sh start --open"
echo " Stop: bash ./scripts/app.sh stop"
echo " Restart: bash ./scripts/app.sh restart"
echo " Reset: bash ./scripts/app.sh reset-state"
echo " Logs: bash ./scripts/app.sh logs -f"
echo "Validation:"
echo " Quick: bash ./scripts/app.sh validate --quick"
echo " Full: bash ./scripts/app.sh validate --full"
}
start_command() {
local port="8888"
local foreground="0"
local open_browser="0"
local restart="0"
while [[ $# -gt 0 ]]; do
case "$1" in
--port)
[[ $# -ge 2 ]] || die "--port requires a value"
port="$2"
shift 2
;;
--open)
open_browser="1"
shift
;;
--foreground)
foreground="1"
shift
;;
--restart)
restart="1"
shift
;;
-h|--help)
cat <<'EOF'
Usage: bash ./scripts/app.sh start [--port PORT] [--open] [--foreground] [--restart]
EOF
return 0
;;
*)
die "Unknown start option: $1"
;;
esac
done
[[ "$port" =~ ^[0-9]+$ ]] || die "Port must be an integer, got: $port"
start_server "$port" "$foreground" "$open_browser" "$restart"
}
restart_command() {
start_command --restart "$@"
}
if [[ $# -eq 0 ]]; then
usage
exit 1
fi
COMMAND="$1"
shift
case "$COMMAND" in
bootstrap)
exec bash "$ROOT_DIR/scripts/bootstrap_mac.sh" "$@"
;;
start)
start_command "$@"
;;
stop)
stop_server
;;
reset-state)
reset_state_command "$@"
;;
restart)
restart_command "$@"
;;
status)
status_command
;;
_status_internal)
status_command
;;
url)
ensure_venv
cleanup_stale_runtime_files >/dev/null
json_path="$(server_json_for_root || true)"
[[ -n "$json_path" ]] || die "No repo-local Jupyter runtime file found. Run: bash ./scripts/app.sh start"
server_url_from_json "$json_path"
;;
open)
ensure_venv
cleanup_stale_runtime_files >/dev/null
json_path="$(server_json_for_root || true)"
[[ -n "$json_path" ]] || die "No repo-local Jupyter runtime file found. Run: bash ./scripts/app.sh start"
url="$(server_url_from_json "$json_path")"
open "$url"
echo "Opened: $url"
;;
logs)
show_logs "${1:-}"
;;
validate)
validate_command "$@"
;;
-h|--help|help)
usage
;;
*)
usage >&2
die "Unknown command: $COMMAND"
;;
esac