java-codebase-rag 0.12.0__py3-none-any.whl → 0.12.2__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 (77) hide show
  1. java_codebase_rag-0.12.2.dist-info/METADATA +35 -0
  2. java_codebase_rag-0.12.2.dist-info/RECORD +4 -0
  3. {java_codebase_rag-0.12.0.dist-info → java_codebase_rag-0.12.2.dist-info}/WHEEL +1 -1
  4. java_codebase_rag/_deprecation.py +0 -103
  5. java_codebase_rag/_fdlimit.py +0 -56
  6. java_codebase_rag/_stdio.py +0 -32
  7. java_codebase_rag/_version.py +0 -35
  8. java_codebase_rag/absence/__init__.py +0 -0
  9. java_codebase_rag/absence/absence_diagnosis.py +0 -700
  10. java_codebase_rag/absence/absence_types.py +0 -124
  11. java_codebase_rag/absence/absence_vocab.py +0 -460
  12. java_codebase_rag/analysis/__init__.py +0 -0
  13. java_codebase_rag/analysis/pr_analysis.py +0 -563
  14. java_codebase_rag/analysis/resolve_service.py +0 -740
  15. java_codebase_rag/ast/__init__.py +0 -0
  16. java_codebase_rag/ast/ast_java.py +0 -2847
  17. java_codebase_rag/ast/ast_kotlin.py +0 -1794
  18. java_codebase_rag/ast/brownfield_events.py +0 -58
  19. java_codebase_rag/ast/chunk_heuristics.py +0 -83
  20. java_codebase_rag/ast/language.py +0 -117
  21. java_codebase_rag/cli.py +0 -1215
  22. java_codebase_rag/cli_dispatch.py +0 -251
  23. java_codebase_rag/cli_format.py +0 -85
  24. java_codebase_rag/cli_progress.py +0 -94
  25. java_codebase_rag/config.py +0 -833
  26. java_codebase_rag/eval/__init__.py +0 -1
  27. java_codebase_rag/eval/ground_truth.py +0 -100
  28. java_codebase_rag/eval/metrics.py +0 -107
  29. java_codebase_rag/eval/runner.py +0 -556
  30. java_codebase_rag/graph/__init__.py +0 -0
  31. java_codebase_rag/graph/build_ast_graph.py +0 -4593
  32. java_codebase_rag/graph/graph_enrich.py +0 -1940
  33. java_codebase_rag/graph/graph_types.py +0 -224
  34. java_codebase_rag/graph/java_ontology.py +0 -465
  35. java_codebase_rag/graph/ladybug_queries.py +0 -2213
  36. java_codebase_rag/graph/path_filtering.py +0 -509
  37. java_codebase_rag/index/__init__.py +0 -0
  38. java_codebase_rag/index/java_index_flow_lancedb.py +0 -879
  39. java_codebase_rag/index/java_index_v1_common.py +0 -33
  40. java_codebase_rag/install_data/__init__.py +0 -0
  41. java_codebase_rag/install_data/agents/explorer-rag-cli.md +0 -110
  42. java_codebase_rag/install_data/agents/explorer-rag-enhanced.md +0 -152
  43. java_codebase_rag/install_data/skills/explore-codebase/SKILL.md +0 -165
  44. java_codebase_rag/install_data/skills/explore-codebase-cli/SKILL.md +0 -107
  45. java_codebase_rag/installer.py +0 -2188
  46. java_codebase_rag/jrag.py +0 -4545
  47. java_codebase_rag/jrag_envelope.py +0 -1107
  48. java_codebase_rag/jrag_hints.py +0 -204
  49. java_codebase_rag/jrag_render.py +0 -926
  50. java_codebase_rag/lance_optimize.py +0 -264
  51. java_codebase_rag/mcp/__init__.py +0 -0
  52. java_codebase_rag/mcp/mcp_hints.py +0 -932
  53. java_codebase_rag/mcp/mcp_v2.py +0 -1916
  54. java_codebase_rag/mcp/server.py +0 -886
  55. java_codebase_rag/pipeline.py +0 -531
  56. java_codebase_rag/progress.py +0 -570
  57. java_codebase_rag/read_payloads.py +0 -781
  58. java_codebase_rag/search/__init__.py +0 -0
  59. java_codebase_rag/search/index_common.py +0 -10
  60. java_codebase_rag/search/search_lancedb.py +0 -1296
  61. java_codebase_rag/search/search_lexical.py +0 -449
  62. java_codebase_rag/search/search_scoring.py +0 -537
  63. java_codebase_rag/watch/__init__.py +0 -0
  64. java_codebase_rag/watch/client.py +0 -230
  65. java_codebase_rag/watch/daemon.py +0 -396
  66. java_codebase_rag/watch/lock.py +0 -201
  67. java_codebase_rag/watch/paths.py +0 -76
  68. java_codebase_rag/watch/protocol.py +0 -122
  69. java_codebase_rag/watch/server.py +0 -273
  70. java_codebase_rag/watch/warm.py +0 -105
  71. java_codebase_rag/watch/watcher.py +0 -394
  72. java_codebase_rag-0.12.0.dist-info/METADATA +0 -340
  73. java_codebase_rag-0.12.0.dist-info/RECORD +0 -75
  74. java_codebase_rag-0.12.0.dist-info/entry_points.txt +0 -5
  75. java_codebase_rag-0.12.0.dist-info/licenses/LICENSE +0 -21
  76. java_codebase_rag-0.12.0.dist-info/top_level.txt +0 -1
  77. /java_codebase_rag/__init__.py → /java_codebase_rag-0.12.2.dist-info/top_level.txt +0 -0
@@ -1,201 +0,0 @@
1
- """Project-level pidfile + ``fcntl.flock`` mutual exclusion for ``jrag watch``.
2
-
3
- ``ProjectLock`` is the single-writer primitive the watch daemon (Task 11)
4
- acquires so that at most one watcher — and no concurrent manual
5
- ``jrag increment`` — runs per project.
6
-
7
- The lock is keyed on the project's index dir: the pidfile path comes from
8
- ``paths.pid_path(index_dir)`` (Task 3). We hold an advisory exclusive flock on
9
- that file for the lifetime of the ``ProjectLock`` object; the file also carries
10
- the holder's pid so other tools can report who holds it (``read_holder``).
11
-
12
- Unix-only by design. The ``fcntl`` import is guarded so that, on a platform
13
- without it, constructing a ``ProjectLock`` fails cleanly with
14
- ``WatchUnsupportedPlatform`` instead of crashing at import time.
15
- """
16
-
17
- import os
18
- from pathlib import Path
19
-
20
- try: # Unix-only; absent on Windows.
21
- import fcntl
22
- except ImportError: # pragma: no cover - exercised via monkeypatch in tests.
23
- fcntl = None
24
-
25
- from .paths import pid_path
26
-
27
-
28
- def _read_pid_file(path: Path):
29
- """Return the integer pid in ``path``, or ``None``.
30
-
31
- ``None`` covers: missing file, empty contents, or non-integer contents.
32
- """
33
- try:
34
- raw = path.read_text().strip()
35
- except (FileNotFoundError, OSError):
36
- return None
37
- if not raw:
38
- return None
39
- try:
40
- return int(raw)
41
- except ValueError:
42
- return None
43
-
44
-
45
- class LockHeldError(Exception):
46
- """Raised by ``ProjectLock.acquire`` when the project lock is held elsewhere.
47
-
48
- Attributes:
49
- pid: the pid currently recorded in the pid file, or ``None`` if it could
50
- not be read/parsed (e.g. the file was empty).
51
- path: the pid file ``Path``.
52
- """
53
-
54
- def __init__(self, pid, path: Path):
55
- self.pid = pid
56
- self.path = path
57
- super().__init__(f"project lock held by pid={pid!r} at {path}")
58
-
59
-
60
- class WatchUnsupportedPlatform(Exception):
61
- """Raised when the host platform lacks ``fcntl`` (the daemon is Unix-only)."""
62
-
63
-
64
- class ProjectLock:
65
- """Exclusive per-project lock backed by a pidfile + ``fcntl.flock``.
66
-
67
- The flock is held for the lifetime of the object: ``acquire`` opens the pid
68
- file and takes ``LOCK_EX | LOCK_NB`` on it, retaining the file handle;
69
- ``release`` closes that handle (which releases the flock) and unlinks the
70
- pid file if it still records our pid.
71
- """
72
-
73
- def __init__(self, index_dir: Path):
74
- if fcntl is None:
75
- raise WatchUnsupportedPlatform(
76
- "jrag watch requires fcntl, which is unavailable on this platform"
77
- )
78
- self.index_dir = index_dir
79
- self.pid_path: Path = pid_path(index_dir)
80
- self._fh = None # retained file handle while we hold the flock
81
-
82
- # ------------------------------------------------------------------
83
- # pid file helpers
84
- # ------------------------------------------------------------------
85
-
86
- def _read_pid(self):
87
- """Return the integer pid recorded in our pid file, or ``None``."""
88
- return _read_pid_file(self.pid_path)
89
-
90
- # ------------------------------------------------------------------
91
- # acquire / release
92
- # ------------------------------------------------------------------
93
-
94
- def acquire(self) -> None:
95
- """Take the exclusive lock, writing our pid into the pid file.
96
-
97
- Opens the pid file read/write (creating it if absent), then takes
98
- ``LOCK_EX | LOCK_NB``. If another holder blocks us, raise
99
- ``LockHeldError`` carrying the pid recorded in the file (or ``None`` if
100
- unreadable). On success, (over)write our pid and keep the handle open.
101
- """
102
- # "r+" preserves an existing holder's pid so we can report it on
103
- # contention; fall back to "w+" only when the file does not yet exist
104
- # (so we never truncate a real pid file before reading it).
105
- try:
106
- fh = open(self.pid_path, "r+")
107
- except FileNotFoundError:
108
- fh = open(self.pid_path, "w+")
109
-
110
- try:
111
- fcntl.flock(fh.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
112
- except BlockingIOError:
113
- fh.close()
114
- raise LockHeldError(self._read_pid(), self.pid_path)
115
- except OSError:
116
- # Unexpected flock failure — don't leak the handle.
117
- fh.close()
118
- raise
119
-
120
- # We hold the lock: record our pid (overwriting any stale value).
121
- fh.seek(0)
122
- fh.truncate()
123
- fh.write(f"{os.getpid()}\n")
124
- fh.flush()
125
- self._fh = fh
126
-
127
- def release(self) -> None:
128
- """Release the flock and, if the pid file still names us, unlink it.
129
-
130
- Closing the handle releases the flock. We only unlink the pid file when
131
- its contents still equal our pid, so we never clobber a successor's file
132
- if they acquired between our close and our unlink.
133
- """
134
- if self._fh is not None:
135
- try:
136
- self._fh.close()
137
- finally:
138
- self._fh = None
139
-
140
- if self._read_pid() == os.getpid():
141
- try:
142
- self.pid_path.unlink()
143
- except FileNotFoundError:
144
- pass
145
-
146
- # ------------------------------------------------------------------
147
- # introspection
148
- # ------------------------------------------------------------------
149
-
150
- def is_holder(self) -> bool:
151
- """True iff this lock currently holds the project lock.
152
-
153
- Implementation note on flock semantics: ``flock`` treats separate file
154
- descriptors even within a single process as independent holders, so a
155
- probe handle CANNOT acquire ``LOCK_EX | LOCK_NB`` while our retained
156
- handle holds it. The probe therefore FAILS exactly when the lock is
157
- held (by us or another). Combined with the pid file recording our own
158
- pid, that means: probe blocked AND our pid on disk => we are the holder.
159
-
160
- (The prose in the task brief said the probe would "succeed"; on Unix
161
- flock the polarity is inverted. See the task-2 report.)
162
- """
163
- try:
164
- probe = open(self.pid_path, "r+")
165
- except FileNotFoundError:
166
- return False
167
- try:
168
- try:
169
- fcntl.flock(probe.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
170
- except BlockingIOError:
171
- # Held by someone; it is us iff our pid is on disk.
172
- return self._read_pid() == os.getpid()
173
- # Probe acquired the lock => it was free => we do not hold it.
174
- fcntl.flock(probe.fileno(), fcntl.LOCK_UN)
175
- return False
176
- finally:
177
- probe.close()
178
-
179
- @classmethod
180
- def read_holder(cls, index_dir: Path):
181
- """Return the live holder's pid, or ``None`` if the lock is free/stale.
182
-
183
- Reads the pid file and returns the recorded pid iff a process with that
184
- pid is currently alive (``os.kill(pid, 0)``); otherwise ``None`` (file
185
- missing, unreadable, or naming a dead/stale pid). Does NOT take or test
186
- the flock, and does not require ``fcntl``.
187
- """
188
- path = pid_path(index_dir)
189
- pid = _read_pid_file(path)
190
- if pid is None:
191
- return None
192
- try:
193
- os.kill(pid, 0)
194
- except ProcessLookupError:
195
- # No such process => stale.
196
- return None
197
- except PermissionError:
198
- # The pid exists but is not ours to signal — it is alive, just owned
199
- # by another user, so it is a legitimate holder.
200
- return pid
201
- return pid
@@ -1,76 +0,0 @@
1
- """Runtime socket/pid/state path derivation for the jrag watch daemon.
2
-
3
- Pure-path logic only — no sockets, no I/O except mkdir for the runtime dir.
4
- No dependencies on other watch modules.
5
- """
6
-
7
- import getpass
8
- import hashlib
9
- import os
10
- import sys
11
- import tempfile
12
- from pathlib import Path
13
-
14
-
15
- def runtime_dir() -> Path:
16
- """Return the per-user runtime directory, created if missing.
17
-
18
- Resolution order:
19
- 1. env XDG_RUNTIME_DIR
20
- 2. env TMPDIR
21
- 3. ~/Library/Caches/JragWatch on macOS (sys.platform == "darwin")
22
- 4. tempfile.gettempdir() / f"jrag-watch-{getpass.getuser()}"
23
-
24
- The directory is created with parents=True, exist_ok=True before returning.
25
- """
26
- # 1. XDG_RUNTIME_DIR
27
- if "XDG_RUNTIME_DIR" in os.environ:
28
- rt_dir = Path(os.environ["XDG_RUNTIME_DIR"])
29
- # 2. TMPDIR
30
- elif "TMPDIR" in os.environ:
31
- rt_dir = Path(os.environ["TMPDIR"])
32
- # 3. macOS-specific path
33
- elif sys.platform == "darwin":
34
- home = Path(os.environ.get("HOME") or os.path.expanduser("~"))
35
- rt_dir = home / "Library" / "Caches" / "JragWatch"
36
- # 4. Fallback
37
- else:
38
- rt_dir = Path(tempfile.gettempdir()) / f"jrag-watch-{getpass.getuser()}"
39
-
40
- # Create if missing
41
- rt_dir.mkdir(parents=True, exist_ok=True)
42
- return rt_dir
43
-
44
-
45
- def project_key(index_dir: Path) -> str:
46
- """Return the first 12 hex characters of SHA256 of the resolved index_dir path.
47
-
48
- The path is resolved before hashing so symlinks/relative paths are stable.
49
- """
50
- resolved = str(index_dir.resolve())
51
- hash_hex = hashlib.sha256(resolved.encode()).hexdigest()
52
- return hash_hex[:12]
53
-
54
-
55
- def socket_path(index_dir: Path) -> Path:
56
- """Return the Unix socket path for a given index_dir.
57
-
58
- Format: runtime_dir() / "jrag-watch-{project_key(index_dir)}.sock"
59
- """
60
- return runtime_dir() / f"jrag-watch-{project_key(index_dir)}.sock"
61
-
62
-
63
- def pid_path(index_dir: Path) -> Path:
64
- """Return the pidfile path for a given index_dir.
65
-
66
- Format: runtime_dir() / "jrag-watch-{project_key(index_dir)}.pid"
67
- """
68
- return runtime_dir() / f"jrag-watch-{project_key(index_dir)}.pid"
69
-
70
-
71
- def state_path(index_dir: Path) -> Path:
72
- """Return the state file path for a given index_dir.
73
-
74
- Format: runtime_dir() / "jrag-watch-{project_key(index_dir)}.state"
75
- """
76
- return runtime_dir() / f"jrag-watch-{project_key(index_dir)}.state"
@@ -1,122 +0,0 @@
1
- """IPC request/response contract + NDJSON codec for jrag watch daemon.
2
-
3
- Pure data + codec logic: dataclasses, protocol version, valid commands,
4
- encode/decode to newline-delimited JSON, and protocol mismatch exception.
5
- No sockets, no I/O, no dependency on other watch modules.
6
- """
7
-
8
- import json
9
- from dataclasses import asdict, dataclass
10
- from typing import Any
11
-
12
- PROTOCOL_VERSION: int = 1
13
-
14
- VALID_CMDS: frozenset[str] = frozenset({
15
- "search",
16
- "find",
17
- "inspect",
18
- "callers",
19
- "callees",
20
- "flow",
21
- })
22
-
23
- # Error kind constants — used as Response.ErrorShape.kind values.
24
- ERR_UNKNOWN_COMMAND = "unknown_command"
25
- ERR_BAD_ARGS = "bad_args"
26
- ERR_BACKEND_ERROR = "backend_error"
27
- ERR_STALE_INDEX = "stale_index"
28
- ERR_BUSY = "busy"
29
-
30
-
31
- class ProtocolMismatch(Exception):
32
- """Raised when the protocol version in a request/response doesn't match PROTOCOL_VERSION."""
33
-
34
- def __init__(self, got: int):
35
- self.got = got
36
- super().__init__(f"Protocol version mismatch: expected {PROTOCOL_VERSION}, got {got}")
37
-
38
-
39
- @dataclass
40
- class Request:
41
- """IPC request from client to daemon."""
42
-
43
- v: int
44
- cmd: str
45
- args: dict[str, Any]
46
-
47
-
48
- @dataclass
49
- class ErrorShape:
50
- """Error detail in a failed Response."""
51
-
52
- kind: str
53
- message: str
54
-
55
-
56
- @dataclass
57
- class Response:
58
- """IPC response from daemon to client."""
59
-
60
- v: int
61
- ok: bool
62
- result: Any | None = None
63
- error: ErrorShape | None = None
64
-
65
-
66
- def encode_request(r: Request) -> bytes:
67
- """Encode a Request to newline-delimited JSON."""
68
- return (json.dumps(asdict(r), default=str) + "\n").encode("utf-8")
69
-
70
-
71
- def decode_request(line: bytes) -> Request:
72
- """Decode a Request from newline-delimited JSON.
73
-
74
- Raises:
75
- ProtocolMismatch: if the protocol version doesn't match
76
- ValueError: if the command is invalid or line is blank
77
- """
78
- if not line or line.strip() == b"":
79
- raise ValueError("Blank line")
80
-
81
- parsed = json.loads(line.decode("utf-8"))
82
-
83
- if parsed["v"] != PROTOCOL_VERSION:
84
- raise ProtocolMismatch(got=parsed["v"])
85
-
86
- if parsed["cmd"] not in VALID_CMDS:
87
- raise ValueError(f"Unknown command: {parsed['cmd']}")
88
-
89
- return Request(**parsed)
90
-
91
-
92
- def encode_response(r: Response) -> bytes:
93
- """Encode a Response to newline-delimited JSON."""
94
- return (json.dumps(asdict(r), default=str) + "\n").encode("utf-8")
95
-
96
-
97
- def decode_response(line: bytes) -> Response:
98
- """Decode a Response from newline-delimited JSON.
99
-
100
- Raises:
101
- ProtocolMismatch: if the protocol version doesn't match
102
- ValueError: if the line is blank
103
- """
104
- if not line or line.strip() == b"":
105
- raise ValueError("Blank line")
106
-
107
- parsed = json.loads(line.decode("utf-8"))
108
-
109
- if parsed["v"] != PROTOCOL_VERSION:
110
- raise ProtocolMismatch(got=parsed["v"])
111
-
112
- # Reconstruct ErrorShape if present
113
- error = None
114
- if parsed.get("error"):
115
- error = ErrorShape(**parsed["error"])
116
-
117
- return Response(
118
- v=parsed["v"],
119
- ok=parsed["ok"],
120
- result=parsed.get("result"),
121
- error=error,
122
- )
@@ -1,273 +0,0 @@
1
- """Unix-socket server for the ``jrag watch`` daemon.
2
-
3
- Accepts newline-delimited JSON requests (Task 4's protocol), dispatches each to
4
- the matching read-command payload core (Task 5), and writes the encoded
5
- response (Task 4's codec). Transport + dispatch only — the server stores nothing
6
- between requests except the warm resources and the operator config.
7
-
8
- Lifecycle (Task 11 wires this into the daemon process):
9
-
10
- * ``start()`` binds an ``AF_UNIX`` / ``SOCK_STREAM`` socket to
11
- :func:`paths.socket_path`, ``chmod 0o600``, ``listen(8)``, and spawns an
12
- accept thread running :meth:`serve`.
13
- * ``serve()`` is the accept loop. Each connection is handled INLINE (the read
14
- commands are fast): read newline-delimited bytes, decode, dispatch, encode,
15
- flush. Multiple pipelined requests on one connection are each answered; a
16
- connection that closes mid-line is dropped silently.
17
- * ``shutdown()`` closes the listening socket and joins the accept thread.
18
- """
19
-
20
- from __future__ import annotations
21
-
22
- import argparse
23
- import dataclasses
24
- import logging
25
- import socket
26
- import threading
27
- from typing import TYPE_CHECKING, Any, Callable
28
-
29
- from java_codebase_rag.jrag import _IndexStale
30
- from java_codebase_rag.read_payloads import (
31
- callers_payload,
32
- callees_payload,
33
- find_payload,
34
- flow_payload,
35
- inspect_payload,
36
- search_payload,
37
- )
38
- from java_codebase_rag.watch import paths
39
- from java_codebase_rag.watch.lock import ProjectLock
40
- from java_codebase_rag.watch.protocol import (
41
- ERR_BAD_ARGS,
42
- ERR_BACKEND_ERROR,
43
- ERR_STALE_INDEX,
44
- ERR_UNKNOWN_COMMAND,
45
- PROTOCOL_VERSION,
46
- ErrorShape,
47
- ProtocolMismatch,
48
- Request,
49
- Response,
50
- decode_request,
51
- encode_response,
52
- )
53
-
54
- if TYPE_CHECKING:
55
- # Type-only: the server never constructs these (the daemon passes them in),
56
- # so they are not needed at import time. Avoids coupling this transport
57
- # module to the heavy warm-resources/config import chain on minimal installs.
58
- from java_codebase_rag.config import ResolvedOperatorConfig
59
- from java_codebase_rag.warm import WarmResources
60
-
61
- log = logging.getLogger(__name__)
62
-
63
- # cmd -> payload core (Task 5). ``dispatch`` looks cmds up here; it ALSO defends
64
- # in depth against an unknown cmd (returns ERR_UNKNOWN_COMMAND) even though
65
- # ``decode_request`` already rejects anything outside VALID_CMDS — so a direct
66
- # caller of ``dispatch`` (or a future divergence between the two sets) still
67
- # gets the right error kind instead of a KeyError.
68
- PAYLOAD_FNS: dict[str, Callable[[argparse.Namespace, Any, Any], Any]] = {
69
- "search": search_payload,
70
- "find": find_payload,
71
- "inspect": inspect_payload,
72
- "callers": callers_payload,
73
- "callees": callees_payload,
74
- "flow": flow_payload,
75
- }
76
-
77
-
78
- def serialize(payload: Any) -> Any:
79
- """Return a JSON-safe representation of a payload core's return value.
80
-
81
- The cold read path's ``--format json`` emits a rendered *Envelope* via
82
- ``Envelope.to_json()`` (after ``project_envelope``). That requires the
83
- handler-specific payload->Envelope projection, which lives in the ``jrag``
84
- read handlers (``_cmd_search`` etc.) and is NOT a separable shared function
85
- — it is tangled with the terminal-rendering code path. Per the task brief,
86
- when that path is tangled we serialize the *payload* directly and let the
87
- client (Task 8) reconstruct + render it locally with the same handler code:
88
-
89
- * pydantic models -> ``model_dump(mode="json")`` (search/find/inspect
90
- ``*Output`` models, nested ``SymbolHit`` rows, etc.);
91
- * dict / list -> recursed element-wise;
92
- * dataclass -> ``dataclasses.asdict``;
93
- * everything else -> passed through (str/int/float/bool/None already
94
- JSON-safe).
95
-
96
- NOTE for Task 8: the daemon ships the serialized *payload*, not the rendered
97
- envelope. The client must reconstruct the payload object and run the same
98
- render path the cold ``jrag`` handler runs on it. ``encode_response`` then
99
- ``json.dumps`` this structure, so the values here must already be JSON-safe.
100
- """
101
- if hasattr(payload, "model_dump"):
102
- return payload.model_dump(mode="json")
103
- if isinstance(payload, dict):
104
- return {key: serialize(val) for key, val in payload.items()}
105
- if isinstance(payload, (list, tuple)):
106
- return [serialize(item) for item in payload]
107
- if dataclasses.is_dataclass(payload) and not isinstance(payload, type):
108
- return dataclasses.asdict(payload)
109
- return payload
110
-
111
-
112
- class WatchServer:
113
- """AF_UNIX socket server dispatching NDJSON requests to payload cores."""
114
-
115
- def __init__(self, warm: "WarmResources", cfg: "ResolvedOperatorConfig") -> None:
116
- self.warm = warm
117
- self.cfg = cfg
118
- self._sock: socket.socket | None = None
119
- self._thread: threading.Thread | None = None
120
- self._stopping = threading.Event()
121
-
122
- # -- lifecycle ----------------------------------------------------------
123
-
124
- def start(self) -> None:
125
- """Bind the Unix socket (chmod 0o600), listen, spawn the accept thread."""
126
- sock_path = paths.socket_path(self.cfg.index_dir)
127
- # Unlink a stale socket ONLY when no live daemon holds the project lock
128
- # (a live holder means another daemon owns this path — leave it alone).
129
- if sock_path.exists() and ProjectLock.read_holder(self.cfg.index_dir) is None:
130
- try:
131
- sock_path.unlink()
132
- except OSError:
133
- log.warning("Could not unlink stale socket %s", sock_path, exc_info=True)
134
-
135
- sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
136
- sock.bind(str(sock_path))
137
- try:
138
- sock_path.chmod(0o600)
139
- except OSError:
140
- log.warning("Could not chmod socket %s to 0o600", sock_path, exc_info=True)
141
- sock.listen(8)
142
- self._sock = sock
143
-
144
- self._stopping.clear()
145
- self._thread = threading.Thread(
146
- target=self.serve, name="jrag-watch-accept", daemon=True
147
- )
148
- self._thread.start()
149
-
150
- def serve(self) -> None:
151
- """Accept loop: one inline connection at a time (commands are fast)."""
152
- sock = self._sock
153
- if sock is None:
154
- return
155
- while not self._stopping.is_set():
156
- try:
157
- conn, _ = sock.accept()
158
- except OSError:
159
- # Listening socket closed (shutdown) -> exit cleanly. Any other
160
- # OSError here is transient; the loop retries while not stopping.
161
- if self._stopping.is_set() or self._sock is None:
162
- break
163
- log.debug("accept() failed; retrying", exc_info=True)
164
- continue
165
- try:
166
- self._handle(conn)
167
- except Exception: # noqa: BLE001 — a handler crash must not kill the loop
168
- log.exception("Unhandled error servicing watch connection")
169
- finally:
170
- try:
171
- conn.close()
172
- except OSError:
173
- pass
174
-
175
- def shutdown(self) -> None:
176
- """Close the listening socket and join the accept thread."""
177
- self._stopping.set()
178
- if self._sock is not None:
179
- try:
180
- self._sock.close()
181
- except OSError:
182
- pass
183
- self._sock = None
184
- thread = self._thread
185
- if thread is not None and thread is not threading.current_thread():
186
- thread.join(timeout=2.0)
187
- self._thread = None
188
-
189
- # -- per-connection handling -------------------------------------------
190
-
191
- def _handle(self, conn: socket.socket) -> None:
192
- """Read newline-delimited requests, dispatch, write responses.
193
-
194
- Loops so multiple pipelined requests on one connection are each
195
- answered. A peer that closes mid-line (a partial buffer with no
196
- trailing newline when recv returns empty) is dropped silently.
197
- """
198
- buffer = b""
199
- while True:
200
- # Answer every complete line currently buffered.
201
- while b"\n" in buffer:
202
- line, _, buffer = buffer.partition(b"\n")
203
- response = self._respond_to_line(line)
204
- try:
205
- conn.sendall(encode_response(response))
206
- except OSError:
207
- return # peer gone — stop servicing this connection
208
- # Need more bytes.
209
- try:
210
- chunk = conn.recv(4096)
211
- except OSError:
212
- return
213
- if not chunk:
214
- return # connection closed; any trailing partial line is dropped
215
- buffer += chunk
216
-
217
- def _respond_to_line(self, line: bytes) -> Response:
218
- """Decode one request line and dispatch it, mapping decode errors."""
219
- try:
220
- req = decode_request(line)
221
- except (ProtocolMismatch, ValueError) as exc:
222
- # ProtocolMismatch (wrong v) and ValueError (blank line / unknown
223
- # cmd as decode sees it) both surface to the client as bad_args.
224
- return Response(
225
- v=PROTOCOL_VERSION,
226
- ok=False,
227
- error=ErrorShape(ERR_BAD_ARGS, str(exc)),
228
- )
229
- return self.dispatch(req, self.warm, self.cfg)
230
-
231
- # -- dispatch -----------------------------------------------------------
232
-
233
- def dispatch(self, req: Request, warm: "WarmResources", cfg: Any) -> Response:
234
- """Map a decoded :class:`Request` to a :class:`Response` via the cmd core.
235
-
236
- Rebuilds ``args = argparse.Namespace(**req.args)`` (the client sends the
237
- full parsed-namespace dict; keys are argparse ``dest`` names) and calls
238
- ``<cmd>_payload(args, cfg, warm.graph())``. Error mapping (pinned
239
- contract): ``_IndexStale`` -> ``stale_index``;
240
- ``(ProtocolMismatch, ValueError)`` -> ``bad_args``; any other
241
- ``Exception`` (incl. :class:`PayloadError`) -> ``backend_error``;
242
- unknown cmd -> ``unknown_command`` (defense in depth).
243
- """
244
- payload_fn = PAYLOAD_FNS.get(req.cmd)
245
- if payload_fn is None:
246
- return Response(
247
- v=PROTOCOL_VERSION,
248
- ok=False,
249
- error=ErrorShape(ERR_UNKNOWN_COMMAND, f"Unknown command: {req.cmd}"),
250
- )
251
-
252
- args = argparse.Namespace(**req.args)
253
- try:
254
- payload = payload_fn(args, cfg, warm.graph())
255
- except _IndexStale as exc:
256
- return Response(
257
- v=PROTOCOL_VERSION,
258
- ok=False,
259
- error=ErrorShape(ERR_STALE_INDEX, str(exc)),
260
- )
261
- except (ProtocolMismatch, ValueError) as exc:
262
- return Response(
263
- v=PROTOCOL_VERSION,
264
- ok=False,
265
- error=ErrorShape(ERR_BAD_ARGS, str(exc)),
266
- )
267
- except Exception as exc: # noqa: BLE001 — includes PayloadError; -> backend_error
268
- return Response(
269
- v=PROTOCOL_VERSION,
270
- ok=False,
271
- error=ErrorShape(ERR_BACKEND_ERROR, str(exc)),
272
- )
273
- return Response(v=PROTOCOL_VERSION, ok=True, result=serialize(payload))