diff --git a/src/java_codebase_rag/mcp/server.py b/src/java_codebase_rag/mcp/server.py index 8feded4..22b0367 100644 --- a/src/java_codebase_rag/mcp/server.py +++ b/src/java_codebase_rag/mcp/server.py @@ -15,7 +15,11 @@ from java_codebase_rag.cli_progress import ( accumulate_and_relay_subprocess_streams, ) -from java_codebase_rag.pipeline import VECTORS_SKIPPED_GRAPH_ONLY, vector_stack_installed +from java_codebase_rag.pipeline import ( + VECTORS_SKIPPED_GRAPH_ONLY, + cocoindex_bin as resolve_cocoindex_bin, + vector_stack_installed, +) from java_codebase_rag.progress import ProgressEvent from java_codebase_rag._fdlimit import raise_fd_limit from java_codebase_rag.config import ( @@ -422,7 +426,15 @@ async def run_refresh_pipeline( phases_run=["graph"] if started else [], optimize_error=None, ) - cocoindex_bin = Path(sys.executable).parent / "cocoindex" + # Resolve cocoindex the same way the sync path does (pipeline.cocoindex_bin): + # next to the interpreter first, then via PATH (``shutil.which``). A console + # script legitimately lives away from the venv python — e.g. ``pip install + # --user`` places it in ``~/.local/bin``. Honoring PATH keeps ``reprocess`` + # (no flags) consistent with init/increment/``reprocess --vectors-only``, + # which all resolve through cocoindex_bin(); previously this path checked + # only next-to-python and failed with "cocoindex not found next to Python" + # even though cocoindex was reachable on PATH. + cocoindex_bin = resolve_cocoindex_bin() if not cocoindex_bin.is_file(): # 127 pre-spawn: emit a terminal failed vectors event so the renderer's # task doesn't hang at running (matches the sync pipeline path). @@ -430,7 +442,10 @@ async def run_refresh_pipeline( on_progress(ProgressEvent(kind="vectors", phase=None, pass_=None, done=None, total=None, status="failed", elapsed_s=None)) return RefreshIndexOutput( success=False, - message=f"cocoindex not found next to Python: {cocoindex_bin}", + message=( + f"cocoindex not found next to Python ({cocoindex_bin}) or on PATH; " + "install cocoindex[lancedb] into the same venv or add its bin/ to PATH." + ), phases_run=[], ) flow_path = _FLOW_FILE diff --git a/tests/package/test_graph_only_boot.py b/tests/package/test_graph_only_boot.py index df3ce55..02ef639 100644 --- a/tests/package/test_graph_only_boot.py +++ b/tests/package/test_graph_only_boot.py @@ -159,3 +159,77 @@ async def fake_graph_phase(root, *, quiet, verbose, on_progress, on_progress_con assert "cocoindex not found" not in (out.message or "") # The JSON message documents the graph-only outcome. assert "graph-only" in (out.message or "") + + +def test_cocoindex_bin_falls_back_to_path(monkeypatch, tmp_path) -> None: + """``cocoindex_bin`` must honor ``shutil.which`` when cocoindex is not next to + the interpreter — a console script legitimately lives away from the venv python + (e.g. ``~/.local/bin`` from ``pip install --user``). Regression guard for the + PATH fallback that init/increment/``reprocess --vectors-only`` rely on.""" + from java_codebase_rag import pipeline + + # cocoindex is NOT next to a (faked) interpreter, but IS present on PATH. + fake_interp = tmp_path / "fakepython" + fake_interp.mkdir() + monkeypatch.setattr(pipeline.sys, "executable", str(fake_interp / "python")) + + on_path = tmp_path / "bin" / "cocoindex" + on_path.parent.mkdir(parents=True) + on_path.write_text("") + monkeypatch.setattr(pipeline.shutil, "which", lambda name: str(on_path)) + + assert pipeline.cocoindex_bin() == on_path + + +def test_refresh_pipeline_resolves_cocoindex_via_path_not_next_to_python( + monkeypatch, tmp_path +) -> None: + """Regression: ``reprocess`` (no flags) must resolve cocoindex through the + shared PATH-aware ``cocoindex_bin()`` helper, not hardcode next-to-python. + When the binary is reachable only via PATH, it must spawn cocoindex rather + than fail with "cocoindex not found next to Python" — matching what the sync + path (``reprocess --vectors-only``) already did.""" + import asyncio + + from java_codebase_rag.mcp import server + + # Vector stack IS installed (so we do NOT take the graph-only skip branch)... + monkeypatch.setattr(server, "vector_stack_installed", lambda: True) + + # ...but cocoindex lives only on PATH, not next to python. The resolver + # returns that PATH-found binary; the old hardcoded path would have missed it. + on_path = tmp_path / "on_path" / "cocoindex" + on_path.parent.mkdir(parents=True) + on_path.write_text("") # so .is_file() is True and the not-found branch is skipped + monkeypatch.setattr(server, "resolve_cocoindex_bin", lambda: on_path) + + class _FakeProc: + def __init__(self) -> None: + self.returncode = 1 # nonzero: stop right after the vectors spawn + + async def communicate(self): + return b"", b"cocoindex exit 1" + + spawned: list[str] = [] + + async def fake_exec(exe, *args, **kwargs): + spawned.append(exe) + return _FakeProc() + + monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_exec) + + monkeypatch.setenv("JAVA_CODEBASE_RAG_SOURCE_ROOT", str(tmp_path)) + monkeypatch.setenv("JAVA_CODEBASE_RAG_INDEX_DIR", str(tmp_path / "idx")) + + out = asyncio.run(server.run_refresh_pipeline(quiet=True)) + + # cocoindex WAS spawned with the PATH-resolved binary (the drop + update + # calls), and the run reached the spawn — it did not short-circuit to the + # not-found failure. The nonzero exit is the fake's, surfacing as a real + # cocoindex failure rather than a resolution failure. + assert spawned, "cocoindex was never spawned (not-found short-circuit)" + assert all(exe == str(on_path) for exe in spawned) + assert out.exit_code == 1 + assert out.phases_run == ["vectors"] + assert out.message == "cocoindex exit 1" + assert "cocoindex not found" not in (out.message or "")