Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/supamem/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -752,7 +752,7 @@ def run_doctor(*, redact_secrets: bool = True) -> int:

# ── Section 1: Health ────────────────────────────────────────────────
console.print("[supamem.brand]Health[/supamem.brand]")
qdrant_up = probe_qdrant(cfg.qdrant_url)
qdrant_up = probe_qdrant(cfg.qdrant_url, api_key=cfg.qdrant_api_key)
if qdrant_up:
ok(f"Qdrant reachable at {cfg.qdrant_url}")
else:
Expand All @@ -777,7 +777,7 @@ def run_doctor(*, redact_secrets: bool = True) -> int:
sparse = "sparse+dense" if coll_status.get("sparse") else "dense-only"
ok(f"collection {cfg.collection!r} ({sparse})")
else:
err(f"collection {cfg.collection!r} missing")
err(f"collection {cfg.collection!r} missing — error: {coll_status.get('error', 'unknown')}")

# ── Section 2: Config chain ──────────────────────────────────────────
console.print()
Expand Down
18 changes: 13 additions & 5 deletions src/supamem/init.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,17 @@ def _slugify(name: str) -> str:
return s or "supamem"


def probe_qdrant(url: str, timeout: float = 2.0) -> bool:
def probe_qdrant(url: str, api_key: str = "", timeout: float = 2.0) -> bool:
"""Return True iff ``GET <url>/healthz`` returns 200 within ``timeout``."""
target = url.rstrip("/") + "/healthz"
try:
with urllib.request.urlopen(target, timeout=timeout) as resp: # noqa: S310 — explicit URL
req = urllib.request.Request(
target,
headers={"User-Agent": "Mozilla/5.0 (supamem-probe)"}
)
if api_key:
req.add_header("api-key", api_key)
with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310 — explicit URL
return resp.status == 200
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, socket.timeout, OSError):
return False
Expand Down Expand Up @@ -134,14 +140,16 @@ def run_init(
skip_patch_agents: bool = False,
) -> int:
"""Greenfield bootstrap. Returns 0 on success, non-zero on hard failure."""
import os
cwd = cwd.resolve()
url = (qdrant_url or "http://localhost:6333").rstrip("/")
url = (qdrant_url or os.environ.get("QDRANT_URL") or "http://localhost:6333").rstrip("/")
api_key = os.environ.get("QDRANT_API_KEY", "")

banner("supamem init", f"bootstrapping in {cwd}")

# ── 1. Probe Qdrant ────────────────────────────────────────────────────
info(f"probing Qdrant at {url}")
if not probe_qdrant(url):
if not probe_qdrant(url, api_key=api_key):
warn(f"Qdrant unreachable at {url}")
info("Start Qdrant with:")
step(DOCKER_RECIPE)
Expand Down Expand Up @@ -190,7 +198,7 @@ def run_init(

# ── 4. Create collection ───────────────────────────────────────────────
try:
client = _get_client(url)
client = _get_client(url, api_key=api_key)
created = create_collection(client, collection, force=force)
if not created:
err(f"collection {collection!r} already exists")
Expand Down
38 changes: 19 additions & 19 deletions tests/test_doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ def test_doctor_redacts_api_key_by_default(

import supamem.doctor as mod

monkeypatch.setattr(mod, "probe_qdrant", lambda url, timeout=2.0: False)
monkeypatch.setattr(mod, "probe_qdrant", lambda url, api_key="", timeout=2.0: False)

rc = mod.run_doctor()
out = capsys.readouterr().out + capsys.readouterr().err
Expand All @@ -39,7 +39,7 @@ def test_doctor_exits_1_on_qdrant_unreachable(
) -> None:
import supamem.doctor as mod

monkeypatch.setattr(mod, "probe_qdrant", lambda url, timeout=2.0: False)
monkeypatch.setattr(mod, "probe_qdrant", lambda url, api_key="", timeout=2.0: False)
rc = mod.run_doctor()
assert rc == 1

Expand All @@ -51,7 +51,7 @@ def test_doctor_exits_1_on_version_drift(
"""A managed-block fence with an old version triggers drift + exit 1."""
import supamem.doctor as mod

monkeypatch.setattr(mod, "probe_qdrant", lambda url, timeout=2.0: True)
monkeypatch.setattr(mod, "probe_qdrant", lambda url, api_key="", timeout=2.0: True)
# Skip the qdrant client path (collection check) cleanly.
monkeypatch.setattr(
mod, "_collection_health", lambda client, name: {"present": True, "sparse": True}
Expand Down Expand Up @@ -91,7 +91,7 @@ def test_doctor_prints_each_config_field_with_source(
) -> None:
import supamem.doctor as mod

monkeypatch.setattr(mod, "probe_qdrant", lambda url, timeout=2.0: False)
monkeypatch.setattr(mod, "probe_qdrant", lambda url, api_key="", timeout=2.0: False)
mod.run_doctor()
out = capsys.readouterr().out
assert "[source: default]" in out
Expand All @@ -107,7 +107,7 @@ def test_doctor_shows_transcript_config(
"""Plan 06-04 Task 01: ``supamem doctor`` surfaces all 6 transcript keys (D-31)."""
import supamem.doctor as mod

monkeypatch.setattr(mod, "probe_qdrant", lambda url, timeout=2.0: False)
monkeypatch.setattr(mod, "probe_qdrant", lambda url, api_key="", timeout=2.0: False)
mod.run_doctor()
out = capsys.readouterr().out
assert "Transcript config" in out
Expand All @@ -134,7 +134,7 @@ def test_doctor_shows_classifier_rooms_and_hash(
"""``supamem doctor`` surfaces [classifier.rooms] config + classifier_hash (D-16)."""
import supamem.doctor as mod

monkeypatch.setattr(mod, "probe_qdrant", lambda url, timeout=2.0: False)
monkeypatch.setattr(mod, "probe_qdrant", lambda url, api_key="", timeout=2.0: False)
mod.run_doctor()
out = capsys.readouterr().out
assert "Classifier rooms" in out
Expand All @@ -155,7 +155,7 @@ def test_doctor_shows_room_histogram_with_null_bucket(

# Probe says reachable so the histogram path runs; client construction
# below raises so the count() try/except path falls back to 0.
monkeypatch.setattr(mod, "probe_qdrant", lambda url, timeout=2.0: True)
monkeypatch.setattr(mod, "probe_qdrant", lambda url, api_key="", timeout=2.0: True)
monkeypatch.setattr(
mod, "_collection_health", lambda client, name: {"present": True, "sparse": True}
)
Expand Down Expand Up @@ -196,7 +196,7 @@ def test_doctor_no_drift_no_qdrant_means_exit_1(
"""Even if no clients are installed, Qdrant unreachable still triggers exit 1."""
import supamem.doctor as mod

monkeypatch.setattr(mod, "probe_qdrant", lambda url, timeout=2.0: False)
monkeypatch.setattr(mod, "probe_qdrant", lambda url, api_key="", timeout=2.0: False)
rc = mod.run_doctor()
assert rc == 1

Expand Down Expand Up @@ -240,7 +240,7 @@ def test_doctor_reranker_panel_healthy(
monkeypatch.setenv("SUPAMEM_CACHE_DIR", str(cache))
_seed_healthy_manifest(cache, "mixedbread-ai/mxbai-rerank-base-v2")

monkeypatch.setattr(mod, "probe_qdrant", lambda url, timeout=2.0: False)
monkeypatch.setattr(mod, "probe_qdrant", lambda url, api_key="", timeout=2.0: False)
rc = mod.run_doctor()
out = capsys.readouterr().out

Expand Down Expand Up @@ -276,7 +276,7 @@ def test_doctor_reranker_panel_partial_download(
(snap / "model.safetensors").unlink()

# Pin Qdrant up + collection present so drift attribution is unambiguous.
monkeypatch.setattr(mod, "probe_qdrant", lambda url, timeout=2.0: True)
monkeypatch.setattr(mod, "probe_qdrant", lambda url, api_key="", timeout=2.0: True)
monkeypatch.setattr(
mod, "_collection_health",
lambda client, name: {"present": True, "sparse": True},
Expand Down Expand Up @@ -366,7 +366,7 @@ def test_doctor_reranker_p50_p95_verifiable(
}
}))

monkeypatch.setattr(mod, "probe_qdrant", lambda url, timeout=2.0: False)
monkeypatch.setattr(mod, "probe_qdrant", lambda url, api_key="", timeout=2.0: False)
mod.run_doctor()
out = capsys.readouterr().out

Expand Down Expand Up @@ -466,7 +466,7 @@ def test_doctor_subagent_reachability_panel_present(

cache = tmp_path / "cache"
monkeypatch.setenv("SUPAMEM_CACHE_DIR", str(cache))
monkeypatch.setattr(mod, "probe_qdrant", lambda url, timeout=2.0: False)
monkeypatch.setattr(mod, "probe_qdrant", lambda url, api_key="", timeout=2.0: False)

# Seed 4 fixtures: 1 patched (covered + manifest entry), 1 covered-only,
# 1 inheritance, 1 malformed.
Expand Down Expand Up @@ -518,7 +518,7 @@ def test_doctor_subagent_reachability_no_manifest_shows_repair_hint(
cache = tmp_path / "cache"
cache.mkdir()
monkeypatch.setenv("SUPAMEM_CACHE_DIR", str(cache))
monkeypatch.setattr(mod, "probe_qdrant", lambda url, timeout=2.0: False)
monkeypatch.setattr(mod, "probe_qdrant", lambda url, api_key="", timeout=2.0: False)

_seed_agent(home, "csv-patchable.md", CSV_PATCHABLE_AGENT)

Expand Down Expand Up @@ -549,7 +549,7 @@ def test_doctor_subagent_reachability_does_not_change_exit_code(
cache = tmp_path / "cache"
cache.mkdir()
monkeypatch.setenv("SUPAMEM_CACHE_DIR", str(cache))
monkeypatch.setattr(mod, "probe_qdrant", lambda url, timeout=2.0: False)
monkeypatch.setattr(mod, "probe_qdrant", lambda url, api_key="", timeout=2.0: False)

# Baseline: empty home, no agents.
rc_baseline = mod.run_doctor()
Expand All @@ -576,7 +576,7 @@ def test_doctor_renders_unpatch_reminder_when_manifest_present(

cache = tmp_path / "cache"
monkeypatch.setenv("SUPAMEM_CACHE_DIR", str(cache))
monkeypatch.setattr(mod, "probe_qdrant", lambda url, timeout=2.0: False)
monkeypatch.setattr(mod, "probe_qdrant", lambda url, api_key="", timeout=2.0: False)

p = _seed_agent(home, "covered.md", CSV_COVERED_AGENT)
_seed_manifest(
Expand Down Expand Up @@ -620,7 +620,7 @@ def test_doctor_handles_empty_global_dir(
cache = tmp_path / "cache"
cache.mkdir()
monkeypatch.setenv("SUPAMEM_CACHE_DIR", str(cache))
monkeypatch.setattr(mod, "probe_qdrant", lambda url, timeout=2.0: False)
monkeypatch.setattr(mod, "probe_qdrant", lambda url, api_key="", timeout=2.0: False)

rc = mod.run_doctor()
out = capsys.readouterr().out
Expand Down Expand Up @@ -652,7 +652,7 @@ def test_doctor_temporal_panel_renders_when_qdrant_unreachable(
"""
import supamem.doctor as mod

monkeypatch.setattr(mod, "probe_qdrant", lambda url, timeout=2.0: False)
monkeypatch.setattr(mod, "probe_qdrant", lambda url, api_key="", timeout=2.0: False)
rc = mod.run_doctor()
out = capsys.readouterr().out

Expand Down Expand Up @@ -703,7 +703,7 @@ def test_doctor_temporal_panel_read_only_never_flips_rc(
cache.mkdir()
monkeypatch.setenv("SUPAMEM_CACHE_DIR", str(cache))

monkeypatch.setattr(mod, "probe_qdrant", lambda url, timeout=2.0: True)
monkeypatch.setattr(mod, "probe_qdrant", lambda url, api_key="", timeout=2.0: True)
monkeypatch.setattr(
mod,
"_collection_health",
Expand Down Expand Up @@ -765,7 +765,7 @@ def test_doctor_temporal_panel_handles_count_exception(
cache.mkdir()
monkeypatch.setenv("SUPAMEM_CACHE_DIR", str(cache))

monkeypatch.setattr(mod, "probe_qdrant", lambda url, timeout=2.0: True)
monkeypatch.setattr(mod, "probe_qdrant", lambda url, api_key="", timeout=2.0: True)
monkeypatch.setattr(
mod,
"_collection_health",
Expand Down
49 changes: 45 additions & 4 deletions tests/test_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ def test_run_init_writes_config_toml(
"""Mock Qdrant client; assert .supamem/config.toml has [supamem] collection key."""
import supamem.init as mod

monkeypatch.setattr(mod, "probe_qdrant", lambda url, timeout=2.0: True)
monkeypatch.setattr(mod, "probe_qdrant", lambda url, api_key="", timeout=2.0: True)

fake_client = MagicMock()
fake_client.get_collections.return_value = MagicMock(collections=[])
Expand All @@ -42,7 +42,7 @@ def test_run_init_skips_create_when_collection_exists(
"""Refuse to create_collection if name already exists (T-80.6-08-04)."""
import supamem.init as mod

monkeypatch.setattr(mod, "probe_qdrant", lambda url, timeout=2.0: True)
monkeypatch.setattr(mod, "probe_qdrant", lambda url, api_key="", timeout=2.0: True)

existing = MagicMock()
existing.name = f"supamem-{tmp_path.name.lower().replace('_', '-')}"
Expand All @@ -68,7 +68,7 @@ def test_run_init_refuses_to_overwrite_existing_config(
"""T-80.6-08-02: refuse to clobber .supamem/config.toml without --force."""
import supamem.init as mod

monkeypatch.setattr(mod, "probe_qdrant", lambda url, timeout=2.0: True)
monkeypatch.setattr(mod, "probe_qdrant", lambda url, api_key="", timeout=2.0: True)
fake_client = MagicMock()
fake_client.get_collections.return_value = MagicMock(collections=[])
monkeypatch.setattr(mod, "_get_client", lambda url, api_key="": fake_client)
Expand All @@ -88,6 +88,47 @@ def test_run_init_aborts_when_qdrant_down_without_yes(
"""If Qdrant is unreachable AND yes=False, abort cleanly without prompting."""
import supamem.init as mod

monkeypatch.setattr(mod, "probe_qdrant", lambda url, timeout=2.0: False)
monkeypatch.setattr(mod, "probe_qdrant", lambda url, api_key="", timeout=2.0: False)
rc = run_init(tmp_path, yes=False)
assert rc == 2


def test_run_init_respects_qdrant_env_vars(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Assert run_init falls back to QDRANT_URL and resolves QDRANT_API_KEY from environment."""
import supamem.init as mod

captured_url = None
captured_api_key = None

def mock_probe_qdrant(url, api_key="", timeout=2.0):
nonlocal captured_url, captured_api_key
captured_url = url
captured_api_key = api_key
return True

monkeypatch.setattr(mod, "probe_qdrant", mock_probe_qdrant)
monkeypatch.setenv("QDRANT_URL", "https://env-qdrant-url.example.com:443")
monkeypatch.setenv("QDRANT_API_KEY", "env-api-key-value")

fake_client = MagicMock()
fake_client.get_collections.return_value = MagicMock(collections=[])

captured_client_url = None
captured_client_api_key = None

def mock_get_client(url, api_key=""):
nonlocal captured_client_url, captured_client_api_key
captured_client_url = url
captured_client_api_key = api_key
return fake_client

monkeypatch.setattr(mod, "_get_client", mock_get_client)

rc = run_init(tmp_path, yes=True)
assert rc == 0
assert captured_url == "https://env-qdrant-url.example.com:443"
assert captured_api_key == "env-api-key-value"
assert captured_client_url == "https://env-qdrant-url.example.com:443"
assert captured_client_api_key == "env-api-key-value"