java-codebase-rag 0.11.2__py3-none-any.whl → 0.12.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. java_codebase_rag-0.12.1.dist-info/METADATA +35 -0
  2. java_codebase_rag-0.12.1.dist-info/RECORD +4 -0
  3. {java_codebase_rag-0.11.2.dist-info → java_codebase_rag-0.12.1.dist-info}/WHEEL +1 -1
  4. java_codebase_rag/_fdlimit.py +0 -56
  5. java_codebase_rag/_stdio.py +0 -32
  6. java_codebase_rag/_version.py +0 -35
  7. java_codebase_rag/absence/__init__.py +0 -0
  8. java_codebase_rag/absence/absence_diagnosis.py +0 -700
  9. java_codebase_rag/absence/absence_types.py +0 -124
  10. java_codebase_rag/absence/absence_vocab.py +0 -460
  11. java_codebase_rag/analysis/__init__.py +0 -0
  12. java_codebase_rag/analysis/pr_analysis.py +0 -563
  13. java_codebase_rag/analysis/resolve_service.py +0 -740
  14. java_codebase_rag/ast/__init__.py +0 -0
  15. java_codebase_rag/ast/ast_java.py +0 -2825
  16. java_codebase_rag/ast/brownfield_events.py +0 -58
  17. java_codebase_rag/ast/chunk_heuristics.py +0 -62
  18. java_codebase_rag/cli.py +0 -1215
  19. java_codebase_rag/cli_format.py +0 -85
  20. java_codebase_rag/cli_progress.py +0 -94
  21. java_codebase_rag/config.py +0 -833
  22. java_codebase_rag/eval/__init__.py +0 -1
  23. java_codebase_rag/eval/ground_truth.py +0 -100
  24. java_codebase_rag/eval/metrics.py +0 -107
  25. java_codebase_rag/eval/runner.py +0 -556
  26. java_codebase_rag/graph/__init__.py +0 -0
  27. java_codebase_rag/graph/build_ast_graph.py +0 -4471
  28. java_codebase_rag/graph/graph_enrich.py +0 -1937
  29. java_codebase_rag/graph/graph_types.py +0 -224
  30. java_codebase_rag/graph/java_ontology.py +0 -465
  31. java_codebase_rag/graph/ladybug_queries.py +0 -2213
  32. java_codebase_rag/graph/path_filtering.py +0 -477
  33. java_codebase_rag/index/__init__.py +0 -0
  34. java_codebase_rag/index/java_index_flow_lancedb.py +0 -734
  35. java_codebase_rag/index/java_index_v1_common.py +0 -33
  36. java_codebase_rag/install_data/__init__.py +0 -0
  37. java_codebase_rag/install_data/agents/explorer-rag-cli.md +0 -108
  38. java_codebase_rag/install_data/agents/explorer-rag-enhanced.md +0 -152
  39. java_codebase_rag/install_data/skills/explore-codebase/SKILL.md +0 -165
  40. java_codebase_rag/install_data/skills/explore-codebase-cli/SKILL.md +0 -107
  41. java_codebase_rag/installer.py +0 -2188
  42. java_codebase_rag/jrag.py +0 -4531
  43. java_codebase_rag/jrag_envelope.py +0 -1107
  44. java_codebase_rag/jrag_hints.py +0 -204
  45. java_codebase_rag/jrag_render.py +0 -926
  46. java_codebase_rag/lance_optimize.py +0 -264
  47. java_codebase_rag/mcp/__init__.py +0 -0
  48. java_codebase_rag/mcp/mcp_hints.py +0 -932
  49. java_codebase_rag/mcp/mcp_v2.py +0 -1916
  50. java_codebase_rag/mcp/server.py +0 -884
  51. java_codebase_rag/pipeline.py +0 -531
  52. java_codebase_rag/progress.py +0 -570
  53. java_codebase_rag/read_payloads.py +0 -781
  54. java_codebase_rag/search/__init__.py +0 -0
  55. java_codebase_rag/search/index_common.py +0 -10
  56. java_codebase_rag/search/search_lancedb.py +0 -1296
  57. java_codebase_rag/search/search_lexical.py +0 -449
  58. java_codebase_rag/search/search_scoring.py +0 -523
  59. java_codebase_rag/watch/__init__.py +0 -0
  60. java_codebase_rag/watch/client.py +0 -230
  61. java_codebase_rag/watch/daemon.py +0 -396
  62. java_codebase_rag/watch/lock.py +0 -201
  63. java_codebase_rag/watch/paths.py +0 -76
  64. java_codebase_rag/watch/protocol.py +0 -122
  65. java_codebase_rag/watch/server.py +0 -273
  66. java_codebase_rag/watch/warm.py +0 -105
  67. java_codebase_rag/watch/watcher.py +0 -370
  68. java_codebase_rag-0.11.2.dist-info/METADATA +0 -331
  69. java_codebase_rag-0.11.2.dist-info/RECORD +0 -71
  70. java_codebase_rag-0.11.2.dist-info/entry_points.txt +0 -4
  71. java_codebase_rag-0.11.2.dist-info/licenses/LICENSE +0 -21
  72. java_codebase_rag-0.11.2.dist-info/top_level.txt +0 -1
  73. /java_codebase_rag/__init__.py → /java_codebase_rag-0.12.1.dist-info/top_level.txt +0 -0
@@ -1,230 +0,0 @@
1
- """IPC client for the ``jrag watch`` daemon: try-the-daemon, cold-fall-back.
2
-
3
- The cold read path is BYTE-IDENTICAL to today: when no daemon is alive (the
4
- common case — including every ``jrag`` subprocess invocation that is not a
5
- ``jrag watch`` process), :func:`get_payload` falls back to the same
6
- ``<cmd>_payload`` core the handler already calls, loading the graph via
7
- ``_load_graph(cfg)`` (a cache hit on the already-loaded singleton). The daemon
8
- is a pure accelerator: it never changes observable output, only latency.
9
-
10
- Contract (pinned by task-8 brief):
11
-
12
- * :func:`is_daemon_alive` -> ``socket_path`` exists AND
13
- :meth:`ProjectLock.read_holder` returns a live pid.
14
- * :func:`request` -> ``response.result`` dict; raises :class:`DaemonUnavailable`
15
- (no daemon / version mismatch / hung) or :class:`DaemonError` (``ok=False``).
16
- * :func:`get_payload` -> the single seam each read handler calls. Tries the
17
- daemon; on :class:`DaemonUnavailable`/:class:`DaemonError` runs the cold
18
- ``cold_core(argparse.Namespace(**args), cfg, _load_graph(cfg))``. On success it
19
- RECONSTRUCTS the payload object from the daemon's serialized dict so the
20
- handler's downstream project+render runs unchanged.
21
-
22
- Reconstruction is the lossless inverse of :func:`watch.server.serialize`:
23
- pydantic ``*Output`` models round-trip via ``model_dump(mode="json")`` /
24
- ``model_validate``; the traversal payloads (callers/callees/flow) are plain
25
- dicts and pass through. The one non-pydantic case is ``find`` query mode, whose
26
- ``rows`` are :class:`SymbolHit` dataclasses accessed by attribute in the renderer
27
- (``row.id``); those are rebuilt via ``SymbolHit(**row)`` so the renderer is
28
- untouched. (The task-8 brief literal said "pass-through" for query mode; that
29
- would break the renderer's attribute access on the hot path, so the rows are
30
- rebuilt instead — see task-8 report.)
31
- """
32
- from __future__ import annotations
33
-
34
- import socket
35
- from typing import TYPE_CHECKING, Any, Callable
36
-
37
- from java_codebase_rag.jrag import _load_graph
38
- from java_codebase_rag.watch.lock import ProjectLock
39
- from java_codebase_rag.watch.paths import socket_path
40
- from java_codebase_rag.watch.protocol import (
41
- PROTOCOL_VERSION,
42
- ProtocolMismatch,
43
- Request,
44
- decode_response,
45
- encode_request,
46
- )
47
-
48
- if TYPE_CHECKING:
49
- from pathlib import Path
50
-
51
- # Connect/read budget so a hung daemon falls back to cold rather than blocking
52
- # the read command. 2s is generous for a local AF_UNIX round trip (the daemon
53
- # answers inline; a healthy response is sub-millisecond).
54
- _DAEMON_TIMEOUT = 2.0
55
-
56
-
57
- class DaemonUnavailable(Exception):
58
- """No live daemon, an unreachable/hung daemon, or a protocol-version mismatch.
59
-
60
- Always triggers the cold fallback in :func:`get_payload`.
61
- """
62
-
63
-
64
- class DaemonError(Exception):
65
- """The daemon answered ``ok=False`` (e.g. ``backend_error`` / ``stale_index``).
66
-
67
- Attributes:
68
- kind: the ``Response.error.kind`` string (``"backend_error"`` etc.).
69
- message: the ``Response.error.message`` string.
70
- """
71
-
72
- def __init__(self, kind: str, message: str) -> None:
73
- self.kind = kind
74
- self.message = message
75
- super().__init__(f"{kind}: {message}")
76
-
77
-
78
- def is_daemon_alive(index_dir: "Path") -> bool:
79
- """True iff the daemon's socket exists AND a live holder pid is recorded.
80
-
81
- The pid check (:meth:`ProjectLock.read_holder`) returns ``None`` for a
82
- missing/empty pid file or a dead/stale pid, so a leftover socket alone does
83
- not count as "alive" — a crashed daemon's socket is ignored and the read
84
- falls back to cold.
85
- """
86
- if not socket_path(index_dir).exists():
87
- return False
88
- return ProjectLock.read_holder(index_dir) is not None
89
-
90
-
91
- def request(index_dir: "Path", cmd: str, args: dict) -> dict:
92
- """Send one command to the daemon and return its ``result`` payload dict.
93
-
94
- Raises:
95
- DaemonUnavailable: not alive, unreachable/hung (timeout), closed
96
- mid-response, or speaking a different protocol version (an older
97
- daemon). All of these trigger the cold fallback.
98
- DaemonError: the daemon responded ``ok=False``.
99
- """
100
- if not is_daemon_alive(index_dir):
101
- raise DaemonUnavailable(f"no live daemon for {index_dir}")
102
-
103
- sock_path = socket_path(index_dir)
104
- try:
105
- with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
106
- sock.settimeout(_DAEMON_TIMEOUT)
107
- sock.connect(str(sock_path))
108
- sock.sendall(encode_request(Request(v=PROTOCOL_VERSION, cmd=cmd, args=args)))
109
- line = _readline(sock)
110
- except OSError as exc:
111
- # Timeout, connection refused, etc. -> cold fallback (never block).
112
- raise DaemonUnavailable(f"daemon unreachable at {sock_path}: {exc}") from exc
113
-
114
- try:
115
- response = decode_response(line)
116
- except (ProtocolMismatch, ValueError) as exc:
117
- # Version mismatch (older daemon) or a blank/partial line (daemon gone)
118
- # -> cold fallback rather than crashing the read command.
119
- raise DaemonUnavailable(f"daemon response undecodable: {exc}") from exc
120
-
121
- if not response.ok:
122
- err = response.error
123
- raise DaemonError(
124
- err.kind if err is not None else "unknown",
125
- err.message if err is not None else "",
126
- )
127
- return response.result
128
-
129
-
130
- def get_payload(cmd: str, args: dict, cfg, *, cold_core: Callable[..., Any]) -> Any:
131
- """Return the payload for ``cmd``, trying the daemon first then cold-falling-back.
132
-
133
- ``args`` is the command's full parsed-namespace dict (``vars(args)``);
134
- ``cold_core`` is the matching ``<cmd>_payload(args, cfg, graph)`` core.
135
-
136
- * Daemon success: the serialized payload dict is RECONSTRUCTED into the same
137
- object the cold core returns, so the handler's render path is unchanged.
138
- * DaemonUnavailable / DaemonError: the cold core runs against
139
- ``_load_graph(cfg)`` — byte-identical to today (the daemon is absent in
140
- every non-``watch`` invocation, and an ``ok=False`` frame re-surfaces as
141
- the cold core's own ``PayloadError`` → identical error envelope + rc).
142
- """
143
- try:
144
- result = request(cfg.index_dir, cmd, args)
145
- except (DaemonUnavailable, DaemonError):
146
- return cold_core(argparse_namespace(args), cfg, _load_graph(cfg))
147
- return _reconstruct(cmd, result)
148
-
149
-
150
- # ---------------------------------------------------------------------------
151
- # internals
152
- # ---------------------------------------------------------------------------
153
-
154
-
155
- def _readline(sock: socket.socket) -> bytes:
156
- """Read until newline (the response is one NDJSON line). Returns whatever was
157
- received before newline/EOF; the caller decodes (and maps blanks to
158
- ``DaemonUnavailable``)."""
159
- buf = b""
160
- while b"\n" not in buf:
161
- chunk = sock.recv(4096)
162
- if not chunk:
163
- break
164
- buf += chunk
165
- return buf
166
-
167
-
168
- def _reconstruct(cmd: str, result: Any) -> Any:
169
- """Rebuild the payload object the cold core returns from the daemon's JSON dict.
170
-
171
- Inverse of :func:`watch.server.serialize`. See module docstring for the
172
- find query-mode ``SymbolHit`` rebuild rationale.
173
- """
174
- if cmd == "search":
175
- from java_codebase_rag.mcp.mcp_v2 import SearchOutput
176
-
177
- return SearchOutput.model_validate(result)
178
-
179
- if cmd == "inspect":
180
- from java_codebase_rag.mcp.mcp_v2 import DescribeOutput
181
-
182
- return {
183
- "describe": DescribeOutput.model_validate(result["describe"]),
184
- "node_id": result["node_id"],
185
- "node_fqn": result["node_fqn"],
186
- "file_location": result["file_location"],
187
- }
188
-
189
- if cmd == "find":
190
- # The find payload is always a {"mode": "query"|"filter", ...} dict.
191
- mode = result.get("mode")
192
- if mode == "filter":
193
- from java_codebase_rag.mcp.mcp_v2 import FindOutput
194
-
195
- return {
196
- "mode": "filter",
197
- "kind": result["kind"],
198
- "out": FindOutput.model_validate(result["out"]),
199
- "limit": result["limit"],
200
- }
201
- # query mode: rows are SymbolHit dataclasses (attribute access in the
202
- # renderer), rebuilt here so rendering is byte-identical.
203
- from java_codebase_rag.graph.ladybug_queries import SymbolHit
204
-
205
- return {
206
- "mode": "query",
207
- "rows": [SymbolHit(**row) for row in result["rows"]],
208
- "raw_truncated": result["raw_truncated"],
209
- "post_filter_active": result["post_filter_active"],
210
- "limit": result["limit"],
211
- "query": result["query"],
212
- "kinds": result["kinds"],
213
- "matched_mode": result["matched_mode"],
214
- "identifier_matched": result["identifier_matched"],
215
- }
216
-
217
- # callers / callees / flow: plain dicts already (node/edge values are dicts
218
- # of JSON-native scalars); pass through unchanged.
219
- return result
220
-
221
-
222
- def argparse_namespace(args: dict):
223
- """Build an ``argparse.Namespace`` from the args dict (deferred import).
224
-
225
- Imported lazily so this module's top level stays free of the ``argparse``
226
- dependency needed only on the cold path; also a single seam for the cold
227
- core's ``args`` reconstruction."""
228
- import argparse
229
-
230
- return argparse.Namespace(**args)
@@ -1,396 +0,0 @@
1
- """The ``jrag watch`` daemon process: assemble the watch components and run them.
2
-
3
- ``WatchDaemon`` is the capstone that wires together the building blocks built in
4
- Tasks 2-10:
5
-
6
- * :class:`watch.lock.ProjectLock` — single-writer mutual exclusion per project.
7
- * :class:`watch.warm.WarmResources` — the warm embedding model + the read-only
8
- graph reader (and the graph copy-on-write snapshot lifecycle).
9
- * :class:`watch.server.WatchServer` — the AF_UNIX socket server that dispatches
10
- read commands to the payload cores and ships the serialized payload.
11
- * :class:`watch.watcher.SourceWatcher` — the file watcher + debounced per-type
12
- reindex dispatcher.
13
-
14
- Lifecycle (``run_foreground``):
15
-
16
- 1. Acquire the project lock. Held elsewhere -> stderr line + ``return 2``.
17
- Unsupported platform -> stderr line + ``return 2``.
18
- 2. EAGERLY warm the embedding model — but ONLY when the vector stack is
19
- installed — so a load failure fails fast (stderr + ``on_event("error", …)``
20
- + lock release + ``return 2``). On graph-only installs (macOS Intel: PEP
21
- 508 excludes sentence_transformers/lancedb) this step is skipped and the
22
- daemon serves warm lexical/graph-only search instead; the read path
23
- (``mcp_v2.search_v2``) degrades to lexical on its own.
24
- 3. Install SIGINT/SIGTERM handlers that set a stop flag.
25
- 4. ``server.start()`` then ``watcher.start()``.
26
- 5. Write the state file (``paths.state_path``) so ``--status``/``--stop`` from
27
- another process can see current truth.
28
- 6. Render a ``rich`` Live status panel (watcher state, last reindex) and
29
- block on a wait loop until the stop flag is set.
30
- 7. Tear down in order — ``watcher.stop()`` → ``server.shutdown()`` →
31
- ``lock.release()`` → unlink socket + state file — and terminate with
32
- ``os._exit(0)``. The explicit ``os._exit`` (mirroring
33
- ``jrag._console_script_main``) skips interpreter finalization, which dodges
34
- a racy pyarrow/lance worker-thread SIGABRT once the daemon has served a
35
- ``search`` (the read path loads lancedb in-process). ``run_foreground``
36
- therefore NEVER returns normally on the serving path.
37
- """
38
- from __future__ import annotations
39
-
40
- import json
41
- import logging
42
- import os
43
- import signal
44
- import sys
45
- import threading
46
- import time
47
- from typing import TYPE_CHECKING, Any
48
-
49
- from java_codebase_rag.pipeline import vector_stack_installed
50
- from java_codebase_rag.watch import paths
51
- from java_codebase_rag.watch.lock import (
52
- LockHeldError,
53
- ProjectLock,
54
- WatchUnsupportedPlatform,
55
- )
56
- from java_codebase_rag.watch.server import WatchServer
57
- from java_codebase_rag.watch.warm import WarmResources
58
- from java_codebase_rag.watch.watcher import SourceWatcher
59
-
60
- if TYPE_CHECKING:
61
- from java_codebase_rag.config import ResolvedOperatorConfig
62
-
63
- # State-file rewrites are throttled so a busy reindex burst does not hammer disk.
64
- # The initial write (``force=True``) and the last reindex both go through
65
- # immediately; ``--status`` readers tolerate a slightly-stale ``last_reindex``.
66
- _STATE_WRITE_MIN_INTERVAL_S = 1.0
67
- # The blocking loop's tick: how often the Live panel refreshes and how quickly a
68
- # stop signal is observed. 0.5 s is responsive without burning CPU.
69
- _LOOP_TICK_S = 0.5
70
-
71
- log = logging.getLogger(__name__)
72
-
73
-
74
- class WatchDaemon:
75
- """Assemble the watch components and serve until interrupted.
76
-
77
- The daemon process holds the project lock for its entire lifetime; the state
78
- file is the cross-process truth that ``--status``/``--stop`` read.
79
- """
80
-
81
- def __init__(self, cfg: "ResolvedOperatorConfig") -> None:
82
- self.cfg = cfg
83
- # Probed once (cheap: 3x importlib.util.find_spec). When False (graph-only
84
- # install — macOS Intel), the daemon skips the embedding-model warm-up and
85
- # the cocoindex vectors reindex; the read path degrades to lexical on its own.
86
- self._vector_enabled = vector_stack_installed()
87
- self.lock = ProjectLock(cfg.index_dir)
88
- self.warm = WarmResources(cfg)
89
- self.server = WatchServer(self.warm, cfg)
90
- self.watcher = SourceWatcher(
91
- cfg,
92
- self.warm,
93
- debounce_ms=cfg.watch_debounce_ms,
94
- backend=cfg.watch_backend,
95
- poll_interval_ms=cfg.watch_poll_interval_ms,
96
- on_event=self._record,
97
- )
98
-
99
- self._stop = threading.Event()
100
- self._state_lock = threading.Lock()
101
- self._last_state_write = 0.0
102
- self._state: dict[str, Any] = {
103
- "started_at": None,
104
- "pid": None,
105
- "socket": str(paths.socket_path(cfg.index_dir)),
106
- # Display label derived from the install-time probe above, NOT a live
107
- # search-capability check — the read path's actual lexical/vector choice
108
- # is mcp_v2's (``_ensure_vector_backend``). The two agree under the PEP
109
- # 508 markers (the vector trio is present or absent together). Surfaced
110
- # in the status panel and ``jrag watch --status``; omitted from display
111
- # on the normal (vector) path to avoid noise.
112
- "mode": "lexical" if not self._vector_enabled else "vector",
113
- "last_reindex_at": None,
114
- "last_reindex_kind": None,
115
- "reindex_count": 0,
116
- # ``queries_served`` is left at 0 for v1: wiring a query-count
117
- # callback out of ``WatchServer`` (Task 7, approved) is out of scope
118
- # for this task's commit surface and the brief marks it optional.
119
- "queries_served": 0,
120
- }
121
-
122
- # ------------------------------------------------------------------
123
- # public lifecycle
124
- # ------------------------------------------------------------------
125
-
126
- def run_foreground(self) -> int:
127
- """Serve until SIGINT/SIGTERM, then tear down and ``os._exit(0)``.
128
-
129
- Early failure paths (lock held, unsupported platform, model-load error)
130
- return ``2`` normally — they occur before the server accepts connections,
131
- so no lance worker threads exist and interpreter finalization is safe.
132
-
133
- The serving path (after ``server.start()``) terminates ONLY via
134
- ``os._exit(0)`` in :meth:`_shutdown`; the trailing ``return 0`` is
135
- unreachable and exists to satisfy the ``-> int`` contract.
136
- """
137
- # 1. Acquire the project lock (single writer per project).
138
- try:
139
- self.lock.acquire()
140
- except LockHeldError as exc:
141
- print(f"jrag watch: index in use by PID {exc.pid}", file=sys.stderr)
142
- return 2
143
- except WatchUnsupportedPlatform:
144
- print("jrag watch: watch mode requires macOS/Linux", file=sys.stderr)
145
- return 2
146
-
147
- # 2. Eagerly warm the embedding model so a load failure fails fast (before
148
- # the server accepts a single query) — but ONLY when the vector stack is
149
- # installed. The model is the only heavy, failure-prone resource that is
150
- # not lazy on the read path. On a graph-only install (macOS Intel) there
151
- # is no vector stack to warm; the daemon serves lexical/graph-only search
152
- # and ``mcp_v2.search_v2`` degrades on its own, so we skip straight to
153
- # serving rather than failing on a missing ``sentence_transformers``.
154
- if self._vector_enabled:
155
- try:
156
- self.warm.model()
157
- except Exception as exc: # noqa: BLE001 — report any load failure, then bail
158
- print(f"jrag watch: failed to load embedding model: {exc}", file=sys.stderr)
159
- self._record("error", {"phase": "model_load", "error": repr(exc)})
160
- self.lock.release()
161
- return 2
162
- else:
163
- print(
164
- "jrag watch: vector stack unavailable — serving lexical (graph-only) search",
165
- file=sys.stderr,
166
- )
167
-
168
- # 3. Install stop-signal handlers (main thread only).
169
- signal.signal(signal.SIGINT, self._on_signal)
170
- signal.signal(signal.SIGTERM, self._on_signal)
171
-
172
- # 4a. Server start: bind the socket before starting the watcher so a
173
- # query the moment the panel renders is already servable.
174
- #
175
- # We hold the EXCLUSIVE project lock, so we are the unique legitimate
176
- # owner of this socket path: any pre-existing socket file is a corpse
177
- # from a crashed prior daemon and MUST be cleared before bind, else
178
- # AF_UNIX bind() fails with EADDRINUSE. ``server.start`` also defends
179
- # against a stale socket for callers that do NOT hold the lock, but its
180
- # guard (``read_holder is None``) is inert here precisely because we
181
- # hold the lock — so the daemon clears its own stale socket itself.
182
- stale_sock = paths.socket_path(self.cfg.index_dir)
183
- try:
184
- stale_sock.unlink()
185
- except FileNotFoundError:
186
- pass
187
- except OSError:
188
- log.warning("could not unlink stale socket %s", stale_sock, exc_info=True)
189
- try:
190
- self.server.start()
191
- except Exception as exc: # noqa: BLE001 — socket bind failure is fatal-but-reported
192
- print(f"jrag watch: failed to start server: {exc}", file=sys.stderr)
193
- self.lock.release()
194
- self._cleanup_runtime_files()
195
- return 2
196
- # 4b. Watcher start.
197
- try:
198
- self.watcher.start()
199
- except Exception as exc: # noqa: BLE001 — reported; server already up so shut it down
200
- print(f"jrag watch: failed to start watcher: {exc}", file=sys.stderr)
201
- self.server.shutdown()
202
- self._cleanup_runtime_files()
203
- self.lock.release()
204
- return 2
205
-
206
- # 5. Write the initial state file (force, so --status sees truth at once).
207
- self._state["started_at"] = time.time()
208
- self._state["pid"] = os.getpid()
209
- self._write_state()
210
-
211
- # 6 + 7. Serve, then tear down. _shutdown ends with os._exit(0) so the
212
- # finally never falls through; the return is unreachable.
213
- try:
214
- self._serve_until_stopped()
215
- finally:
216
- self._shutdown()
217
- return 0 # pragma: no cover — os._exit in _shutdown
218
-
219
- # ------------------------------------------------------------------
220
- # event recording (called from the watcher debounce thread + the UI loop)
221
- # ------------------------------------------------------------------
222
-
223
- def _record(self, kind: str, detail: dict[str, Any]) -> None:
224
- """Update in-memory state from a watcher event; throttle state rewrites.
225
-
226
- Called on the watcher's debounce worker thread, so all state mutation is
227
- under ``_state_lock``. The state file is rewritten at most once per
228
- ``_STATE_WRITE_MIN_INTERVAL_S`` so ``--status`` readers see recent truth
229
- without disk churn during a reindex burst.
230
- """
231
- with self._state_lock:
232
- if kind == "indexing_done":
233
- self._state["last_reindex_at"] = time.time()
234
- self._state["last_reindex_kind"] = "+".join(detail.get("kinds", []))
235
- self._state["reindex_count"] += 1
236
- elif kind == "indexing_started":
237
- self._state["last_reindex_kind"] = (
238
- "indexing:" + "+".join(detail.get("kinds", []))
239
- )
240
- elif kind == "error":
241
- self._state["last_error"] = {
242
- "phase": detail.get("phase"),
243
- "at": time.time(),
244
- "detail": detail,
245
- }
246
- self._maybe_write_state_locked()
247
-
248
- # ------------------------------------------------------------------
249
- # serve loop + status panel
250
- # ------------------------------------------------------------------
251
-
252
- def _serve_until_stopped(self) -> None:
253
- """Render the status panel and block until the stop flag is set.
254
-
255
- On a non-TTY stdio (detached, piped, tests) the Live region is skipped in
256
- favor of a single startup line — ``rich.Live`` on a pipe reprints the
257
- whole panel on every update and would flood the redirect log.
258
- """
259
- from rich.console import Console
260
-
261
- console = Console()
262
- live = None
263
- if console.is_terminal:
264
- try:
265
- from rich.live import Live
266
-
267
- live = Live(
268
- self._render_panel(),
269
- console=console,
270
- refresh_per_second=4,
271
- transient=False,
272
- )
273
- live.start()
274
- except Exception: # noqa: BLE001 — Live is cosmetic; never block serving
275
- live = None
276
- if live is None:
277
- print(
278
- f"jrag watch: serving on {self._state['socket']} "
279
- f"(pid {os.getpid()})",
280
- flush=True,
281
- )
282
-
283
- try:
284
- while not self._stop.is_set():
285
- if live is not None:
286
- try:
287
- live.update(self._render_panel())
288
- except Exception: # noqa: BLE001 — cosmetic
289
- pass
290
- # Event.wait returns True as soon as the flag is set, so a stop
291
- # signal is observed within one tick rather than the full window.
292
- self._stop.wait(_LOOP_TICK_S)
293
- finally:
294
- if live is not None:
295
- try:
296
- live.stop()
297
- except Exception: # noqa: BLE001 — cosmetic
298
- pass
299
-
300
- def _render_panel(self):
301
- """Build the ``rich`` status table from the current in-memory state."""
302
- from rich.table import Table
303
-
304
- with self._state_lock:
305
- state = dict(self._state)
306
- table = Table(title=f"jrag watch (pid {os.getpid()})", show_header=False, box=None)
307
- table.add_row("socket", str(state.get("socket")))
308
- if state.get("mode") == "lexical":
309
- table.add_row("mode", "lexical (graph-only)")
310
- table.add_row("reindex count", str(state.get("reindex_count", 0)))
311
- last_kind = state.get("last_reindex_kind")
312
- last_at = state.get("last_reindex_at")
313
- if last_kind:
314
- when = time.strftime("%H:%M:%S", time.localtime(last_at)) if last_at else "—"
315
- table.add_row("last reindex", f"{last_kind} ({when})")
316
- else:
317
- table.add_row("last reindex", "—")
318
- return table
319
-
320
- # ------------------------------------------------------------------
321
- # shutdown
322
- # ------------------------------------------------------------------
323
-
324
- def _shutdown(self) -> None:
325
- """Tear down watcher → server → lock, remove runtime files, ``os._exit(0)``.
326
-
327
- Each step is best-effort: a failure in one must not skip the rest, and
328
- the process MUST terminate via ``os._exit(0)`` (never a normal return)
329
- to avoid the lance worker-thread SIGABRT at finalization once the server
330
- has served a ``search`` query.
331
- """
332
- try:
333
- self.watcher.stop()
334
- except Exception: # noqa: BLE001 — teardown must continue
335
- log.warning("watcher.stop raised during shutdown", exc_info=True)
336
- try:
337
- self.server.shutdown()
338
- except Exception: # noqa: BLE001
339
- log.warning("server.shutdown raised during shutdown", exc_info=True)
340
- try:
341
- self.lock.release()
342
- except Exception: # noqa: BLE001
343
- log.warning("lock.release raised during shutdown", exc_info=True)
344
- self._cleanup_runtime_files()
345
- sys.stdout.flush()
346
- sys.stderr.flush()
347
- os._exit(0)
348
-
349
- # ------------------------------------------------------------------
350
- # helpers
351
- # ------------------------------------------------------------------
352
-
353
- def _on_signal(self, signum, frame) -> None: # noqa: ARG002 — signal API
354
- """SIGINT/SIGTERM handler: flag the serve loop to stop (main thread)."""
355
- self._stop.set()
356
-
357
- def _cleanup_runtime_files(self) -> None:
358
- """Remove the socket and state file (idempotent, best-effort)."""
359
- for path in (
360
- paths.socket_path(self.cfg.index_dir),
361
- paths.state_path(self.cfg.index_dir),
362
- ):
363
- try:
364
- path.unlink()
365
- except FileNotFoundError:
366
- pass
367
- except OSError:
368
- log.warning("could not unlink %s", path, exc_info=True)
369
-
370
- def _write_state(self) -> None:
371
- """Unconditionally write the state JSON now (initial write).
372
-
373
- Acquires ``_state_lock``; the throttled re-write path is
374
- :meth:`_maybe_write_state_locked`, called by :meth:`_record` which
375
- already holds the lock.
376
- """
377
- with self._state_lock:
378
- self._write_state_locked()
379
-
380
- def _maybe_write_state_locked(self) -> None:
381
- """Throttled state write; caller MUST hold ``_state_lock``."""
382
- now = time.monotonic()
383
- if now - self._last_state_write >= _STATE_WRITE_MIN_INTERVAL_S:
384
- self._write_state_locked()
385
-
386
- def _write_state_locked(self) -> None:
387
- """Write the JSON state file (best-effort); caller MUST hold ``_state_lock``."""
388
- path = paths.state_path(self.cfg.index_dir)
389
- data = dict(self._state)
390
- try:
391
- tmp = path.with_suffix(path.suffix + ".tmp")
392
- tmp.write_text(json.dumps(data))
393
- os.replace(tmp, path)
394
- self._last_state_write = time.monotonic()
395
- except OSError:
396
- log.warning("could not write state file %s", path, exc_info=True)