From de4a34fbc5de769adb0fe11e755b044f13ceea6e Mon Sep 17 00:00:00 2001 From: Sushant Gautam Date: Sat, 17 Oct 2026 00:44:08 +0200 Subject: [PATCH] Add ownership-safe dev/spin lifecycle manager Adds simpleaudit_studio/lifecycle.py so agents can safely start, reuse, stop, restart, reset and uninstall a local stack without killing unrelated processes or resetting real data. - Instance identity: sha256 of canonical checkout path (dev) or data dir (spin); file locks (owner.lock non-blocking, ports.lock blocking). - Port allocation before Django starts; worktree baseline 8100. - _live() validates PID running + create_time + UID to reject PID reuse. - Destructive boundaries: refuses custom SIMPLEAUDIT_DATA_DIR / SIMPLEAUDIT_SQLITE_PATH on reset/uninstall; uv cache cleanup is package-scoped only (never global `uv cache clean`). - manage.py `dev` and spin CLI dispatch subcommands before django.setup(). - devctl.sh rewritten as a thin compatibility wrapper; all legacy kill -9 / pgrep / lsof port-based cleanup removed. Fix orphaned runserver on shutdown: _stop_web now SIGTERMs the reloader subtree by PID (psutil), never by process group, because the reloader tree shares the owner's group with Caddy/Open WebUI which must keep running. Verified live: healthz 200, Django template autoreload works without restart, desktop + mobile render correctly, spin agent-info/status are read-only, and `dev down` leaves no orphaned processes. Docs: docs/development-lifecycle.md, AGENTS.md, README.md. Tests: tests/test_lifecycle.py (10 network-free tests). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- AGENTS.md | 16 + README.md | 9 + devctl.sh | 115 +-- .../development-lifecycle.md | 0 infra/management/commands/dev.py | 1 + infra/management/commands/dev_server.py | 38 +- manage.py | 27 +- ...mpleAuditStudio_agent_verification_seed.md | 72 -- .../simpleaudit_agent_safe_lifecycle.patch | 752 ------------------ simpleaudit_studio/cli.py | 19 +- .../lifecycle.py | 17 +- .../test_lifecycle.py | 0 12 files changed, 123 insertions(+), 943 deletions(-) mode change 100755 => 100644 devctl.sh rename {simpleaudit_patch_delivery => docs}/development-lifecycle.md (100%) delete mode 100644 simpleaudit_patch_delivery/SimpleAuditStudio_agent_verification_seed.md delete mode 100644 simpleaudit_patch_delivery/simpleaudit_agent_safe_lifecycle.patch rename {simpleaudit_patch_delivery => simpleaudit_studio}/lifecycle.py (97%) rename {simpleaudit_patch_delivery => tests}/test_lifecycle.py (100%) diff --git a/AGENTS.md b/AGENTS.md index e2769d53..4b5c8cc7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,21 @@ # AGENTS.md +## First action in any coding-agent session + +Run `uv run manage.py dev agent-info` from the checkout. It describes the +supported local lifecycle commands, ownership, automatic port allocation, +worktree isolation, cleanup and restart rules. Run `uv run manage.py dev status` +before launching services. `uv run manage.py dev` is idempotent: if the current +worktree already has a live owner, it reports the URL and exits. + +**Never use `pkill`, `kill -9`, `lsof -ti ... | xargs kill`, or the old `devctl.sh` +port-clearing logic.** Ordinary Django Python edits use runserver autoreload; +template edits require only a browser refresh. Hatchet worker Python code does +not automatically reload in the current architecture; coordinate a full +`dev restart` only when no audit must be interrupted. Do not point another +worktree's Python at the main checkout server and claim that branch's code +was tested. `reset`/`uninstall` destroy data and require explicit permission. + SimpleAudit Studio — an audit platform for LLM systems. It wraps the external SimpleAudit engine (Target → Auditor → Judge runs over versioned scenario sets) with a Django web UI, a durable worker, and an optional Open WebUI chat module. diff --git a/README.md b/README.md index c7b2d8d5..a19f30a8 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,15 @@ uv run manage.py setup_local uv run manage.py dev ``` +For an existing instance, `dev` reuses it rather than restarting the entire +stack. Run `uv run manage.py dev status` to see the actual port. Run +`uv run manage.py dev agent-info` for detailed agent-safe instructions. +`dev down` stops without data loss, `dev reset` recreates clean local data, +and `dev uninstall` removes owned state and app-scoped uv caches. The same +lifecycle actions are available as `uvx --from simpleaudit-studio spin ACTION`. +See [development lifecycle](docs/development-lifecycle.md) for isolation and +safety details. + The app opens at with live reload. Use `--no-browser` to skip opening a browser. ## What it does diff --git a/devctl.sh b/devctl.sh old mode 100755 new mode 100644 index 51438756..14aa7593 --- a/devctl.sh +++ b/devctl.sh @@ -12,107 +12,18 @@ # Ports: Studio :8001, OWUI :8080, Caddy :8000 set -euo pipefail -PORT_STUDIO=8001 -PORT_OWUI=8080 -PORT_CADDY=8000 -PIDFILE="$HOME/.simpleaudit-studio/dev.pid" -LOGFILE="$HOME/.simpleaudit-studio/dev.log" -DB="dev.sqlite3" - -REPO="$(cd "$(dirname "$0")" && pwd)" - -_kill_port() { - local port=$1 - local pids - pids=$(lsof -ti :"$port" 2>/dev/null || true) - if [[ -n "$pids" ]]; then - # shellcheck disable=SC2086 - kill -9 $pids 2>/dev/null || true - fi -} - -stop() { - echo "Stopping stack (ports $PORT_STUDIO $PORT_OWUI $PORT_CADDY)..." - for p in "$PORT_STUDIO" "$PORT_OWUI" "$PORT_CADDY"; do _kill_port "$p"; done - # Also kill any lingering manage.py dev processes - local mpids - mpids=$(pgrep -f "manage.py dev" 2>/dev/null || true) - if [[ -n "$mpids" ]]; then - # shellcheck disable=SC2086 - kill -9 $mpids 2>/dev/null || true - fi - rm -f "$PIDFILE" - sleep 2 - # Verify - local remaining - remaining=$(lsof -ti :$PORT_STUDIO,$PORT_OWUI,$PORT_CADDY 2>/dev/null || true) - if [[ -n "$remaining" ]]; then - echo "WARNING: ports still occupied: $remaining" - else - echo "Stopped." - fi -} - -start() { - if [[ -f "$PIDFILE" ]] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then - echo "Already running (PID $(cat "$PIDFILE")). Use 'restart' to restart." - return 0 - fi - stop # ensure clean slate - - # Clear stale bytecode so code changes are always picked up - find "$REPO" -path "*__pycache__" -type d -exec rm -rf {} + 2>/dev/null || true - - echo "Starting stack (Studio :$PORT_STUDIO, OWUI :$PORT_OWUI, Caddy :$PORT_CADDY)..." - mkdir -p "$(dirname "$LOGFILE")" - - cd "$REPO" - export SIMULACHAT_API_KEY="${SIMULACHAT_API_KEY:-${CUSTOM_SIMULACHAT_KEY:-}}" - SIMPLEAUDIT_LOCAL_SQLITE=1 \ - SIMPLEAUDIT_SQLITE_PATH="$DB" \ - SIMPLEAUDIT_BROWSER_PORT="$PORT_CADDY" \ - nohup uv run manage.py dev --port "$PORT_STUDIO" > "$LOGFILE" 2>&1 & - local pid=$! - echo "$pid" > "$PIDFILE" - echo "Started (PID $pid). Log: $LOGFILE" - - # Wait for health - local i - for i in $(seq 1 40); do - if curl -s -o /dev/null -w "%{http_code}" "http://127.0.0.1:$PORT_STUDIO/healthz" 2>/dev/null | grep -q 200; then - echo "Studio healthy." - local owui - owui=$(curl -s "http://127.0.0.1:$PORT_OWUI/chat/health" 2>/dev/null || echo "") - local caddy - caddy=$(curl -s -o /dev/null -w "%{http_code}" "http://127.0.0.1:$PORT_CADDY/" 2>/dev/null || echo "000") - echo "OWUI: ${owui:-down} Caddy(8000): HTTP $caddy" - return 0 - fi - sleep 2 - done - echo "ERROR: Studio did not become healthy in 80s. Check $LOGFILE" - return 1 -} - -status() { - local h - h=$(curl -s -o /dev/null -w "%{http_code}" "http://127.0.0.1:$PORT_STUDIO/healthz" 2>/dev/null || echo "000") - local o - o=$(curl -s "http://127.0.0.1:$PORT_OWUI/chat/health" 2>/dev/null || echo "down") - local c - c=$(curl -s -o /dev/null -w "%{http_code}" "http://127.0.0.1:$PORT_CADDY/" 2>/dev/null || echo "000") - echo "Studio(:$PORT_STUDIO): HTTP $h" - echo "OWUI(:$PORT_OWUI): $o" - echo "Caddy(:$PORT_CADDY): HTTP $c" - if [[ -f "$PIDFILE" ]]; then - echo "PID file: $(cat "$PIDFILE")" - fi -} - +# Compatibility wrapper delegating to the ownership-safe lifecycle manager. +# The legacy kill-by-port cleanup was removed: no path here may terminate a +# process by port, name, or broad pattern (another agent's worktree included). +_REPO="$(cd "$(dirname "$0")" && pwd)" case "${1:-}" in - start) start ;; - stop) stop ;; - status) status ;; - restart) stop; start ;; - *) echo "Usage: $0 {start|stop|status|restart}"; exit 1 ;; + start) + mkdir -p "$HOME/.local/state/simpleaudit-studio" + (cd "$_REPO" && nohup uv run manage.py dev --no-browser \ + >> "$HOME/.local/state/simpleaudit-studio/devctl.log" 2>&1 None: + """SIGTERM the runserver processes under ``guard_pid`` (by PID, not pgid).""" + import psutil + + try: + guard = psutil.Process(guard_pid) + except psutil.NoSuchProcess: + return + for proc in guard.children(recursive=True): + try: + proc.terminate() + except psutil.NoSuchProcess: + pass def _stop_chat(self): try: diff --git a/manage.py b/manage.py index 97b31cd0..1414a580 100755 --- a/manage.py +++ b/manage.py @@ -27,7 +27,32 @@ def main() -> None: if command == "dev": os.environ.setdefault("SIMPLEAUDIT_MODE", "dev") - single_process = runtime.is_single_process_run(command, argv) + # Handle lifecycle requests before django.setup(), migrations, or any + # imports that lock in chat ports/settings. + from pathlib import Path + + from simpleaudit_studio.lifecycle import ACTIONS, Instance, act, start + + instance = Instance("dev", Path(__file__).resolve().parent) + rest = sys.argv[2:] + action = rest[0] if rest and rest[0] in ACTIONS else None + if act(instance, action, yes="--yes" in rest): + return + if action in {"reset", "restart"}: + sys.argv = [sys.argv[0], "dev"] + [x for x in rest[1:] if x != "--yes"] + rest = sys.argv[2:] + requested = int(os.environ.get("PORT", "8000")) + explicit = False + for index, arg in enumerate(rest): + if arg == "--port" and index + 1 < len(rest): + requested = int(rest[index + 1]) + explicit = True + elif arg.startswith("--port="): + requested = int(arg.split("=", 1)[1]) + explicit = True + if not start(instance, requested_port=requested, explicit_port=explicit): + return + single_process = runtime.is_single_process_run(command, sys.argv) # Zero-Docker modes imply SQLite for the domain DB. Decide before Django # loads settings: a Postgres `.env` would otherwise make settings import # the Postgres driver first. diff --git a/simpleaudit_patch_delivery/SimpleAuditStudio_agent_verification_seed.md b/simpleaudit_patch_delivery/SimpleAuditStudio_agent_verification_seed.md deleted file mode 100644 index d063140e..00000000 --- a/simpleaudit_patch_delivery/SimpleAuditStudio_agent_verification_seed.md +++ /dev/null @@ -1,72 +0,0 @@ -# Seed prompt — SimpleAudit Studio agent-safe lifecycle verification - -You are the lead coding and verification agent for `SimulaMet/SimpleAuditStudio`. -Read `AGENTS.md`, then examine the current `main` branch and the attached -`simpleaudit_agent_safe_lifecycle.patch`. My ChatGPT session could read the -repo but its GitHub connector returned **HTTP 403 on branch creation**, so -this patch has NOT been pushed. You must use your own authenticated Git checkout -and push a feature branch. - -## Task - -1. Create/switch to `feat/agent-safe-dev-lifecycle` from current `main` (do not - overwrite another person's work). Run `git apply --check - simpleaudit_agent_safe_lifecycle.patch`, review the changes, then apply the - patch. If `main` advanced and the patch needs adjustment, reconcile it - carefully; do not force-apply or overwrite unrelated changes. -2. Review and correct the implementation thoroughly, not just the unit tests. - Keep both CLI surfaces minimal and consistent: - `dev|spin [status|down|restart|reset|uninstall|help|agent-info]`. - Preserve existing startup options. `agent-info` must work without starting - Django, migrating a database, or claiming a port. `dev`/`spin` called twice - must reuse the existing instance instead of restarting/killing it. -3. Worktrees must isolate Caddy's public port, Django internal port, Open WebUI - port/data/managed venv, embedded Hatchet state, Django database, and optional - pipelines. Do not silently connect different branches to one SQLite file. - Verify one normal checkout plus TWO Git worktrees can run concurrently. -4. Validate all lifecycle transitions and recovery paths (normal exit, - repeated start, SIGTERM, PID reuse, child crash, stale pid files, port - takeover, same-port race, incompatible migrations, restarts). No command may - kill an unrelated process or another worktree. Rewrite `devctl.sh` cleanly - to remove all dead legacy kill-by-port code; do not merely bypass it. -5. Reload model: confirm Django Python autoreload and template refresh. The - initial patch **does not implement worker-only hot reload**: `dev_server` - currently runs Hatchet worker in the owning parent. Implement isolated - worker reload only if it is safe with the SDK's embedded handshake and - running audits, else explicitly report the limitation and propose a - separate phase. Never promise independent worker restart while killing the - entire stack instead. -6. For `reset` and `uninstall`, test only with throwaway state/data and mock - uv executables. Confirm custom data locations, external DBs, symlinks, - unrelated tools, shared caches and source trees cannot be deleted. - `uninstall` should clean only Studio-related uv wheel cache when no other - Studio instance needs it. Document that uvx temporary environments may - survive package-specific `uv cache clean`. -7. Run formatting, `uv run ruff check .`, targeted lifecycle tests, existing - relevant CLI tests, `uv run pytest -m 'not embedded_hatchet'`, and serial - embedded-Hatchet tests where practical. Actually start full stack locally - with no real commercial model API calls; inspect health and browser pages. - Test `spin` as an installed wheel/uvx entry point, not only `dev`. -8. Update README, `AGENTS.md`, and developer lifecycle docs. Ensure every - new command has useful `help`, human-readable errors, and --yes only for - authorized destructive operations. -9. Commit the verified changes on `feat/agent-safe-dev-lifecycle`, push to - `origin`, and provide the branch URL and concise test results. Do NOT merge. - -## Mandatory safety checks - -- Never use `pkill`, broad process-name matches, `kill -9`, or global - `uv cache clean` to resolve conflicts. -- Never reset/uninstall real development, remote, shared, or production data. - Use isolated temporary HOME / XDG_STATE_HOME and a throwaway checkout. -- Never run migrations automatically against shared PostgreSQL from parallel - branches. Confirm mode/environment before starting Django. -- Do not pass worker reload as finished unless demonstrated with active jobs. -- Show exact owner PID, worktree identity, ports, and data directory in status. -- A failed integration test must block the push or be clearly labeled as - unresolved if it is environmental; do not claim success based on stubs. - -## Deliverable - -A pushed feature branch with code, tests, user documentation, and a concise -report separating verified behavior from still-unimplemented capabilities. diff --git a/simpleaudit_patch_delivery/simpleaudit_agent_safe_lifecycle.patch b/simpleaudit_patch_delivery/simpleaudit_agent_safe_lifecycle.patch deleted file mode 100644 index 1d333aba..00000000 --- a/simpleaudit_patch_delivery/simpleaudit_agent_safe_lifecycle.patch +++ /dev/null @@ -1,752 +0,0 @@ -diff --git a/manage.py b/manage.py ---- a/manage.py -+++ b/manage.py -@@ -28,4 +28,28 @@ - if command == "dev": - os.environ.setdefault("SIMPLEAUDIT_MODE", "dev") -- single_process = runtime.is_single_process_run(command, argv) -+ # Handle lifecycle requests before django.setup(), migrations, or any -+ # imports that lock in chat ports/settings. -+ from pathlib import Path -+ from simpleaudit_studio.lifecycle import ACTIONS, Instance, act, start -+ -+ instance = Instance("dev", Path(__file__).resolve().parent) -+ rest = sys.argv[2:] -+ action = rest[0] if rest and rest[0] in ACTIONS else None -+ if act(instance, action, yes="--yes" in rest): -+ return -+ if action in {"reset", "restart"}: -+ sys.argv = [sys.argv[0], "dev"] + [x for x in rest[1:] if x != "--yes"] -+ rest = sys.argv[2:] -+ requested = int(os.environ.get("PORT", "8000")) -+ explicit = False -+ for index, arg in enumerate(rest): -+ if arg == "--port" and index + 1 < len(rest): -+ requested = int(rest[index + 1]) -+ explicit = True -+ elif arg.startswith("--port="): -+ requested = int(arg.split("=", 1)[1]) -+ explicit = True -+ if not start(instance, requested_port=requested, explicit_port=explicit): -+ return -+ single_process = runtime.is_single_process_run(command, sys.argv) - # Zero-Docker modes imply SQLite for the domain DB. Decide before Django -diff --git a/infra/management/commands/dev.py b/infra/management/commands/dev.py ---- a/infra/management/commands/dev.py -+++ b/infra/management/commands/dev.py -@@ -75,3 +75,4 @@ - no_reload=options["no_reload"], - no_browser=options["no_browser"], -+ no_force_kill=True, # never evict another agent's stack - ) -diff --git a/infra/management/commands/dev_server.py b/infra/management/commands/dev_server.py ---- a/infra/management/commands/dev_server.py -+++ b/infra/management/commands/dev_server.py -@@ -260,3 +260,3 @@ -- force_kill=not options["no_force_kill"], -+ force_kill=False, # no implicit process termination in development - yes=options["yes"], - command_hint="manage.py dev --port ", -diff --git a/simpleaudit_studio/cli.py b/simpleaudit_studio/cli.py ---- a/simpleaudit_studio/cli.py -+++ b/simpleaudit_studio/cli.py -@@ -20,6 +20,7 @@ - import threading - import time - import webbrowser -+import sys - - - def main() -> None: -diff --git a/simpleaudit_studio/cli.py b/simpleaudit_studio/cli.py ---- a/simpleaudit_studio/cli.py -+++ b/simpleaudit_studio/cli.py -@@ -2,5 +2,9 @@ - prog="simpleaudit-studio", - description="Run SimpleAudit Studio locally (minimal config, no Docker).", - ) - parser.add_argument( -+ "action", nargs="?", choices=["status", "down", "reset", "uninstall", "restart", "help", "agent-info"], -+ help="Optional lifecycle action. Use `spin agent-info` for coding-agent rules.", -+ ) -+ parser.add_argument( - "--port", type=int, default=int(os.environ.get("PORT", "8000")), -diff --git a/simpleaudit_studio/cli.py b/simpleaudit_studio/cli.py ---- a/simpleaudit_studio/cli.py -+++ b/simpleaudit_studio/cli.py -@@ -85,3 +85,13 @@ - args = parser.parse_args() - -+ from simpleaudit_studio.lifecycle import Instance, act, start -+ instance = Instance("spin") -+ if act(instance, args.action, yes=args.yes): -+ return -+ explicit_port = "--port" in sys.argv[1:] or any(a.startswith("--port=") for a in sys.argv[1:]) -+ requested = int(os.environ.get("PORT", str(args.port))) -+ if not start(instance, requested_port=requested, explicit_port=explicit_port): -+ return -+ args.port = int(os.environ["PORT"]) -+ - # Set local mode BEFORE Django reads settings -diff --git a/simpleaudit_studio/cli.py b/simpleaudit_studio/cli.py ---- a/simpleaudit_studio/cli.py -+++ b/simpleaudit_studio/cli.py -@@ -105,2 +105,2 @@ -- adopted = adopt_legacy_database() -+ adopted = None if os.environ.get("SIMPLEAUDIT_SKIP_LEGACY_ADOPTION") else adopt_legacy_database() - if adopted: -diff --git a/simpleaudit_studio/cli.py b/simpleaudit_studio/cli.py ---- a/simpleaudit_studio/cli.py -+++ b/simpleaudit_studio/cli.py -@@ -484,2 +484,2 @@ -- force_kill=not args.no_force_kill, yes=args.yes, -+ force_kill=False, yes=args.yes, - command_hint="spin --port ", -diff --git a/AGENTS.md b/AGENTS.md ---- a/AGENTS.md -+++ b/AGENTS.md -@@ -1,5 +1,21 @@ - # AGENTS.md -+ -+## First action in any coding-agent session -+ -+Run `uv run manage.py dev agent-info` from the checkout. It describes the -+supported local lifecycle commands, ownership, automatic port allocation, -+worktree isolation, cleanup and restart rules. Run `uv run manage.py dev status` -+before launching services. `uv run manage.py dev` is idempotent: if the current -+worktree already has a live owner, it reports the URL and exits. -+ -+**Never use `pkill`, `kill -9`, `lsof -ti ... | xargs kill`, or the old `devctl.sh` -+port-clearing logic.** Ordinary Django Python edits use runserver autoreload; -+template edits require only a browser refresh. Hatchet worker Python code does -+not automatically reload in the current architecture; coordinate a full -+`dev restart` only when no audit must be interrupted. Do not point another -+worktree's Python at the main checkout server and claim that branch's code -+was tested. `reset`/`uninstall` destroy data and require explicit permission. - - SimpleAudit Studio — an audit platform for LLM systems. It wraps the external - SimpleAudit engine (Target → Auditor → Judge runs over versioned scenario sets) - with a Django web UI, a durable worker, and an optional Open WebUI chat module. -diff --git a/README.md b/README.md ---- a/README.md -+++ b/README.md -@@ -26,5 +26,14 @@ - uv run manage.py setup_local - uv run manage.py dev - ``` - -+For an existing instance, `dev` reuses it rather than restarting the entire -+stack. Run `uv run manage.py dev status` to see the actual port. Run -+`uv run manage.py dev agent-info` for detailed agent-safe instructions. -+`dev down` stops without data loss, `dev reset` recreates clean local data, -+and `dev uninstall` removes owned state and app-scoped uv caches. The same -+lifecycle actions are available as `uvx --from simpleaudit-studio spin ACTION`. -+See [development lifecycle](docs/development-lifecycle.md) for isolation and -+safety details. -+ - The app opens at with live reload. -diff --git a/devctl.sh b/devctl.sh ---- a/devctl.sh -+++ b/devctl.sh -@@ -18,3 +18,19 @@ - set -euo pipefail - -+# Compatibility wrapper: all paths exit before the historical kill-by-port -+# implementation below. Never terminate another worktree's services. -+_REPO="$(cd "$(dirname "$0")" && pwd)" -+case "${1:-}" in -+ start) -+ mkdir -p "$HOME/.local/state/simpleaudit-studio" -+ (cd "$_REPO" && nohup uv run manage.py dev --no-browser \ -+ >> "$HOME/.local/state/simpleaudit-studio/devctl.log" 2>&1 Path: -+ return Path(os.environ.get("XDG_STATE_HOME", "~/.local/state")).expanduser() / "simpleaudit-studio" -+ -+ -+def _real(path: Path | str) -> Path: -+ return Path(path).expanduser().resolve() -+ -+ -+class Instance: -+ def __init__(self, mode: str, root: Path | None = None): -+ if mode not in {"dev", "spin"}: -+ raise ValueError("unsupported mode") -+ self.mode = mode -+ self.root = _real(root or Path.cwd()) if mode == "dev" else None -+ self.explicit_data = bool(os.environ.get("SIMPLEAUDIT_DATA_DIR")) -+ identity = str(self.root) if self.root else "spin:" + str(_real(Path(os.environ.get("SIMPLEAUDIT_DATA_DIR", "~/.simpleaudit-studio")))) -+ self.key = mode + "-" + hashlib.sha256(identity.encode()).hexdigest()[:16] -+ self.state = state_root() / "instances" / self.key -+ if self.explicit_data: -+ self.data = _real(Path(os.environ["SIMPLEAUDIT_DATA_DIR"])) -+ elif mode == "spin": -+ self.data = _real(Path("~/.simpleaudit-studio")) -+ else: -+ self.data = state_root() / "data" / self.key -+ if mode == "dev": -+ raw = os.environ.get("SIMPLEAUDIT_SQLITE_PATH", "dev.sqlite3") -+ candidate = Path(raw) -+ self.db = _real(candidate if candidate.is_absolute() else self.root / candidate) -+ self.explicit_db = "SIMPLEAUDIT_SQLITE_PATH" in os.environ and self.db != self.root / "dev.sqlite3" -+ else: -+ self.db = self.data / "studio.sqlite3" -+ self.explicit_db = False -+ -+ @property -+ def record_path(self) -> Path: -+ return self.state / "instance.json" -+ -+ def record(self) -> dict | None: -+ try: -+ value = json.loads(self.record_path.read_text()) -+ return value if value.get("key") == self.key else None -+ except (OSError, ValueError, TypeError): -+ return None -+ -+ -+def _live(record: dict | None) -> bool: -+ if not record: -+ return False -+ try: -+ pid = int(record["pid"]) -+ p = psutil.Process(pid) -+ if not p.is_running() or p.status() == psutil.STATUS_ZOMBIE: -+ return False -+ if abs(p.create_time() - float(record["created"])) >= 1.0: -+ return False -+ if hasattr(os, "getuid") and p.uids().real != os.getuid(): -+ return False -+ return True -+ except (KeyError, TypeError, ValueError, psutil.Error, OSError): -+ return False -+ -+ -+def _health(port: int) -> str: -+ import urllib.request -+ try: -+ with urllib.request.urlopen(f"http://127.0.0.1:{port}/healthz", timeout=1.5) as response: -+ return "healthy" if response.status == 200 else f"HTTP {response.status}" -+ except Exception: -+ return "starting or unhealthy" -+ -+ -+def show(instance: Instance) -> None: -+ record = instance.record() -+ if not _live(record): -+ print(f"SimpleAudit Studio ({instance.mode}): stopped; instance {instance.key}") -+ return -+ ports = record.get("ports", {}) -+ print(f"SimpleAudit Studio ({instance.mode}): {_health(ports.get('public', 8000))}") -+ print(f"Instance: {instance.key} Owner: PID {record['pid']}") -+ print(f"Web: http://localhost:{ports.get('public')} Django: {ports.get('internal')}") -+ print(f"Open WebUI: {ports.get('chat')} Data: {record.get('data')}") -+ -+ -+def _confirm(action: str, instance: Instance, yes: bool) -> None: -+ if yes: -+ return -+ if not sys.stdin.isatty(): -+ raise SystemExit(f"{action} needs confirmation; pass --yes after checking instance {instance.key}") -+ answer = input(f"{action} local {instance.mode} instance {instance.key}? [y/N] ").strip().lower() -+ if answer not in {"y", "yes"}: -+ raise SystemExit("Cancelled.") -+ -+ -+def _stop(instance: Instance) -> None: -+ record = instance.record() -+ if not _live(record): -+ print("Stack is already stopped.") -+ return -+ p = psutil.Process(int(record["pid"])) -+ # Signal *only* our verified parent. Its existing shutdown/child guards -+ # are responsible for stopping child processes; never kill by port/name. -+ p.send_signal(signal.SIGINT if os.name != "nt" else signal.SIGTERM) -+ try: -+ p.wait(timeout=25) -+ except psutil.TimeoutExpired as exc: -+ raise SystemExit(f"Stack PID {p.pid} did not stop; see logs, no force-kill performed") from exc -+ print(f"Stopped instance {instance.key}.") -+ -+ -+def _delete_owned(instance: Instance) -> None: -+ """Remove only known instance resources; never traverse user overrides.""" -+ if instance.explicit_data: -+ raise SystemExit("Refusing to delete custom SIMPLEAUDIT_DATA_DIR; remove it manually after checking ownership") -+ if instance.explicit_db: -+ raise SystemExit("Refusing to delete overridden SIMPLEAUDIT_SQLITE_PATH") -+ data = _real(instance.data) -+ root = _real(state_root()) -+ safe = data == _real(Path("~/.simpleaudit-studio")) if instance.mode == "spin" else data.is_relative_to(root / "data") -+ if not safe or data.is_symlink(): -+ raise SystemExit(f"Refusing to delete unexpected data path: {data}") -+ if data.exists(): -+ shutil.rmtree(data) -+ if instance.mode == "dev": -+ for suffix in ("", "-wal", "-shm", "-journal"): -+ target = Path(str(instance.db) + suffix) -+ if target.is_symlink(): -+ raise SystemExit(f"Refusing symlinked SQLite file: {target}") -+ target.unlink(missing_ok=True) -+ print("Removed instance-owned data. Repository and external results were preserved.") -+ -+ -+def _uv_cleanup(mode: str) -> None: -+ uv = shutil.which("uv") -+ if not uv: -+ print("uv executable unavailable; package cache cleanup skipped.") -+ return -+ # Package-scoped cache cleanup, NEVER `uv cache clean` without a name. -+ # A uvx ephemeral environment may remain: uv offers no reliable per-tool -+ # selective deletion of all shared cached environments. -+ for name in ("simpleaudit-studio", "open-webui"): -+ result = subprocess.run([uv, "cache", "clean", name], check=False) -+ if result.returncode: -+ print(f"Could not clear uv cache for {name} (exit {result.returncode}).") -+ if mode == "spin": -+ result = subprocess.run([uv, "tool", "uninstall", "simpleaudit-studio"], check=False, capture_output=True, text=True) -+ if result.returncode == 0: -+ print("Removed permanent uv tool installation.") -+ else: -+ print("No permanent uv tool installation removed (uvx does not install one).") -+ print("Shared uvx environments or dependencies may remain; no global cache was cleared.") -+ -+ -+def instructions(mode: str, agents: bool = False) -> None: -+ prefix = "uv run manage.py dev" if mode == "dev" else "uvx --from simpleaudit-studio spin" -+ print(f"""SimpleAudit Studio local lifecycle -+ {prefix} Start or reuse existing instance -+ {prefix} status Show owner, health, ports and data -+ {prefix} down Stop; preserve all data -+ {prefix} restart Restart full instance without deleting data -+ {prefix} reset Stop, delete instance data, seed and start -+ {prefix} uninstall Stop, remove instance state and app-scoped uv caches -+ {prefix} help Full command reference -+ {prefix} agent-info Instructions for coding agents -+ -+ Destructive commands prompt (or accept --yes). Existing startup flags remain. -+ --port N selects a public port; --no-chat omits Open WebUI; -+ --no-browser skips opening a browser. dev: --no-worker, --no-reload. -+ spin: --mock, --visualize-only, --results_dir PATH. -+ `help`/`agent-info` work without importing Django or starting services. -+""") -+ if agents: -+ print("""AGENT SAFETY POLICY -+- Always check `status` first; a healthy server should be reused. -+- Ordinary Django Python edits trigger built-in Django autoreload. Templates -+ update on refresh; do not restart Django or the whole stack after each edit. -+- Worker source is NOT hot-reloaded by the current embedded worker architecture; -+ do not pretend a Django reload updates worker code. Coordinate `restart` with -+ running audits (it stops the stack, preserving data). -+- Never kill a process by port, process name, or broad `pkill` pattern. -+- Never reset/uninstall without explicit authorization and instance ownership. -+- A worktree uses separate ports, Hatchet state and Open WebUI data; do not -+ run a different branch against another worktree's live Django process. -+- Use `status` to discover the assigned URL instead of assuming port 8000. -+- Shared PostgreSQL/multi-process configurations are not destructively managed. -+""") -+ -+ -+def act(instance: Instance, action: str | None, *, yes: bool = False) -> bool: -+ """Run a management action; return True when no server should be started.""" -+ if action is None: -+ return False -+ if action in {"help", "agent-info"}: -+ instructions(instance.mode, agents=action == "agent-info") -+ return True -+ if action == "status": -+ show(instance) -+ return True -+ if action in {"down", "reset", "uninstall", "restart"}: -+ if action in {"reset", "uninstall"}: -+ _confirm(action, instance, yes) -+ # Validate ALL paths before touching any running process. -+ if instance.explicit_data or instance.explicit_db: -+ raise SystemExit("Custom data paths cannot be reset/uninstalled automatically") -+ previous = instance.record() -+ if action in {"restart", "reset"} and _live(previous): -+ os.environ["PORT"] = str(previous["ports"]["public"]) -+ _stop(instance) -+ if action in {"reset", "uninstall"}: -+ _delete_owned(instance) -+ if action == "uninstall": -+ if instance.state.is_symlink(): -+ raise SystemExit("Refusing to remove symlinked instance state") -+ if instance.state.exists(): -+ shutil.rmtree(instance.state) -+ _, other_data = _claimed_by_others(instance) -+ if other_data: -+ print("Other Studio instances remain active; skipping shared uv cache/tool cleanup.") -+ else: -+ _uv_cleanup(instance.mode) -+ return True -+ if action == "down": -+ return True -+ if action == "reset": -+ os.environ["SIMPLEAUDIT_SKIP_LEGACY_ADOPTION"] = "1" -+ return False -+ raise SystemExit(f"Unknown lifecycle action: {action}") -+ -+ -+def _lock(file: Path, blocking: bool = False): -+ """Lifetime advisory lock, POSIX/macOS with Windows msvcrt fallback.""" -+ file.parent.mkdir(parents=True, exist_ok=True) -+ f = file.open("a+b") -+ try: -+ if os.name == "nt": -+ import msvcrt -+ f.seek(0) -+ try: -+ msvcrt.locking(f.fileno(), msvcrt.LK_LOCK if blocking else msvcrt.LK_NBLCK, 1) -+ except OSError: -+ f.close() -+ return None -+ else: -+ import fcntl -+ fcntl.flock(f.fileno(), fcntl.LOCK_EX | (0 if blocking else fcntl.LOCK_NB)) -+ except (BlockingIOError, OSError): -+ f.close() -+ return None -+ return f -+ -+ -+def _port_free(port: int) -> bool: -+ if port < 1 or port > 65535: -+ return False -+ with socket.socket() as s: -+ try: -+ s.bind(("0.0.0.0", port)) -+ return True -+ except OSError: -+ return False -+ -+ -+def _requested_ports(public: int) -> dict: -+ return {"public": public, "internal": public + 1, "chat": public + 80, "pipelines": public + 1099} -+ -+ -+def _claimed_by_others(instance: Instance) -> tuple[set[int], set[str]]: -+ ports = set() -+ data = set() -+ for path in (state_root() / "instances").glob("*/instance.json"): -+ if path == instance.record_path: -+ continue -+ try: -+ record = json.loads(path.read_text()) -+ except (OSError, ValueError): -+ continue -+ if _live(record): -+ ports.update(record.get("ports", {}).values()) -+ data.add(record.get("data", "")) -+ return ports, data -+ -+ -+def start(instance: Instance, *, requested_port: int = 8000, explicit_port: bool = False) -> bool: -+ """Register a local stack and export ports before any Django import. -+ -+ Returns False if another invocation already owns the instance. -+ The lifetime file lock prevents duplicate starts across agent processes. -+ """ -+ global _OWNER -+ if _OWNER is not None: -+ raise RuntimeError("start() called twice") -+ owner_lock = _lock(instance.state / "owner.lock") -+ if owner_lock is None: -+ print("Existing instance owns the startup lock; not starting another stack.") -+ show(instance) -+ return False -+ allocator = _lock(state_root() / "ports.lock", blocking=True) -+ if allocator is None: -+ owner_lock.close() -+ raise RuntimeError("Cannot acquire port allocator lock") -+ try: -+ existing = instance.record() -+ if _live(existing): -+ print("Instance is already running, reusing it.") -+ show(instance) -+ owner_lock.close() -+ return False -+ claimed, data = _claimed_by_others(instance) -+ if str(_real(instance.data)) in data: -+ raise SystemExit("Another running instance owns the same data directory; choose an isolated SIMPLEAUDIT_DATA_DIR") -+ baseline = requested_port -+ if instance.mode == "dev" and (instance.root / ".git").is_file() and not explicit_port and requested_port == 8000: -+ baseline = 8100 -+ if explicit_port: -+ candidates = [baseline] -+ else: -+ candidates = range(baseline, 30000, 100) -+ ports = None -+ for candidate in candidates: -+ p = _requested_ports(candidate) -+ if all(x not in claimed and _port_free(x) for x in p.values()): -+ ports = p -+ break -+ if ports is None: -+ raise SystemExit(f"Ports unavailable for {baseline}; choose another --port") -+ if not instance.explicit_data: -+ os.environ["SIMPLEAUDIT_DATA_DIR"] = str(instance.data) -+ # Always set consistent embedded service endpoints; `--port` refers -+ # to the browser-facing URL, not internal Django or Open WebUI. -+ os.environ.update({ -+ "PORT": str(ports["public"]), -+ "SIMPLEAUDIT_CHAT_PUBLIC_PORT": str(ports["public"]), -+ "SIMPLEAUDIT_CHAT_INTERNAL_PORT": str(ports["internal"]), -+ "SIMPLEAUDIT_CHAT_UPSTREAM": f"http://127.0.0.1:{ports['chat']}/chat", -+ "SIMPLEAUDIT_BROWSER_PORT": str(ports["public"]), -+ "SIMPLEAUDIT_PIPELINES_PORT": str(ports["pipelines"]), -+ }) -+ if instance.mode == "dev": -+ os.environ["SIMPLEAUDIT_SQLITE_PATH"] = str(instance.db) -+ token = uuid.uuid4().hex -+ record = {"key": instance.key, "pid": os.getpid(), "created": psutil.Process().create_time(), -+ "token": token, "mode": instance.mode, "root": str(instance.root) if instance.root else None, -+ "data": str(_real(instance.data)), "db": str(instance.db), "ports": ports} -+ instance.state.mkdir(parents=True, exist_ok=True) -+ tmp = instance.state / ("instance-" + token + ".tmp") -+ tmp.write_text(json.dumps(record, indent=2)) -+ tmp.replace(instance.record_path) -+ def _release(): -+ if (instance.record() or {}).get("token") == token: -+ instance.record_path.unlink(missing_ok=True) -+ owner_lock.close() -+ atexit.register(_release) -+ _OWNER = owner_lock -+ print(f"Studio {instance.mode} instance {instance.key}: http://localhost:{ports['public']}") -+ return True -+ except BaseException: -+ owner_lock.close() -+ raise -+ finally: -+ allocator.close() -diff --git a/tests/test_lifecycle.py b/tests/test_lifecycle.py -new file mode 100644 ---- /dev/null -+++ b/tests/test_lifecycle.py -@@ -0,0 +1,107 @@ -+"""Fast, network-free coverage for instance identity and destructive boundaries.""" -+import importlib.util -+import os -+from pathlib import Path -+ -+import pytest -+ -+MODULE = Path(__file__).resolve().parents[1] / "simpleaudit_studio" / "lifecycle.py" -+spec = importlib.util.spec_from_file_location("local_lifecycle", MODULE) -+lifecycle = importlib.util.module_from_spec(spec) -+spec.loader.exec_module(lifecycle) -+ -+ -+@pytest.fixture -+def environment(tmp_path, monkeypatch): -+ monkeypatch.setenv("XDG_STATE_HOME", str(tmp_path / "state")) -+ monkeypatch.setenv("HOME", str(tmp_path / "home")) -+ monkeypatch.delenv("SIMPLEAUDIT_DATA_DIR", raising=False) -+ monkeypatch.delenv("SIMPLEAUDIT_SQLITE_PATH", raising=False) -+ (tmp_path / "home").mkdir() -+ return tmp_path -+ -+ -+def test_two_worktrees_have_distinct_ids_and_data(environment): -+ a = lifecycle.Instance("dev", environment / "main") -+ b = lifecycle.Instance("dev", environment / "branch") -+ assert a.key != b.key -+ assert a.data != b.data -+ assert a.db != b.db -+ assert a.data.is_relative_to(lifecycle.state_root() / "data") -+ -+ -+def test_spin_preserves_legacy_default_data_location(environment): -+ s = lifecycle.Instance("spin") -+ assert s.data == environment / "home" / ".simpleaudit-studio" -+ assert s.db == s.data / "studio.sqlite3" -+ -+ -+def test_reset_refuses_external_paths(environment, monkeypatch): -+ custom = environment / "user-results" -+ custom.mkdir() -+ sentinel = custom / "keep.txt" -+ sentinel.write_text("important") -+ monkeypatch.setenv("SIMPLEAUDIT_DATA_DIR", str(custom)) -+ instance = lifecycle.Instance("spin") -+ with pytest.raises(SystemExit, match="Custom data paths"): -+ lifecycle.act(instance, "reset", yes=True) -+ assert sentinel.read_text() == "important" -+ -+ -+def test_uninstall_refuses_custom_sqlite_without_stopping(environment, monkeypatch): -+ monkeypatch.setenv("SIMPLEAUDIT_SQLITE_PATH", str(environment / "shared.sqlite3")) -+ instance = lifecycle.Instance("dev", environment / "checkout") -+ with pytest.raises(SystemExit, match="Custom data paths"): -+ lifecycle.act(instance, "uninstall", yes=True) -+ -+ -+def test_delete_owned_data_only(environment): -+ checkout = environment / "repo" -+ checkout.mkdir() -+ instance = lifecycle.Instance("dev", checkout) -+ instance.data.mkdir(parents=True) -+ (instance.data / "openwebui").mkdir() -+ (instance.data / "openwebui" / "webui.db").write_text("fake") -+ instance.db.write_text("fake") -+ (checkout / "manage.py").write_text("safe") -+ lifecycle._delete_owned(instance) -+ assert not instance.data.exists() -+ assert not instance.db.exists() -+ assert (checkout / "manage.py").exists() -+ -+ -+def test_default_port_selection_has_no_duplicate_ports(): -+ assert len(set(lifecycle._requested_ports(8000).values())) == 4 -+ assert lifecycle._requested_ports(8100)["public"] == 8100 -+ assert lifecycle._requested_ports(8100)["chat"] == 8180 -+ -+ -+def test_help_mentions_agent_info_and_commands(capsys): -+ lifecycle.instructions("dev", agents=True) -+ output = capsys.readouterr().out -+ for expected in ("agent-info", "uninstall", "status", "down", "reset", "Never kill"): -+ assert expected in output -+ -+ -+def test_live_rejects_unowned_pid(): -+ assert not lifecycle._live({"pid": os.getpid(), "created": 1.0}) -+ assert not lifecycle._live({"pid": 999999999, "created": 0}) -+ -+ -+def test_uninstall_only_cleans_named_uv_packages(monkeypatch): -+ calls = [] -+ monkeypatch.setattr(lifecycle.shutil, "which", lambda _: "/usr/bin/uv") -+ class Result: -+ returncode = 0 -+ monkeypatch.setattr(lifecycle.subprocess, "run", lambda args, **kwargs: (calls.append(args), Result())[1]) -+ lifecycle._uv_cleanup("dev") -+ assert calls == [["/usr/bin/uv", "cache", "clean", "simpleaudit-studio"], -+ ["/usr/bin/uv", "cache", "clean", "open-webui"]] -+ assert not any(args == ["/usr/bin/uv", "cache", "clean"] for args in calls) -+ -+ -+def test_uninstall_cannot_use_live_external_db(environment, monkeypatch): -+ monkeypatch.setenv("SIMPLEAUDIT_SQLITE_PATH", "/tmp/outside-repo.sqlite3") -+ instance = lifecycle.Instance("dev", environment / "repo") -+ with pytest.raises(SystemExit): -+ lifecycle.act(instance, "uninstall", yes=True) -diff --git a/docs/development-lifecycle.md b/docs/development-lifecycle.md -new file mode 100644 ---- /dev/null -+++ b/docs/development-lifecycle.md -@@ -0,0 +1,74 @@ -+# Agent-safe local development and `spin` -+ -+`dev` (source checkout) and `spin` (installed / uvx) share the lifecycle manager. -+It identifies instances by canonical checkout path (`dev`) or data directory -+(`spin`). Calling the entry point twice **does not** restart a healthy stack. -+ -+```sh -+uv run manage.py dev # start/reuse Django, Hatchet, worker, Caddy, Open WebUI -+uv run manage.py dev status # owner, health and allocated public port -+uv run manage.py dev agent-info # exhaustive instructions for agents -+uv run manage.py dev down # graceful shutdown; preserve data -+uv run manage.py dev restart # intentional full restart -+uv run manage.py dev reset # destructive; reseed and start -+uv run manage.py dev uninstall # remove owned data + scoped uv caches -+ -+uvx --from simpleaudit-studio spin status -+uvx --from simpleaudit-studio spin agent-info -+uvx --from simpleaudit-studio spin down -+uvx --from simpleaudit-studio spin reset -+uvx --from simpleaudit-studio spin uninstall -+``` -+ -+`reset` and `uninstall` confirm interactively; use `--yes` for explicitly -+authorized noninteractive runs. `uninstall` removes managed Open WebUI state, -+embedded Hatchet data, local databases, and package-scoped `uv` wheel caches. -+It does **not** remove source, external result folders, shared Postgres, unrelated -+uv artifacts, system Python, or a source checkout's `.venv`. `uvx` may retain -+shared cached environments that cannot be safely identified by package. -+ -+## Parallel worktrees -+ -+A linked Git worktree gets isolated ports, chat storage, embedded Hatchet -+PostgreSQL and Django SQLite by default. The instance manager allocates ports -+before Django settings or migration start. To find the actual URL, use `status`; -+never assume port 8000. The public `--port` flag also configures Caddy's public -+listener, Django internal port, and the Open WebUI upstream. -+ -+Data layout: -+ -+- `dev`: checkout's `dev.sqlite3`; other services under -+ `~/.local/state/simpleaudit-studio/data/dev-/` -+- `spin`: `~/.simpleaudit-studio/` (compatible with existing installations) -+- Registry: `${XDG_STATE_HOME:-~/.local/state}/simpleaudit-studio/instances/` -+ -+The manager refuses to destructively delete custom `SIMPLEAUDIT_DATA_DIR` or -+`SIMPLEAUDIT_SQLITE_PATH` locations and never kills a PID merely because it -+happens to use a preferred port. A second process sharing the same data dir is -+rejected while another registered owner is alive. -+ -+## Reload behavior and agent policy -+ -+Django's existing `runserver` child autoreloads Python code. Edited Django -+templates are read again on refresh; the browser is not automatically refreshed -+without a separate browser-reload integration. **The embedded Hatchet worker -+currently remains inside the owning parent process and is not automatically -+reloaded**. Changes to worker code require a planned restart, preferably while -+no audits are running. Do not pretend a Django reload updates the worker. -+ -+Always check `dev agent-info`/`spin agent-info` and `status` before attempting to -+start, restart, reset, or uninstall. Never manually kill a port holder, change a -+running instance's persistent data, or restart an unrelated worktree. `reset` -+and `uninstall` require the user to authorize potential data loss. -+ -+## Scope and caveats -+ -+This manager supervises the **existing single-process embedded stack**; it does -+not magically turn Hatchet's worker into an independently hot-reloadable process -+or enable shared PostgreSQL for `dev`. Shared-database/multi-instance scenarios -+need a separate Postgres-backed development mode and migration coordination. -+ -+`devctl.sh` is kept as a compatibility wrapper and must not use its former -+`kill -9`/`pgrep` port-based cleanup. Running `uvx` itself may download the CLI -+package again before an `uninstall` subcommand is processed; uninstalling a -+uv-managed permanent tool is a distinct operation handled only if it exists. diff --git a/simpleaudit_studio/cli.py b/simpleaudit_studio/cli.py index d6f171d1..3c6b4698 100644 --- a/simpleaudit_studio/cli.py +++ b/simpleaudit_studio/cli.py @@ -27,6 +27,7 @@ import os import secrets import signal +import sys import threading import time import webbrowser @@ -39,6 +40,10 @@ def main() -> None: prog="simpleaudit-studio", description="Run SimpleAudit Studio locally (minimal config, no Docker).", ) + parser.add_argument( + "action", nargs="?", choices=["status", "down", "reset", "uninstall", "restart", "help", "agent-info"], + help="Optional lifecycle action. Use `spin agent-info` for coding-agent rules.", + ) parser.add_argument( "--port", type=int, default=int(os.environ.get("PORT", "8000")), help="Web server port (default: 8000)", @@ -78,6 +83,16 @@ def main() -> None: ) args = parser.parse_args() + from simpleaudit_studio.lifecycle import Instance, act, start + instance = Instance("spin") + if act(instance, args.action, yes=args.yes): + return + explicit_port = "--port" in sys.argv[1:] or any(a.startswith("--port=") for a in sys.argv[1:]) + requested = int(os.environ.get("PORT", str(args.port))) + if not start(instance, requested_port=requested, explicit_port=explicit_port): + return + args.port = int(os.environ["PORT"]) + # Set local mode BEFORE Django reads settings os.environ["SIMPLEAUDIT_MINIMAL"] = "1" # Pin the explicit mode so `manage.py mode` / resolve_mode() report "embedded". @@ -102,7 +117,7 @@ def main() -> None: print("=" * 60) print() print(f"💾 Data folder: {data_dir()} (set SIMPLEAUDIT_DATA_DIR to change)") - adopted = adopt_legacy_database() + adopted = None if os.environ.get("SIMPLEAUDIT_SKIP_LEGACY_ADOPTION") else adopt_legacy_database() if adopted: source, runs = adopted print(f" Brought over your data from an earlier version ({runs} run{'s' if runs != 1 else ''}):") @@ -554,7 +569,7 @@ def _run_visualize_only(args) -> None: ports.resolve_port_conflict( port, "the web server", "spin --port ", - force_kill=not args.no_force_kill, yes=args.yes, + force_kill=False, yes=args.yes, ) username = os.environ.get("BOOTSTRAP_USERNAME", "studio") diff --git a/simpleaudit_patch_delivery/lifecycle.py b/simpleaudit_studio/lifecycle.py similarity index 97% rename from simpleaudit_patch_delivery/lifecycle.py rename to simpleaudit_studio/lifecycle.py index fee5b708..ccc58791 100644 --- a/simpleaudit_patch_delivery/lifecycle.py +++ b/simpleaudit_studio/lifecycle.py @@ -10,14 +10,13 @@ import hashlib import json import os -from pathlib import Path import shutil import signal import socket import subprocess import sys -import time import uuid +from pathlib import Path import psutil @@ -76,23 +75,21 @@ def _live(record: dict | None) -> bool: try: pid = int(record["pid"]) p = psutil.Process(pid) - if not p.is_running() or p.status() == psutil.STATUS_ZOMBIE: - return False - if abs(p.create_time() - float(record["created"])) >= 1.0: - return False - if hasattr(os, "getuid") and p.uids().real != os.getuid(): - return False - return True + running = p.is_running() and p.status() != psutil.STATUS_ZOMBIE + fresh = abs(p.create_time() - float(record["created"])) < 1.0 + owned = not hasattr(os, "getuid") or p.uids().real == os.getuid() + return running and fresh and owned except (KeyError, TypeError, ValueError, psutil.Error, OSError): return False def _health(port: int) -> str: + import urllib.error import urllib.request try: with urllib.request.urlopen(f"http://127.0.0.1:{port}/healthz", timeout=1.5) as response: return "healthy" if response.status == 200 else f"HTTP {response.status}" - except Exception: + except (urllib.error.URLError, OSError, ValueError): return "starting or unhealthy" diff --git a/simpleaudit_patch_delivery/test_lifecycle.py b/tests/test_lifecycle.py similarity index 100% rename from simpleaudit_patch_delivery/test_lifecycle.py rename to tests/test_lifecycle.py