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
7 changes: 1 addition & 6 deletions java_codebase_rag/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
from java_codebase_rag._fdlimit import raise_fd_limit
from java_codebase_rag._version import version_string
from java_codebase_rag.pipeline import (
VECTORS_SKIPPED_GRAPH_ONLY as _VECTORS_SKIPPED_GRAPH_ONLY,
clip,
is_cocoindex_preflight_blocker,
is_graph_preflight_blocker,
Expand Down Expand Up @@ -72,12 +73,6 @@ def _reprocess_drift_graph_only_line(index_dir: Path) -> str:
)


_VECTORS_SKIPPED_GRAPH_ONLY = (
"java-codebase-rag: vectors skipped — vector stack not installed on this platform "
"(graph-only mode). The graph is built/refreshed; semantic search is unavailable."
)


def _reprocess_exit_code(payload: dict[str, Any]) -> int:
if payload.get("success"):
return 0
Expand Down
10 changes: 10 additions & 0 deletions java_codebase_rag/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,16 @@

COCOINDEX_TARGET = "java_index_flow_lancedb.py:JavaCodeIndexLance"

# Operator-facing line printed when an indexing command skips the vectors phase
# because the vector stack is absent (graph-only install, e.g. macOS Intel where
# cocoindex[lancedb]/lancedb/sentence-transformers are gated off by PEP 508 markers).
# Single source of truth — imported by cli.py (init/increment) and server.py
# (reprocess) so the wording can't drift between the two paths.
VECTORS_SKIPPED_GRAPH_ONLY = (
"java-codebase-rag: vectors skipped — vector stack not installed on this platform "
"(graph-only mode). The graph is built/refreshed; semantic search is unavailable."
)


def bundle_dir() -> Path:
return Path(__file__).resolve().parent.parent
Expand Down
132 changes: 98 additions & 34 deletions server.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
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.progress import ProgressEvent
from java_codebase_rag._fdlimit import raise_fd_limit
from java_codebase_rag.config import (
Expand Down Expand Up @@ -314,6 +315,61 @@ def list_code_index_tables_payload() -> IndexInfoOutput:
)


async def _run_graph_phase(
root: Path,
*,
quiet: bool,
verbose: bool,
on_progress: object | None,
on_progress_console: object | None,
) -> tuple[int | None, str, str, bool]:
"""Run ``build_ast_graph.py`` and return ``(code, stdout, stderr, started)``.

Shared by the vectors→graph refresh path and the graph-only path (macOS Intel,
where the vector stack is gated off). ``started`` is True only when the graph
subprocess was actually created, so callers set ``phases_run`` accurately — the
CLI maps an empty ``phases_run`` to a preflight exit code 2 (nothing spawned).
A missing builder or a spawn failure returns ``started=False`` with the graph
code carrying the reason (``None`` for missing builder, ``-1`` for spawn error).
"""
builder = Path(__file__).resolve().parent / "build_ast_graph.py"
if not builder.is_file():
return None, "", "", False
try:
graph_args = [
sys.executable,
str(builder),
"--source-root",
str(root),
"--ladybug-path",
resolve_ladybug_path(),
]
if not quiet:
graph_args.append("--verbose")
gproc = await asyncio.create_subprocess_exec(
*graph_args,
cwd=str(root),
env=_cocoindex_subprocess_env(root),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
if quiet:
gout_b, gerr_b = await gproc.communicate()
else:
gout_b, gerr_b = await accumulate_and_relay_subprocess_streams(
gproc, relay=True, verbose=verbose,
on_progress=on_progress, on_progress_console=on_progress_console,
)
return (
gproc.returncode,
gout_b.decode(errors="replace"),
gerr_b.decode(errors="replace"),
True,
)
except Exception as exc:
return -1, "", f"graph builder spawn failed: {exc}", False


async def run_refresh_pipeline(
*,
quiet: bool = False,
Expand All @@ -322,6 +378,42 @@ async def run_refresh_pipeline(
on_progress_console: object | None = None,
) -> RefreshIndexOutput:
root = _project_root()
if not vector_stack_installed():
# Graph-only install (macOS Intel): the vector stack (cocoindex/lancedb/
# sentence-transformers) is gated off by PEP 508 markers and uninstallable,
# so the cocoindex binary is absent. Skip the vectors phase and build the
# graph only — mirroring init/increment, which treat cocoindex-absent as a
# skip, not a failure (the graph layer is the supported surface there). No
# vectors progress event is emitted, so the renderer's vectors task stays
# invisible (its "never spawned" invariant) instead of hanging at running.
print(VECTORS_SKIPPED_GRAPH_ONLY, file=sys.stderr, flush=True)
if not quiet:
print(file=sys.stderr, flush=True)
graph_code, graph_out, graph_err, started = await _run_graph_phase(
root, quiet=quiet, verbose=verbose,
on_progress=on_progress, on_progress_console=on_progress_console,
)
ok = graph_code == 0
if not ok:
message = (
f"graph builder exit {graph_code}"
if graph_code is not None
else (graph_err.strip() or "graph builder unavailable")
)
else:
message = "reprocess completed (graph-only; vectors skipped — vector stack not installed)"
return RefreshIndexOutput(
success=ok,
exit_code=None,
stdout="",
stderr="",
message=message,
graph_exit_code=graph_code,
graph_stdout=graph_out[-4000:] if len(graph_out) > 4000 else graph_out,
graph_stderr=graph_err[-4000:] if len(graph_err) > 4000 else graph_err,
phases_run=["graph"] if started else [],
optimize_error=None,
)
cocoindex_bin = Path(sys.executable).parent / "cocoindex"
if not cocoindex_bin.is_file():
# 127 pre-spawn: emit a terminal failed vectors event so the renderer's
Expand Down Expand Up @@ -466,40 +558,12 @@ async def run_refresh_pipeline(
except Exception as exc:
optimize_error = f"lance optimize failed: {exc}"
print(f"java-codebase-rag: {optimize_error}", file=sys.stderr)
builder = Path(__file__).resolve().parent / "build_ast_graph.py"
if builder.is_file():
try:
graph_args = [
sys.executable,
str(builder),
"--source-root",
str(root),
"--ladybug-path",
resolve_ladybug_path(),
]
if not quiet:
graph_args.append("--verbose")
gproc = await asyncio.create_subprocess_exec(
*graph_args,
cwd=str(root),
env=_cocoindex_subprocess_env(root),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
phases_run = ["vectors", "graph"]
if quiet:
gout_b, gerr_b = await gproc.communicate()
else:
gout_b, gerr_b = await accumulate_and_relay_subprocess_streams(
gproc, relay=True, verbose=verbose,
on_progress=on_progress, on_progress_console=on_progress_console,
)
graph_code = gproc.returncode
graph_out = gout_b.decode(errors="replace")
graph_err = gerr_b.decode(errors="replace")
except Exception as exc:
graph_code = -1
graph_err = f"graph builder spawn failed: {exc}"
graph_code, graph_out, graph_err, graph_started = await _run_graph_phase(
root, quiet=quiet, verbose=verbose,
on_progress=on_progress, on_progress_console=on_progress_console,
)
if graph_started:
phases_run = ["vectors", "graph"]
message: str | None = None
if not ok:
message = f"cocoindex exit {proc.returncode}"
Expand Down
6 changes: 6 additions & 0 deletions tests/test_cli_progress_stdout_invariant.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,12 @@ def capture_write(data: bytes | bytearray) -> int:
def test_refresh_pipeline_quiet_stderr_baseline(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
import server

# This test simulates a FULL (vectors-capable) install by faking the cocoindex
# binary below; it must also report the vector stack as installed, otherwise
# run_refresh_pipeline's graph-only branch short-circuits before the vectors
# path this test exercises.
monkeypatch.setattr(server, "vector_stack_installed", lambda: True)

repo_root = Path(__file__).resolve().parent.parent
idx = tmp_path / "idx_q"
idx.mkdir(parents=True)
Expand Down
51 changes: 51 additions & 0 deletions tests/test_graph_only_boot.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,3 +100,54 @@ def test_vector_stack_installed_reports_absent_when_blocked() -> None:
)
assert proc.returncode == 0, proc.stderr
assert "INSTALLED:False" in proc.stdout


def test_refresh_pipeline_skips_vectors_and_builds_graph_on_graph_only(
monkeypatch, tmp_path
) -> None:
"""``reprocess``'s refresh pipeline must skip vectors and build the graph on a
graph-only install (macOS Intel), NOT fail with a cryptic "cocoindex not found"
message. Mirrors init/increment's skip-vectors-then-build-graph branch; this is
the regression guard for the Intel-Mac reprocess failure.
"""
import asyncio
import io
from contextlib import redirect_stderr
from pathlib import Path

import server

monkeypatch.setattr(server, "vector_stack_installed", lambda: False)

captured: dict[str, object] = {}

async def fake_graph_phase(root, *, quiet, verbose, on_progress, on_progress_console):
captured["called"] = True
captured["root"] = root
return 0, "GRAPH_STDOUT", "GRAPH_STDERR", True

monkeypatch.setattr(server, "_run_graph_phase", fake_graph_phase)

repo_root = Path(__file__).resolve().parent.parent
monkeypatch.setenv("JAVA_CODEBASE_RAG_SOURCE_ROOT", str(repo_root))
monkeypatch.setenv("JAVA_CODEBASE_RAG_INDEX_DIR", str(tmp_path / "idx"))

buf = io.StringIO()
with redirect_stderr(buf):
out = asyncio.run(server.run_refresh_pipeline(quiet=True))
err = buf.getvalue()

# Vectors were skipped; the graph phase ran (got the resolved source root) and
# succeeded.
assert captured.get("called") is True
assert captured.get("root") == repo_root
assert out.success is True
assert out.phases_run == ["graph"]
assert out.graph_exit_code == 0
# The operator-facing skip line is printed; the cryptic failure is not, anywhere.
assert "vectors skipped" in err
assert "graph-only mode" in err
assert "cocoindex not found" not in err
assert "cocoindex not found" not in (out.message or "")
# The JSON message documents the graph-only outcome.
assert "graph-only" in (out.message or "")
Loading