diff --git a/backend/src/analytics_agent/quickstart.py b/backend/src/analytics_agent/quickstart.py index 7cdac9c..c6a74e5 100644 --- a/backend/src/analytics_agent/quickstart.py +++ b/backend/src/analytics_agent/quickstart.py @@ -309,6 +309,31 @@ def _check_prereqs(port: int) -> None: click.echo(f" ✓ Port {port} available") +def _is_stale_demo_db_failure(db_url: str, stderr: str) -> bool: + """True when bootstrap failed because the demo-written MySQL DATABASE_URL is unreachable.""" + # Matches the URL written by _write_demo_config() on both Mac (host.docker.internal) + # and Linux (172.17.0.1). The literal datahub:datahub@…/talkster combo is + # demo-specific, so a match means we can safely strip the var. + if "mysql+aiomysql://datahub:datahub@" not in db_url or "/talkster" not in db_url: + return False + return "Can't connect" in stderr or "nodename nor servname" in stderr + + +def _strip_env_vars(env_path: Path, keys: set[str]) -> None: + """Remove KEY=VALUE lines for the given keys from a .env file.""" + if not env_path.exists(): + return + kept: list[str] = [] + for line in env_path.read_text().splitlines(): + stripped = line.strip() + if stripped and not stripped.startswith("#") and "=" in stripped: + k, _, _ = stripped.partition("=") + if k.strip() in keys: + continue + kept.append(line) + env_path.write_text("\n".join(kept) + ("\n" if kept else "")) + + def _bootstrap_and_launch(config_dir: Path, port: int, *, open_setup: bool = False) -> None: """Run bootstrap (migrations + seeds) then start the server.""" import subprocess as _sp @@ -331,6 +356,22 @@ def _bootstrap_and_launch(config_dir: Path, port: int, *, open_setup: bool = Fal capture_output=True, text=True, ) + if result.returncode != 0 and _is_stale_demo_db_failure( + env.get("DATABASE_URL", ""), result.stderr + ): + click.echo( + " ! Stale demo DATABASE_URL detected (MySQL unreachable). " + "Clearing it and falling back to the default SQLite database…", + err=True, + ) + _strip_env_vars(env_path, {"DATABASE_URL"}) + env.pop("DATABASE_URL", None) + result = _sp.run( + [sys.executable, "-m", "analytics_agent.cli", "bootstrap"], + env=env, + capture_output=True, + text=True, + ) if result.returncode != 0: click.echo(result.stderr, err=True) sys.exit(result.returncode) diff --git a/tests/unit/test_quickstart_stale_demo.py b/tests/unit/test_quickstart_stale_demo.py new file mode 100644 index 0000000..4f27966 --- /dev/null +++ b/tests/unit/test_quickstart_stale_demo.py @@ -0,0 +1,86 @@ +""" +Tests for self-healing recovery when `quickstart` runs against a stale demo +DATABASE_URL left in ~/.datahub/analytics-agent/.env by a previous `demo` run. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from analytics_agent.quickstart import _is_stale_demo_db_failure, _strip_env_vars + +# ── _is_stale_demo_db_failure ──────────────────────────────────────────────── + +_DEMO_URL_MAC = "mysql+aiomysql://datahub:datahub@host.docker.internal:3306/talkster" +_DEMO_URL_LINUX = "mysql+aiomysql://datahub:datahub@172.17.0.1:3306/talkster" +_CONNECT_ERR_MAC = "OperationalError: (pymysql.err.OperationalError) (2003, \"Can't connect to MySQL server on 'host.docker.internal'\")" +_CONNECT_ERR_DNS = "socket.gaierror: [Errno 8] nodename nor servname provided, or not known" + + +@pytest.mark.parametrize("url", [_DEMO_URL_MAC, _DEMO_URL_LINUX]) +def test_stale_demo_detected_on_mac_and_linux(url): + assert _is_stale_demo_db_failure(url, _CONNECT_ERR_MAC) + assert _is_stale_demo_db_failure(url, _CONNECT_ERR_DNS) + + +def test_non_demo_mysql_not_treated_as_stale(): + """A user-supplied MySQL URL must NOT trigger auto-cleanup, even on connect failure.""" + user_url = "mysql+aiomysql://prod_user:secret@my-db.example.com:3306/analytics" + assert not _is_stale_demo_db_failure(user_url, _CONNECT_ERR_MAC) + + +def test_demo_url_but_unrelated_stderr(): + """Random stderr (e.g. migration syntax error) must not falsely trigger recovery.""" + assert not _is_stale_demo_db_failure( + _DEMO_URL_MAC, "alembic.util.exc.CommandError: Can't locate revision" + ) + + +def test_empty_db_url(): + assert not _is_stale_demo_db_failure("", _CONNECT_ERR_MAC) + + +def test_sqlite_url_is_not_stale(): + assert not _is_stale_demo_db_failure("sqlite+aiosqlite:///x.db", _CONNECT_ERR_MAC) + + +# ── _strip_env_vars ────────────────────────────────────────────────────────── + + +def test_strip_env_vars_removes_only_named_keys(tmp_path: Path): + env_file = tmp_path / ".env" + env_file.write_text( + "ANTHROPIC_API_KEY=sk-ant-xxx\n" + "DATABASE_URL=mysql+aiomysql://datahub:datahub@host.docker.internal:3306/talkster\n" + "# A comment line\n" + "DATAHUB_GMS_URL=http://localhost:8080\n" + "\n" + "LLM_PROVIDER=anthropic\n" + ) + _strip_env_vars(env_file, {"DATABASE_URL"}) + remaining = env_file.read_text() + assert "DATABASE_URL" not in remaining + assert "ANTHROPIC_API_KEY=sk-ant-xxx" in remaining + assert "DATAHUB_GMS_URL=http://localhost:8080" in remaining + assert "# A comment line" in remaining + assert "LLM_PROVIDER=anthropic" in remaining + + +def test_strip_env_vars_multiple_keys(tmp_path: Path): + env_file = tmp_path / ".env" + env_file.write_text("A=1\nB=2\nC=3\n") + _strip_env_vars(env_file, {"A", "C"}) + assert env_file.read_text() == "B=2\n" + + +def test_strip_env_vars_missing_file_is_noop(tmp_path: Path): + """Should not raise when the file doesn't exist.""" + _strip_env_vars(tmp_path / "nonexistent.env", {"X"}) + + +def test_strip_env_vars_key_not_present(tmp_path: Path): + env_file = tmp_path / ".env" + env_file.write_text("A=1\nB=2\n") + _strip_env_vars(env_file, {"DOES_NOT_EXIST"}) + assert env_file.read_text() == "A=1\nB=2\n"