code-review-graph-codeblackwell 2.3.6.post1__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.
- code_review_graph/__init__.py +20 -0
- code_review_graph/__main__.py +4 -0
- code_review_graph/analysis.py +410 -0
- code_review_graph/changes.py +409 -0
- code_review_graph/cli.py +1255 -0
- code_review_graph/communities.py +874 -0
- code_review_graph/constants.py +23 -0
- code_review_graph/context_savings.py +317 -0
- code_review_graph/custom_languages.py +322 -0
- code_review_graph/daemon.py +1009 -0
- code_review_graph/daemon_cli.py +320 -0
- code_review_graph/docs/LLM-OPTIMIZED-REFERENCE.md +71 -0
- code_review_graph/embeddings.py +1006 -0
- code_review_graph/enrich.py +303 -0
- code_review_graph/eval/__init__.py +33 -0
- code_review_graph/eval/benchmarks/__init__.py +1 -0
- code_review_graph/eval/benchmarks/agent_baseline.py +193 -0
- code_review_graph/eval/benchmarks/build_performance.py +60 -0
- code_review_graph/eval/benchmarks/flow_completeness.py +36 -0
- code_review_graph/eval/benchmarks/impact_accuracy.py +220 -0
- code_review_graph/eval/benchmarks/multi_hop_retrieval.py +125 -0
- code_review_graph/eval/benchmarks/search_quality.py +59 -0
- code_review_graph/eval/benchmarks/token_efficiency.py +143 -0
- code_review_graph/eval/configs/code-review-graph.yaml +50 -0
- code_review_graph/eval/configs/express.yaml +45 -0
- code_review_graph/eval/configs/fastapi.yaml +48 -0
- code_review_graph/eval/configs/flask.yaml +50 -0
- code_review_graph/eval/configs/gin.yaml +51 -0
- code_review_graph/eval/configs/httpx.yaml +48 -0
- code_review_graph/eval/reporter.py +301 -0
- code_review_graph/eval/runner.py +211 -0
- code_review_graph/eval/scorer.py +85 -0
- code_review_graph/eval/token_benchmark.py +182 -0
- code_review_graph/exports.py +409 -0
- code_review_graph/flows.py +698 -0
- code_review_graph/graph.py +1427 -0
- code_review_graph/graph_diff.py +122 -0
- code_review_graph/hints.py +384 -0
- code_review_graph/incremental.py +1245 -0
- code_review_graph/jedi_resolver.py +303 -0
- code_review_graph/main.py +1079 -0
- code_review_graph/memory.py +142 -0
- code_review_graph/migrations.py +284 -0
- code_review_graph/parser.py +6957 -0
- code_review_graph/postprocessing.py +134 -0
- code_review_graph/prompts.py +159 -0
- code_review_graph/refactor.py +852 -0
- code_review_graph/registry.py +319 -0
- code_review_graph/rescript_resolver.py +206 -0
- code_review_graph/search.py +447 -0
- code_review_graph/skills.py +1481 -0
- code_review_graph/spring_resolver.py +200 -0
- code_review_graph/temporal_resolver.py +199 -0
- code_review_graph/token_benchmark.py +125 -0
- code_review_graph/tools/__init__.py +156 -0
- code_review_graph/tools/_common.py +176 -0
- code_review_graph/tools/analysis_tools.py +184 -0
- code_review_graph/tools/build.py +541 -0
- code_review_graph/tools/community_tools.py +246 -0
- code_review_graph/tools/context.py +152 -0
- code_review_graph/tools/docs.py +274 -0
- code_review_graph/tools/flows_tools.py +176 -0
- code_review_graph/tools/query.py +692 -0
- code_review_graph/tools/refactor_tools.py +168 -0
- code_review_graph/tools/registry_tools.py +125 -0
- code_review_graph/tools/review.py +477 -0
- code_review_graph/tsconfig_resolver.py +257 -0
- code_review_graph/visualization.py +2184 -0
- code_review_graph/wiki.py +305 -0
- code_review_graph_codeblackwell-2.3.6.post1.dist-info/METADATA +718 -0
- code_review_graph_codeblackwell-2.3.6.post1.dist-info/RECORD +74 -0
- code_review_graph_codeblackwell-2.3.6.post1.dist-info/WHEEL +4 -0
- code_review_graph_codeblackwell-2.3.6.post1.dist-info/entry_points.txt +3 -0
- code_review_graph_codeblackwell-2.3.6.post1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,1009 @@
|
|
|
1
|
+
"""Multi-repo watch daemon for code-review-graph.
|
|
2
|
+
|
|
3
|
+
Reads ``~/.code-review-graph/watch.toml`` to configure which repositories
|
|
4
|
+
to watch, then spawns one ``code-review-graph watch`` child process per
|
|
5
|
+
repo. Monitors the config file for live changes (adding/removing repos)
|
|
6
|
+
and health-checks child processes, restarting any that die.
|
|
7
|
+
|
|
8
|
+
No external dependencies beyond Python stdlib — no tmux required.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
import os
|
|
16
|
+
import shutil
|
|
17
|
+
import signal
|
|
18
|
+
import subprocess
|
|
19
|
+
import sys
|
|
20
|
+
import threading
|
|
21
|
+
import time
|
|
22
|
+
from collections.abc import Callable
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
if sys.version_info >= (3, 11):
|
|
28
|
+
import tomllib
|
|
29
|
+
else:
|
|
30
|
+
try:
|
|
31
|
+
import tomli as tomllib # type: ignore[no-redef]
|
|
32
|
+
except ImportError:
|
|
33
|
+
tomllib = None # type: ignore[assignment]
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
# Config file location
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
CONFIG_PATH: Path = Path.home() / ".code-review-graph" / "watch.toml"
|
|
42
|
+
PID_PATH: Path = Path.home() / ".code-review-graph" / "daemon.pid"
|
|
43
|
+
STATE_PATH: Path = Path.home() / ".code-review-graph" / "daemon-state.json"
|
|
44
|
+
_HEALTH_CHECK_INTERVAL = 30
|
|
45
|
+
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
# Dataclasses
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class WatchRepo:
|
|
53
|
+
"""A single repository to watch."""
|
|
54
|
+
|
|
55
|
+
path: str
|
|
56
|
+
"""Resolved absolute path to the repository root."""
|
|
57
|
+
|
|
58
|
+
alias: str
|
|
59
|
+
"""Short name for this repo (derived from directory name when not specified)."""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class DaemonConfig:
|
|
64
|
+
"""Top-level daemon configuration."""
|
|
65
|
+
|
|
66
|
+
session_name: str = "crg-watch"
|
|
67
|
+
"""Logical daemon name (used in log messages and status output)."""
|
|
68
|
+
|
|
69
|
+
log_dir: Path = field(default_factory=lambda: Path.home() / ".code-review-graph" / "logs")
|
|
70
|
+
"""Directory for per-repo log files."""
|
|
71
|
+
|
|
72
|
+
poll_interval: int = 2
|
|
73
|
+
"""Seconds between file-system polls for config changes."""
|
|
74
|
+
|
|
75
|
+
repos: list[WatchRepo] = field(default_factory=list)
|
|
76
|
+
"""Repositories the daemon watches."""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
# Loading
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def load_config(path: Path | None = None) -> DaemonConfig:
|
|
85
|
+
"""Load daemon configuration from a TOML file.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
path: Explicit config path. Falls back to :data:`CONFIG_PATH`.
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
A fully-validated :class:`DaemonConfig`.
|
|
92
|
+
|
|
93
|
+
Raises:
|
|
94
|
+
RuntimeError: If ``tomllib`` / ``tomli`` is unavailable on Python < 3.11.
|
|
95
|
+
"""
|
|
96
|
+
if tomllib is None:
|
|
97
|
+
raise RuntimeError(
|
|
98
|
+
"TOML parsing requires the 'tomli' package on Python < 3.11. "
|
|
99
|
+
"Install it with: pip install tomli"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
config_path = path or CONFIG_PATH
|
|
103
|
+
|
|
104
|
+
if not config_path.exists():
|
|
105
|
+
logger.info("Config file not found at %s — using defaults", config_path)
|
|
106
|
+
return DaemonConfig()
|
|
107
|
+
|
|
108
|
+
with open(config_path, "rb") as fh:
|
|
109
|
+
raw: dict[str, Any] = tomllib.load(fh)
|
|
110
|
+
|
|
111
|
+
# -- [daemon] section ---------------------------------------------------
|
|
112
|
+
daemon_section: dict[str, Any] = raw.get("daemon", {})
|
|
113
|
+
session_name: str = daemon_section.get("session_name", "crg-watch")
|
|
114
|
+
log_dir = Path(daemon_section.get("log_dir", str(DaemonConfig().log_dir)))
|
|
115
|
+
poll_interval: int = int(daemon_section.get("poll_interval", 2))
|
|
116
|
+
|
|
117
|
+
# -- [[repos]] array ----------------------------------------------------
|
|
118
|
+
repos: list[WatchRepo] = []
|
|
119
|
+
seen_aliases: set[str] = set()
|
|
120
|
+
|
|
121
|
+
for entry in raw.get("repos", []):
|
|
122
|
+
repo_path_str: str = entry.get("path", "")
|
|
123
|
+
if not repo_path_str:
|
|
124
|
+
logger.warning("Skipping repo entry with empty path")
|
|
125
|
+
continue
|
|
126
|
+
|
|
127
|
+
repo_path = Path(repo_path_str).expanduser().resolve()
|
|
128
|
+
|
|
129
|
+
if not repo_path.is_dir():
|
|
130
|
+
logger.warning("Skipping repo %s — directory does not exist", repo_path)
|
|
131
|
+
continue
|
|
132
|
+
|
|
133
|
+
has_repo_marker = (
|
|
134
|
+
(repo_path / ".git").exists()
|
|
135
|
+
or (repo_path / ".svn").exists()
|
|
136
|
+
or (repo_path / ".code-review-graph").exists()
|
|
137
|
+
)
|
|
138
|
+
if not has_repo_marker:
|
|
139
|
+
logger.warning(
|
|
140
|
+
"Skipping repo %s — no .git, .svn, or .code-review-graph directory found",
|
|
141
|
+
repo_path,
|
|
142
|
+
)
|
|
143
|
+
continue
|
|
144
|
+
|
|
145
|
+
alias: str = entry.get("alias", "") or repo_path.name
|
|
146
|
+
|
|
147
|
+
if alias in seen_aliases:
|
|
148
|
+
logger.warning("Skipping duplicate alias '%s' for repo %s", alias, repo_path)
|
|
149
|
+
continue
|
|
150
|
+
|
|
151
|
+
seen_aliases.add(alias)
|
|
152
|
+
repos.append(WatchRepo(path=str(repo_path), alias=alias))
|
|
153
|
+
|
|
154
|
+
return DaemonConfig(
|
|
155
|
+
session_name=session_name,
|
|
156
|
+
log_dir=log_dir,
|
|
157
|
+
poll_interval=poll_interval,
|
|
158
|
+
repos=repos,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# ---------------------------------------------------------------------------
|
|
163
|
+
# Saving
|
|
164
|
+
# ---------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _serialize_toml(config: DaemonConfig) -> str:
|
|
168
|
+
"""Serialize a :class:`DaemonConfig` to TOML text.
|
|
169
|
+
|
|
170
|
+
``tomllib`` is read-only, so we build the TOML manually.
|
|
171
|
+
"""
|
|
172
|
+
lines: list[str] = [
|
|
173
|
+
"[daemon]",
|
|
174
|
+
f'session_name = "{config.session_name}"',
|
|
175
|
+
f'log_dir = "{config.log_dir}"',
|
|
176
|
+
f"poll_interval = {config.poll_interval}",
|
|
177
|
+
]
|
|
178
|
+
for repo in config.repos:
|
|
179
|
+
lines.append("")
|
|
180
|
+
lines.append("[[repos]]")
|
|
181
|
+
lines.append(f'path = "{repo.path}"')
|
|
182
|
+
lines.append(f'alias = "{repo.alias}"')
|
|
183
|
+
lines.append("") # trailing newline
|
|
184
|
+
return "\n".join(lines)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def save_config(config: DaemonConfig, path: Path | None = None) -> None:
|
|
188
|
+
"""Write *config* back to a TOML file.
|
|
189
|
+
|
|
190
|
+
Creates parent directories if they do not exist.
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
config: The daemon configuration to persist.
|
|
194
|
+
path: Explicit config path. Falls back to :data:`CONFIG_PATH`.
|
|
195
|
+
"""
|
|
196
|
+
config_path = path or CONFIG_PATH
|
|
197
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
198
|
+
config_path.write_text(_serialize_toml(config), encoding="utf-8")
|
|
199
|
+
logger.info("Config saved to %s", config_path)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
# ---------------------------------------------------------------------------
|
|
203
|
+
# Convenience helpers (used by CLI commands)
|
|
204
|
+
# ---------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def add_repo_to_config(
|
|
208
|
+
repo_path: str,
|
|
209
|
+
alias: str | None = None,
|
|
210
|
+
config_path: Path | None = None,
|
|
211
|
+
) -> DaemonConfig:
|
|
212
|
+
"""Add a repository to the daemon config and persist the change.
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
repo_path: Path to the repository (will be resolved to absolute).
|
|
216
|
+
alias: Optional short name. Derived from dirname if *None*.
|
|
217
|
+
config_path: Explicit config file path. Falls back to :data:`CONFIG_PATH`.
|
|
218
|
+
|
|
219
|
+
Returns:
|
|
220
|
+
The updated :class:`DaemonConfig`.
|
|
221
|
+
|
|
222
|
+
Raises:
|
|
223
|
+
ValueError: If the path is not a valid repository directory.
|
|
224
|
+
"""
|
|
225
|
+
resolved = Path(repo_path).expanduser().resolve()
|
|
226
|
+
|
|
227
|
+
if not resolved.is_dir():
|
|
228
|
+
raise ValueError(f"Not a directory: {resolved}")
|
|
229
|
+
|
|
230
|
+
has_repo_marker = (
|
|
231
|
+
(resolved / ".git").exists()
|
|
232
|
+
or (resolved / ".svn").exists()
|
|
233
|
+
or (resolved / ".code-review-graph").exists()
|
|
234
|
+
)
|
|
235
|
+
if not has_repo_marker:
|
|
236
|
+
raise ValueError(f"No .git, .svn, or .code-review-graph directory in {resolved}")
|
|
237
|
+
|
|
238
|
+
effective_alias = alias or resolved.name
|
|
239
|
+
|
|
240
|
+
config = load_config(config_path)
|
|
241
|
+
|
|
242
|
+
# Check for duplicate path or alias
|
|
243
|
+
for existing in config.repos:
|
|
244
|
+
if existing.path == str(resolved):
|
|
245
|
+
logger.warning("Repo %s is already configured — skipping", resolved)
|
|
246
|
+
return config
|
|
247
|
+
if existing.alias == effective_alias:
|
|
248
|
+
raise ValueError(f"Alias '{effective_alias}' is already in use by {existing.path}")
|
|
249
|
+
|
|
250
|
+
config.repos.append(WatchRepo(path=str(resolved), alias=effective_alias))
|
|
251
|
+
save_config(config, config_path)
|
|
252
|
+
return config
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def remove_repo_from_config(
|
|
256
|
+
path_or_alias: str,
|
|
257
|
+
config_path: Path | None = None,
|
|
258
|
+
) -> DaemonConfig:
|
|
259
|
+
"""Remove a repository from the daemon config by path or alias.
|
|
260
|
+
|
|
261
|
+
Args:
|
|
262
|
+
path_or_alias: Either the absolute/relative repo path or its alias.
|
|
263
|
+
config_path: Explicit config file path. Falls back to :data:`CONFIG_PATH`.
|
|
264
|
+
|
|
265
|
+
Returns:
|
|
266
|
+
The updated :class:`DaemonConfig`.
|
|
267
|
+
"""
|
|
268
|
+
config = load_config(config_path)
|
|
269
|
+
resolved = str(Path(path_or_alias).expanduser().resolve())
|
|
270
|
+
|
|
271
|
+
original_count = len(config.repos)
|
|
272
|
+
config.repos = [r for r in config.repos if r.path != resolved and r.alias != path_or_alias]
|
|
273
|
+
|
|
274
|
+
if len(config.repos) == original_count:
|
|
275
|
+
logger.warning(
|
|
276
|
+
"No repo matching '%s' found in config — nothing removed",
|
|
277
|
+
path_or_alias,
|
|
278
|
+
)
|
|
279
|
+
else:
|
|
280
|
+
save_config(config, config_path)
|
|
281
|
+
|
|
282
|
+
return config
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
# ---------------------------------------------------------------------------
|
|
286
|
+
# PID file management
|
|
287
|
+
# ---------------------------------------------------------------------------
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def write_pid(pid: int | None = None, path: Path | None = None) -> None:
|
|
291
|
+
"""Write the current (or given) PID to the PID file."""
|
|
292
|
+
pid_path = path or PID_PATH
|
|
293
|
+
pid_path.parent.mkdir(parents=True, exist_ok=True)
|
|
294
|
+
pid_path.write_text(str(pid or os.getpid()), encoding="utf-8")
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def read_pid(path: Path | None = None) -> int | None:
|
|
298
|
+
"""Read the daemon PID from disk. Returns None if missing/invalid."""
|
|
299
|
+
pid_path = path or PID_PATH
|
|
300
|
+
if not pid_path.exists():
|
|
301
|
+
return None
|
|
302
|
+
try:
|
|
303
|
+
return int(pid_path.read_text(encoding="utf-8").strip())
|
|
304
|
+
except (ValueError, OSError):
|
|
305
|
+
return None
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def clear_pid(path: Path | None = None) -> None:
|
|
309
|
+
"""Remove the PID file."""
|
|
310
|
+
pid_path = path or PID_PATH
|
|
311
|
+
try:
|
|
312
|
+
pid_path.unlink(missing_ok=True)
|
|
313
|
+
except OSError:
|
|
314
|
+
pass
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
# Win32 constants for the OpenProcess-based liveness check (#511).
|
|
318
|
+
_PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
|
319
|
+
_ERROR_ACCESS_DENIED = 5
|
|
320
|
+
_WAIT_OBJECT_0 = 0x0
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _pid_alive_windows(
|
|
324
|
+
pid: int,
|
|
325
|
+
kernel32: Any,
|
|
326
|
+
get_last_error: Callable[[], int] | None = None,
|
|
327
|
+
) -> bool:
|
|
328
|
+
"""Win32 PID liveness check via OpenProcess/WaitForSingleObject.
|
|
329
|
+
|
|
330
|
+
The kernel32 interface is injected so tests can drive handle/wait
|
|
331
|
+
outcomes on non-Windows platforms. *get_last_error* defaults to
|
|
332
|
+
``kernel32.GetLastError``; the real caller passes
|
|
333
|
+
``ctypes.get_last_error`` (reliable with ``use_last_error=True``).
|
|
334
|
+
"""
|
|
335
|
+
if get_last_error is None:
|
|
336
|
+
get_last_error = kernel32.GetLastError
|
|
337
|
+
handle = kernel32.OpenProcess(_PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
|
|
338
|
+
if not handle:
|
|
339
|
+
# NULL handle: process is dead, or we lack access. ACCESS_DENIED
|
|
340
|
+
# means it exists but is owned by another user — treat as alive.
|
|
341
|
+
return get_last_error() == _ERROR_ACCESS_DENIED
|
|
342
|
+
try:
|
|
343
|
+
# WAIT_OBJECT_0 means the process handle is signaled (it exited).
|
|
344
|
+
return kernel32.WaitForSingleObject(handle, 0) != _WAIT_OBJECT_0
|
|
345
|
+
finally:
|
|
346
|
+
kernel32.CloseHandle(handle)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def pid_alive(pid: int) -> bool:
|
|
350
|
+
"""Cross-platform check whether a process with *pid* is running.
|
|
351
|
+
|
|
352
|
+
On Windows ``os.kill(pid, 0)`` routes to GenerateConsoleCtrlEvent and
|
|
353
|
+
raises ``OSError`` (WinError 87) for alive PIDs outside the caller's
|
|
354
|
+
console process group (#511), so the Win32 API is used instead.
|
|
355
|
+
"""
|
|
356
|
+
if sys.platform == "win32":
|
|
357
|
+
import ctypes
|
|
358
|
+
|
|
359
|
+
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
360
|
+
return _pid_alive_windows(pid, kernel32, ctypes.get_last_error)
|
|
361
|
+
try:
|
|
362
|
+
os.kill(pid, 0) # signal 0 = existence check
|
|
363
|
+
return True
|
|
364
|
+
except ProcessLookupError:
|
|
365
|
+
return False
|
|
366
|
+
except PermissionError:
|
|
367
|
+
return True # process exists but owned by another user
|
|
368
|
+
except OSError as exc:
|
|
369
|
+
# Unexpected platform quirk — treat as not alive rather than crash.
|
|
370
|
+
logger.debug("PID %d liveness check failed: %s", pid, exc)
|
|
371
|
+
return False
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def is_daemon_running(path: Path | None = None) -> bool:
|
|
375
|
+
"""Check whether a daemon process is alive."""
|
|
376
|
+
pid = read_pid(path)
|
|
377
|
+
if pid is None:
|
|
378
|
+
return False
|
|
379
|
+
if pid_alive(pid):
|
|
380
|
+
return True
|
|
381
|
+
# Stale PID file — clean up
|
|
382
|
+
clear_pid(path)
|
|
383
|
+
return False
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
# ---------------------------------------------------------------------------
|
|
387
|
+
# Child state persistence (for cross-process status queries)
|
|
388
|
+
# ---------------------------------------------------------------------------
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def load_state(path: Path | None = None) -> dict[str, Any]:
|
|
392
|
+
"""Load persisted child process state from disk.
|
|
393
|
+
|
|
394
|
+
Returns a dict mapping alias to ``{"pid": int, "path": str}``.
|
|
395
|
+
Returns an empty dict if the file is missing or corrupt.
|
|
396
|
+
"""
|
|
397
|
+
state_path = path or STATE_PATH
|
|
398
|
+
if not state_path.exists():
|
|
399
|
+
return {}
|
|
400
|
+
try:
|
|
401
|
+
return json.loads(state_path.read_text(encoding="utf-8")) # type: ignore[no-any-return]
|
|
402
|
+
except (json.JSONDecodeError, OSError):
|
|
403
|
+
return {}
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _is_pid_alive(pid: int) -> bool:
|
|
407
|
+
"""Check whether a process with the given PID is running."""
|
|
408
|
+
return pid_alive(pid)
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
# ---------------------------------------------------------------------------
|
|
412
|
+
# ConfigWatcher — monitors config file for live changes
|
|
413
|
+
# ---------------------------------------------------------------------------
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
class ConfigWatcher:
|
|
417
|
+
"""Watches the daemon config file for changes and triggers reconciliation."""
|
|
418
|
+
|
|
419
|
+
def __init__(
|
|
420
|
+
self,
|
|
421
|
+
config_path: Path,
|
|
422
|
+
callback: Callable[[], None],
|
|
423
|
+
poll_interval: int = 2,
|
|
424
|
+
) -> None:
|
|
425
|
+
self._config_path = config_path
|
|
426
|
+
self._callback = callback
|
|
427
|
+
self._poll_interval = poll_interval
|
|
428
|
+
self._observer: Any = None # watchdog Observer when available
|
|
429
|
+
self._last_mtime: float = 0.0
|
|
430
|
+
self._poll_thread: threading.Thread | None = None
|
|
431
|
+
self._stop_event: threading.Event = threading.Event()
|
|
432
|
+
|
|
433
|
+
# ------------------------------------------------------------------
|
|
434
|
+
# Public
|
|
435
|
+
# ------------------------------------------------------------------
|
|
436
|
+
|
|
437
|
+
def start(self) -> None:
|
|
438
|
+
"""Begin watching the config file for modifications."""
|
|
439
|
+
try:
|
|
440
|
+
from watchdog.events import FileSystemEventHandler
|
|
441
|
+
from watchdog.observers import Observer
|
|
442
|
+
|
|
443
|
+
watcher = self
|
|
444
|
+
|
|
445
|
+
class _Handler(FileSystemEventHandler): # type: ignore[misc]
|
|
446
|
+
def on_modified(self, event: Any) -> None:
|
|
447
|
+
if Path(event.src_path).resolve() == watcher._config_path.resolve():
|
|
448
|
+
watcher._on_config_changed()
|
|
449
|
+
|
|
450
|
+
handler = _Handler()
|
|
451
|
+
self._observer = Observer()
|
|
452
|
+
self._observer.schedule(
|
|
453
|
+
handler,
|
|
454
|
+
str(self._config_path.parent),
|
|
455
|
+
recursive=False,
|
|
456
|
+
)
|
|
457
|
+
self._observer.daemon = True
|
|
458
|
+
self._observer.start()
|
|
459
|
+
logger.info(
|
|
460
|
+
"Config watcher started (watchdog) for %s",
|
|
461
|
+
self._config_path,
|
|
462
|
+
)
|
|
463
|
+
except ImportError:
|
|
464
|
+
# Fallback to polling when watchdog is unavailable
|
|
465
|
+
logger.info(
|
|
466
|
+
"watchdog not available — falling back to polling for %s",
|
|
467
|
+
self._config_path,
|
|
468
|
+
)
|
|
469
|
+
self._start_polling()
|
|
470
|
+
|
|
471
|
+
def stop(self) -> None:
|
|
472
|
+
"""Stop watching the config file."""
|
|
473
|
+
self._stop_event.set()
|
|
474
|
+
if self._observer is not None:
|
|
475
|
+
self._observer.stop()
|
|
476
|
+
self._observer.join(timeout=5)
|
|
477
|
+
self._observer = None
|
|
478
|
+
if self._poll_thread is not None:
|
|
479
|
+
self._poll_thread.join(timeout=5)
|
|
480
|
+
self._poll_thread = None
|
|
481
|
+
|
|
482
|
+
# ------------------------------------------------------------------
|
|
483
|
+
# Internal
|
|
484
|
+
# ------------------------------------------------------------------
|
|
485
|
+
|
|
486
|
+
def _start_polling(self) -> None:
|
|
487
|
+
"""Poll the config file mtime in a background thread."""
|
|
488
|
+
if self._config_path.exists():
|
|
489
|
+
self._last_mtime = self._config_path.stat().st_mtime
|
|
490
|
+
|
|
491
|
+
def _poll() -> None:
|
|
492
|
+
while not self._stop_event.is_set():
|
|
493
|
+
self._stop_event.wait(self._poll_interval)
|
|
494
|
+
if self._stop_event.is_set():
|
|
495
|
+
break
|
|
496
|
+
try:
|
|
497
|
+
if not self._config_path.exists():
|
|
498
|
+
continue
|
|
499
|
+
mtime = self._config_path.stat().st_mtime
|
|
500
|
+
if mtime != self._last_mtime:
|
|
501
|
+
self._last_mtime = mtime
|
|
502
|
+
self._on_config_changed()
|
|
503
|
+
except OSError:
|
|
504
|
+
pass
|
|
505
|
+
|
|
506
|
+
self._poll_thread = threading.Thread(
|
|
507
|
+
target=_poll,
|
|
508
|
+
daemon=True,
|
|
509
|
+
name="config-poller",
|
|
510
|
+
)
|
|
511
|
+
self._poll_thread.start()
|
|
512
|
+
|
|
513
|
+
def _on_config_changed(self) -> None:
|
|
514
|
+
"""Handle a detected config file modification."""
|
|
515
|
+
logger.info("Config file changed, triggering reconciliation")
|
|
516
|
+
try:
|
|
517
|
+
self._callback()
|
|
518
|
+
except Exception:
|
|
519
|
+
logger.exception("Error during config-change reconciliation")
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
# ---------------------------------------------------------------------------
|
|
523
|
+
# WatchDaemon — manages child processes for multi-repo watching
|
|
524
|
+
# ---------------------------------------------------------------------------
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
class WatchDaemon:
|
|
528
|
+
"""Manages child processes for multi-repo file watching.
|
|
529
|
+
|
|
530
|
+
Each watched repository gets a ``code-review-graph watch`` child process
|
|
531
|
+
managed via :mod:`subprocess`. No external tools (tmux, screen, etc.)
|
|
532
|
+
are required.
|
|
533
|
+
"""
|
|
534
|
+
|
|
535
|
+
def __init__(
|
|
536
|
+
self,
|
|
537
|
+
config: DaemonConfig | None = None,
|
|
538
|
+
config_path: Path | None = None,
|
|
539
|
+
) -> None:
|
|
540
|
+
self._config: DaemonConfig = config or load_config(config_path)
|
|
541
|
+
self._config_path: Path = config_path or CONFIG_PATH
|
|
542
|
+
self._state_path: Path = STATE_PATH
|
|
543
|
+
self._children: dict[str, subprocess.Popen[bytes]] = {}
|
|
544
|
+
self._current_repos: dict[str, WatchRepo] = {}
|
|
545
|
+
self._config_watcher: ConfigWatcher | None = None
|
|
546
|
+
self._health_thread: threading.Thread | None = None
|
|
547
|
+
self._health_stop: threading.Event = threading.Event()
|
|
548
|
+
self._lock: threading.Lock = threading.Lock()
|
|
549
|
+
|
|
550
|
+
# ------------------------------------------------------------------
|
|
551
|
+
# Public interface
|
|
552
|
+
# ------------------------------------------------------------------
|
|
553
|
+
|
|
554
|
+
def start(self) -> None:
|
|
555
|
+
"""Spawn a watcher child process for each configured repo."""
|
|
556
|
+
logger.info("Starting daemon '%s'", self._config.session_name)
|
|
557
|
+
|
|
558
|
+
# Auto-register repos in the central registry
|
|
559
|
+
from .registry import Registry
|
|
560
|
+
|
|
561
|
+
registry = Registry()
|
|
562
|
+
for repo in self._config.repos:
|
|
563
|
+
registry.register(repo.path, alias=repo.alias)
|
|
564
|
+
|
|
565
|
+
# Build initial graph for repos that lack a database
|
|
566
|
+
for repo in self._config.repos:
|
|
567
|
+
db_path = Path(repo.path) / ".code-review-graph" / "graph.db"
|
|
568
|
+
if not db_path.exists():
|
|
569
|
+
self._initial_build(repo)
|
|
570
|
+
|
|
571
|
+
# Spawn a watcher child for every repo
|
|
572
|
+
for repo in self._config.repos:
|
|
573
|
+
self._start_watcher(repo)
|
|
574
|
+
|
|
575
|
+
# Track current state
|
|
576
|
+
self._current_repos = {r.alias: r for r in self._config.repos}
|
|
577
|
+
|
|
578
|
+
# Persist child PIDs to disk for cross-process status queries
|
|
579
|
+
self._save_state()
|
|
580
|
+
|
|
581
|
+
# Start watching the config file for live changes
|
|
582
|
+
self.start_config_watcher()
|
|
583
|
+
|
|
584
|
+
# Start health checker to auto-restart dead watchers
|
|
585
|
+
self.start_health_checker()
|
|
586
|
+
|
|
587
|
+
msg = f"Daemon started — watching {len(self._config.repos)} repo(s)"
|
|
588
|
+
logger.info(msg)
|
|
589
|
+
print(msg) # noqa: T201
|
|
590
|
+
|
|
591
|
+
def stop(self) -> None:
|
|
592
|
+
"""Tear down the daemon: stop watchers, terminate children."""
|
|
593
|
+
self.stop_config_watcher()
|
|
594
|
+
self.stop_health_checker()
|
|
595
|
+
|
|
596
|
+
with self._lock:
|
|
597
|
+
for alias, proc in list(self._children.items()):
|
|
598
|
+
self._terminate_child(alias, proc)
|
|
599
|
+
self._children.clear()
|
|
600
|
+
|
|
601
|
+
self._current_repos.clear()
|
|
602
|
+
self._clear_state()
|
|
603
|
+
clear_pid()
|
|
604
|
+
logger.info("Daemon stopped")
|
|
605
|
+
|
|
606
|
+
def reconcile(self, new_config: DaemonConfig | None = None) -> None:
|
|
607
|
+
"""Reconcile running watchers with the (possibly updated) config.
|
|
608
|
+
|
|
609
|
+
Child processes are started, stopped, or restarted to match the
|
|
610
|
+
desired state. New repos are registered in the central registry
|
|
611
|
+
and their graphs are built automatically (mirroring ``start()``).
|
|
612
|
+
"""
|
|
613
|
+
if new_config is not None:
|
|
614
|
+
self._config = new_config
|
|
615
|
+
|
|
616
|
+
desired: dict[str, WatchRepo] = {r.alias: r for r in self._config.repos}
|
|
617
|
+
current: set[str] = set(self._current_repos.keys())
|
|
618
|
+
|
|
619
|
+
to_add: set[str] = desired.keys() - current
|
|
620
|
+
to_remove: set[str] = current - desired.keys()
|
|
621
|
+
to_update: set[str] = {
|
|
622
|
+
alias
|
|
623
|
+
for alias in desired.keys() & current
|
|
624
|
+
if desired[alias].path != self._current_repos[alias].path
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
# Register new/updated repos and build graphs *before* acquiring
|
|
628
|
+
# the lock so that long-running builds don't block health checks.
|
|
629
|
+
if to_add or to_update:
|
|
630
|
+
from .registry import Registry
|
|
631
|
+
|
|
632
|
+
registry = Registry()
|
|
633
|
+
|
|
634
|
+
repos_needing_build: list[WatchRepo] = []
|
|
635
|
+
for alias in to_add | to_update:
|
|
636
|
+
repo = desired[alias]
|
|
637
|
+
registry.register(repo.path, alias=repo.alias)
|
|
638
|
+
db_path = Path(repo.path) / ".code-review-graph" / "graph.db"
|
|
639
|
+
if not db_path.exists():
|
|
640
|
+
repos_needing_build.append(repo)
|
|
641
|
+
|
|
642
|
+
for repo in repos_needing_build:
|
|
643
|
+
self._initial_build(repo)
|
|
644
|
+
|
|
645
|
+
with self._lock:
|
|
646
|
+
# Remove stale watchers
|
|
647
|
+
for alias in to_remove:
|
|
648
|
+
proc = self._children.pop(alias, None)
|
|
649
|
+
if proc is not None:
|
|
650
|
+
self._terminate_child(alias, proc)
|
|
651
|
+
del self._current_repos[alias]
|
|
652
|
+
|
|
653
|
+
# Add new watchers
|
|
654
|
+
for alias in to_add:
|
|
655
|
+
repo = desired[alias]
|
|
656
|
+
self._start_watcher(repo)
|
|
657
|
+
self._current_repos[alias] = repo
|
|
658
|
+
|
|
659
|
+
# Update changed watchers (path changed for same alias)
|
|
660
|
+
for alias in to_update:
|
|
661
|
+
proc = self._children.pop(alias, None)
|
|
662
|
+
if proc is not None:
|
|
663
|
+
self._terminate_child(alias, proc)
|
|
664
|
+
repo = desired[alias]
|
|
665
|
+
self._start_watcher(repo)
|
|
666
|
+
self._current_repos[alias] = repo
|
|
667
|
+
|
|
668
|
+
# Persist updated state
|
|
669
|
+
self._save_state()
|
|
670
|
+
|
|
671
|
+
logger.info(
|
|
672
|
+
"Reconcile complete — added: %d, removed: %d, updated: %d",
|
|
673
|
+
len(to_add),
|
|
674
|
+
len(to_remove),
|
|
675
|
+
len(to_update),
|
|
676
|
+
)
|
|
677
|
+
|
|
678
|
+
def status(self) -> dict[str, Any]:
|
|
679
|
+
"""Return a summary of daemon state.
|
|
680
|
+
|
|
681
|
+
When called from the daemon process itself, uses the in-memory
|
|
682
|
+
``_children`` dict. When called from a separate process (e.g. the
|
|
683
|
+
CLI ``status`` command), falls back to the persisted state file and
|
|
684
|
+
checks liveness via ``os.kill(pid, 0)``.
|
|
685
|
+
"""
|
|
686
|
+
repos: list[dict[str, Any]] = []
|
|
687
|
+
with self._lock:
|
|
688
|
+
if self._children:
|
|
689
|
+
# In-process: we have live Popen handles
|
|
690
|
+
for alias, repo in self._current_repos.items():
|
|
691
|
+
proc = self._children.get(alias)
|
|
692
|
+
alive = proc is not None and proc.poll() is None
|
|
693
|
+
repos.append(
|
|
694
|
+
{
|
|
695
|
+
"alias": alias,
|
|
696
|
+
"path": repo.path,
|
|
697
|
+
"alive": alive,
|
|
698
|
+
"pid": proc.pid if proc is not None else None,
|
|
699
|
+
}
|
|
700
|
+
)
|
|
701
|
+
else:
|
|
702
|
+
# Cross-process: read persisted state from disk
|
|
703
|
+
state = load_state(self._state_path)
|
|
704
|
+
for repo in self._config.repos:
|
|
705
|
+
entry = state.get(repo.alias, {})
|
|
706
|
+
pid: int | None = entry.get("pid")
|
|
707
|
+
alive = pid is not None and _is_pid_alive(pid)
|
|
708
|
+
repos.append(
|
|
709
|
+
{
|
|
710
|
+
"alias": repo.alias,
|
|
711
|
+
"path": repo.path,
|
|
712
|
+
"alive": alive,
|
|
713
|
+
"pid": pid,
|
|
714
|
+
}
|
|
715
|
+
)
|
|
716
|
+
return {
|
|
717
|
+
"session_name": self._config.session_name,
|
|
718
|
+
"running": True,
|
|
719
|
+
"repos": repos,
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
# ------------------------------------------------------------------
|
|
723
|
+
# Config watching
|
|
724
|
+
# ------------------------------------------------------------------
|
|
725
|
+
|
|
726
|
+
def start_config_watcher(self) -> None:
|
|
727
|
+
"""Begin watching the config file for live edits."""
|
|
728
|
+
self._config_watcher = ConfigWatcher(
|
|
729
|
+
config_path=self._config_path,
|
|
730
|
+
callback=self._on_config_change,
|
|
731
|
+
poll_interval=self._config.poll_interval,
|
|
732
|
+
)
|
|
733
|
+
self._config_watcher.start()
|
|
734
|
+
|
|
735
|
+
def _on_config_change(self) -> None:
|
|
736
|
+
"""Reload configuration and reconcile running watchers."""
|
|
737
|
+
try:
|
|
738
|
+
new_config = load_config(self._config_path)
|
|
739
|
+
except Exception:
|
|
740
|
+
logger.warning(
|
|
741
|
+
"Failed to parse config file — keeping last good config",
|
|
742
|
+
exc_info=True,
|
|
743
|
+
)
|
|
744
|
+
return
|
|
745
|
+
self.reconcile(new_config)
|
|
746
|
+
|
|
747
|
+
def stop_config_watcher(self) -> None:
|
|
748
|
+
"""Stop the config file watcher if running."""
|
|
749
|
+
if self._config_watcher is not None:
|
|
750
|
+
self._config_watcher.stop()
|
|
751
|
+
self._config_watcher = None
|
|
752
|
+
|
|
753
|
+
# ------------------------------------------------------------------
|
|
754
|
+
# Health checking
|
|
755
|
+
# ------------------------------------------------------------------
|
|
756
|
+
|
|
757
|
+
def start_health_checker(self) -> None:
|
|
758
|
+
"""Start the background health-check thread."""
|
|
759
|
+
self._health_stop = threading.Event()
|
|
760
|
+
self._health_thread = threading.Thread(
|
|
761
|
+
target=self._health_loop,
|
|
762
|
+
daemon=True,
|
|
763
|
+
name="health-checker",
|
|
764
|
+
)
|
|
765
|
+
self._health_thread.start()
|
|
766
|
+
logger.info(
|
|
767
|
+
"Health checker started (interval=%ds)",
|
|
768
|
+
_HEALTH_CHECK_INTERVAL,
|
|
769
|
+
)
|
|
770
|
+
|
|
771
|
+
def stop_health_checker(self) -> None:
|
|
772
|
+
"""Stop the health-check thread."""
|
|
773
|
+
if hasattr(self, "_health_stop"):
|
|
774
|
+
self._health_stop.set()
|
|
775
|
+
if hasattr(self, "_health_thread") and self._health_thread is not None:
|
|
776
|
+
self._health_thread.join(timeout=5)
|
|
777
|
+
self._health_thread = None
|
|
778
|
+
|
|
779
|
+
def _health_loop(self) -> None:
|
|
780
|
+
"""Periodically check child processes and restart dead ones."""
|
|
781
|
+
while not self._health_stop.is_set():
|
|
782
|
+
self._health_stop.wait(_HEALTH_CHECK_INTERVAL)
|
|
783
|
+
if self._health_stop.is_set():
|
|
784
|
+
break
|
|
785
|
+
self._check_health()
|
|
786
|
+
|
|
787
|
+
def _check_health(self) -> None:
|
|
788
|
+
"""Check each watcher child and restart if dead."""
|
|
789
|
+
restarted = False
|
|
790
|
+
with self._lock:
|
|
791
|
+
for alias, repo in list(self._current_repos.items()):
|
|
792
|
+
proc = self._children.get(alias)
|
|
793
|
+
if proc is None or proc.poll() is not None:
|
|
794
|
+
logger.warning("Watcher for '%s' is dead — restarting", alias)
|
|
795
|
+
# Clean up dead process entry
|
|
796
|
+
self._children.pop(alias, None)
|
|
797
|
+
self._start_watcher(repo)
|
|
798
|
+
restarted = True
|
|
799
|
+
if restarted:
|
|
800
|
+
self._save_state()
|
|
801
|
+
|
|
802
|
+
# ------------------------------------------------------------------
|
|
803
|
+
# Daemonization
|
|
804
|
+
# ------------------------------------------------------------------
|
|
805
|
+
|
|
806
|
+
def daemonize(self) -> None:
|
|
807
|
+
"""Fork to background using the double-fork pattern.
|
|
808
|
+
|
|
809
|
+
Redirects stdout/stderr to the daemon log file. Writes PID file.
|
|
810
|
+
Sets up SIGTERM handler for graceful shutdown.
|
|
811
|
+
|
|
812
|
+
On Windows, forking is not supported — the daemon runs in the
|
|
813
|
+
foreground and a warning is logged.
|
|
814
|
+
"""
|
|
815
|
+
if sys.platform == "win32":
|
|
816
|
+
logger.warning("Forking is not supported on Windows — running in foreground")
|
|
817
|
+
write_pid()
|
|
818
|
+
self._setup_signal_handlers()
|
|
819
|
+
return
|
|
820
|
+
|
|
821
|
+
# First fork
|
|
822
|
+
pid = os.fork()
|
|
823
|
+
if pid > 0:
|
|
824
|
+
# Parent exits
|
|
825
|
+
sys.exit(0)
|
|
826
|
+
|
|
827
|
+
# Become session leader
|
|
828
|
+
os.setsid()
|
|
829
|
+
|
|
830
|
+
# Second fork (prevent acquiring a controlling terminal)
|
|
831
|
+
pid = os.fork()
|
|
832
|
+
if pid > 0:
|
|
833
|
+
sys.exit(0)
|
|
834
|
+
|
|
835
|
+
# Redirect file descriptors
|
|
836
|
+
sys.stdout.flush()
|
|
837
|
+
sys.stderr.flush()
|
|
838
|
+
|
|
839
|
+
self._config.log_dir.mkdir(parents=True, exist_ok=True)
|
|
840
|
+
log_file = self._config.log_dir / "daemon.log"
|
|
841
|
+
|
|
842
|
+
# Open log file for stdout/stderr
|
|
843
|
+
fd = os.open(
|
|
844
|
+
str(log_file),
|
|
845
|
+
os.O_WRONLY | os.O_CREAT | os.O_APPEND,
|
|
846
|
+
0o644,
|
|
847
|
+
)
|
|
848
|
+
os.dup2(fd, sys.stdout.fileno())
|
|
849
|
+
os.dup2(fd, sys.stderr.fileno())
|
|
850
|
+
|
|
851
|
+
# Redirect stdin from /dev/null
|
|
852
|
+
devnull = os.open(os.devnull, os.O_RDONLY)
|
|
853
|
+
os.dup2(devnull, sys.stdin.fileno())
|
|
854
|
+
os.close(devnull)
|
|
855
|
+
if fd > 2:
|
|
856
|
+
os.close(fd)
|
|
857
|
+
|
|
858
|
+
# Write PID file
|
|
859
|
+
write_pid()
|
|
860
|
+
|
|
861
|
+
# Set up signal handlers
|
|
862
|
+
self._setup_signal_handlers()
|
|
863
|
+
|
|
864
|
+
logger.info("Daemonized (PID %d)", os.getpid())
|
|
865
|
+
|
|
866
|
+
def _setup_signal_handlers(self) -> None:
|
|
867
|
+
"""Install SIGTERM/SIGHUP handlers for graceful shutdown."""
|
|
868
|
+
|
|
869
|
+
def _handle_sigterm(signum: int, frame: Any) -> None:
|
|
870
|
+
logger.info("Received signal %d — shutting down", signum)
|
|
871
|
+
self.stop()
|
|
872
|
+
sys.exit(0)
|
|
873
|
+
|
|
874
|
+
signal.signal(signal.SIGTERM, _handle_sigterm)
|
|
875
|
+
if sys.platform != "win32":
|
|
876
|
+
signal.signal(signal.SIGHUP, _handle_sigterm)
|
|
877
|
+
|
|
878
|
+
def run_forever(self) -> None:
|
|
879
|
+
"""Block forever, keeping the daemon alive.
|
|
880
|
+
|
|
881
|
+
The config watcher and health checker run in background threads.
|
|
882
|
+
This method sleeps in the main thread until interrupted.
|
|
883
|
+
"""
|
|
884
|
+
try:
|
|
885
|
+
while True:
|
|
886
|
+
time.sleep(1)
|
|
887
|
+
except KeyboardInterrupt:
|
|
888
|
+
logger.info("Keyboard interrupt — stopping daemon")
|
|
889
|
+
self.stop()
|
|
890
|
+
|
|
891
|
+
# ------------------------------------------------------------------
|
|
892
|
+
# Internal helpers
|
|
893
|
+
# ------------------------------------------------------------------
|
|
894
|
+
|
|
895
|
+
def _save_state(self) -> None:
|
|
896
|
+
"""Persist child PIDs and repo paths to disk for cross-process queries.
|
|
897
|
+
|
|
898
|
+
Called after any mutation of ``_children`` so that ``status`` commands
|
|
899
|
+
running in a separate process can determine which watchers are alive.
|
|
900
|
+
"""
|
|
901
|
+
state: dict[str, dict[str, Any]] = {}
|
|
902
|
+
for alias, proc in self._children.items():
|
|
903
|
+
repo = self._current_repos.get(alias)
|
|
904
|
+
state[alias] = {
|
|
905
|
+
"pid": proc.pid,
|
|
906
|
+
"path": repo.path if repo else "",
|
|
907
|
+
}
|
|
908
|
+
try:
|
|
909
|
+
self._state_path.parent.mkdir(parents=True, exist_ok=True)
|
|
910
|
+
self._state_path.write_text(json.dumps(state), encoding="utf-8")
|
|
911
|
+
except OSError:
|
|
912
|
+
logger.warning("Failed to persist daemon state to %s", self._state_path)
|
|
913
|
+
|
|
914
|
+
def _clear_state(self) -> None:
|
|
915
|
+
"""Remove the state file from disk."""
|
|
916
|
+
try:
|
|
917
|
+
self._state_path.unlink(missing_ok=True)
|
|
918
|
+
except OSError:
|
|
919
|
+
pass
|
|
920
|
+
|
|
921
|
+
def _start_watcher(self, repo: WatchRepo) -> None:
|
|
922
|
+
"""Spawn a child process running ``code-review-graph watch`` for *repo*."""
|
|
923
|
+
self._config.log_dir.mkdir(parents=True, exist_ok=True)
|
|
924
|
+
log_path = self._config.log_dir / f"{repo.alias}.log"
|
|
925
|
+
|
|
926
|
+
crg_bin = shutil.which("code-review-graph")
|
|
927
|
+
if crg_bin:
|
|
928
|
+
cmd: list[str] = [crg_bin, "watch", "--repo", repo.path]
|
|
929
|
+
else:
|
|
930
|
+
cmd = [
|
|
931
|
+
sys.executable,
|
|
932
|
+
"-m",
|
|
933
|
+
"code_review_graph",
|
|
934
|
+
"watch",
|
|
935
|
+
"--repo",
|
|
936
|
+
repo.path,
|
|
937
|
+
]
|
|
938
|
+
|
|
939
|
+
log_fd = open(log_path, "ab") # noqa: SIM115
|
|
940
|
+
try:
|
|
941
|
+
proc = subprocess.Popen(
|
|
942
|
+
cmd,
|
|
943
|
+
cwd=repo.path,
|
|
944
|
+
stdout=log_fd,
|
|
945
|
+
stderr=subprocess.STDOUT,
|
|
946
|
+
stdin=subprocess.DEVNULL,
|
|
947
|
+
)
|
|
948
|
+
except Exception:
|
|
949
|
+
log_fd.close()
|
|
950
|
+
logger.exception("Failed to start watcher for '%s'", repo.alias)
|
|
951
|
+
return
|
|
952
|
+
|
|
953
|
+
# The log fd is inherited by the child; we can close our copy.
|
|
954
|
+
# The child keeps the fd open via its own reference.
|
|
955
|
+
log_fd.close()
|
|
956
|
+
|
|
957
|
+
self._children[repo.alias] = proc
|
|
958
|
+
logger.info(
|
|
959
|
+
"Started watcher for '%s' (PID %d) — log: %s",
|
|
960
|
+
repo.alias,
|
|
961
|
+
proc.pid,
|
|
962
|
+
log_path,
|
|
963
|
+
)
|
|
964
|
+
|
|
965
|
+
@staticmethod
|
|
966
|
+
def _terminate_child(alias: str, proc: subprocess.Popen[bytes]) -> None:
|
|
967
|
+
"""Gracefully terminate a child process (SIGTERM, then SIGKILL)."""
|
|
968
|
+
if proc.poll() is not None:
|
|
969
|
+
return # already dead
|
|
970
|
+
|
|
971
|
+
logger.info("Terminating watcher '%s' (PID %d)", alias, proc.pid)
|
|
972
|
+
proc.terminate()
|
|
973
|
+
try:
|
|
974
|
+
proc.wait(timeout=5)
|
|
975
|
+
except subprocess.TimeoutExpired:
|
|
976
|
+
logger.warning("Watcher '%s' did not stop — sending SIGKILL", alias)
|
|
977
|
+
proc.kill()
|
|
978
|
+
proc.wait(timeout=5)
|
|
979
|
+
|
|
980
|
+
def _initial_build(self, repo: WatchRepo) -> None:
|
|
981
|
+
"""Run a one-off graph build for a repo that has no database yet."""
|
|
982
|
+
logger.info("Building initial graph for %s...", repo.alias)
|
|
983
|
+
|
|
984
|
+
crg_bin = shutil.which("code-review-graph")
|
|
985
|
+
if crg_bin:
|
|
986
|
+
cmd: list[str] = [crg_bin, "build", "--repo", repo.path]
|
|
987
|
+
else:
|
|
988
|
+
cmd = [
|
|
989
|
+
sys.executable,
|
|
990
|
+
"-m",
|
|
991
|
+
"code_review_graph",
|
|
992
|
+
"build",
|
|
993
|
+
"--repo",
|
|
994
|
+
repo.path,
|
|
995
|
+
]
|
|
996
|
+
|
|
997
|
+
result = subprocess.run(
|
|
998
|
+
cmd,
|
|
999
|
+
capture_output=True,
|
|
1000
|
+
text=True,
|
|
1001
|
+
check=False,
|
|
1002
|
+
)
|
|
1003
|
+
if result.returncode != 0:
|
|
1004
|
+
logger.warning(
|
|
1005
|
+
"Initial build for '%s' failed (rc=%d): %s",
|
|
1006
|
+
repo.alias,
|
|
1007
|
+
result.returncode,
|
|
1008
|
+
result.stderr.strip(),
|
|
1009
|
+
)
|