diff --git a/java_codebase_rag/cli.py b/java_codebase_rag/cli.py index fb9967e..93cf185 100644 --- a/java_codebase_rag/cli.py +++ b/java_codebase_rag/cli.py @@ -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, @@ -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 diff --git a/java_codebase_rag/pipeline.py b/java_codebase_rag/pipeline.py index c3e35e5..1a21eeb 100644 --- a/java_codebase_rag/pipeline.py +++ b/java_codebase_rag/pipeline.py @@ -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 diff --git a/server.py b/server.py index 95e4280..b7377a3 100644 --- a/server.py +++ b/server.py @@ -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 ( @@ -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, @@ -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 @@ -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}" diff --git a/tests/test_cli_progress_stdout_invariant.py b/tests/test_cli_progress_stdout_invariant.py index 81fd670..33dfc6d 100644 --- a/tests/test_cli_progress_stdout_invariant.py +++ b/tests/test_cli_progress_stdout_invariant.py @@ -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) diff --git a/tests/test_graph_only_boot.py b/tests/test_graph_only_boot.py index f050b74..1948247 100644 --- a/tests/test_graph_only_boot.py +++ b/tests/test_graph_only_boot.py @@ -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 "")