Skip to content
Merged
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
21 changes: 18 additions & 3 deletions src/java_codebase_rag/mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -422,15 +426,26 @@ 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).
if on_progress is not None:
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
Expand Down
74 changes: 74 additions & 0 deletions tests/package/test_graph_only_boot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "")
Loading