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.
Files changed (74) hide show
  1. code_review_graph/__init__.py +20 -0
  2. code_review_graph/__main__.py +4 -0
  3. code_review_graph/analysis.py +410 -0
  4. code_review_graph/changes.py +409 -0
  5. code_review_graph/cli.py +1255 -0
  6. code_review_graph/communities.py +874 -0
  7. code_review_graph/constants.py +23 -0
  8. code_review_graph/context_savings.py +317 -0
  9. code_review_graph/custom_languages.py +322 -0
  10. code_review_graph/daemon.py +1009 -0
  11. code_review_graph/daemon_cli.py +320 -0
  12. code_review_graph/docs/LLM-OPTIMIZED-REFERENCE.md +71 -0
  13. code_review_graph/embeddings.py +1006 -0
  14. code_review_graph/enrich.py +303 -0
  15. code_review_graph/eval/__init__.py +33 -0
  16. code_review_graph/eval/benchmarks/__init__.py +1 -0
  17. code_review_graph/eval/benchmarks/agent_baseline.py +193 -0
  18. code_review_graph/eval/benchmarks/build_performance.py +60 -0
  19. code_review_graph/eval/benchmarks/flow_completeness.py +36 -0
  20. code_review_graph/eval/benchmarks/impact_accuracy.py +220 -0
  21. code_review_graph/eval/benchmarks/multi_hop_retrieval.py +125 -0
  22. code_review_graph/eval/benchmarks/search_quality.py +59 -0
  23. code_review_graph/eval/benchmarks/token_efficiency.py +143 -0
  24. code_review_graph/eval/configs/code-review-graph.yaml +50 -0
  25. code_review_graph/eval/configs/express.yaml +45 -0
  26. code_review_graph/eval/configs/fastapi.yaml +48 -0
  27. code_review_graph/eval/configs/flask.yaml +50 -0
  28. code_review_graph/eval/configs/gin.yaml +51 -0
  29. code_review_graph/eval/configs/httpx.yaml +48 -0
  30. code_review_graph/eval/reporter.py +301 -0
  31. code_review_graph/eval/runner.py +211 -0
  32. code_review_graph/eval/scorer.py +85 -0
  33. code_review_graph/eval/token_benchmark.py +182 -0
  34. code_review_graph/exports.py +409 -0
  35. code_review_graph/flows.py +698 -0
  36. code_review_graph/graph.py +1427 -0
  37. code_review_graph/graph_diff.py +122 -0
  38. code_review_graph/hints.py +384 -0
  39. code_review_graph/incremental.py +1245 -0
  40. code_review_graph/jedi_resolver.py +303 -0
  41. code_review_graph/main.py +1079 -0
  42. code_review_graph/memory.py +142 -0
  43. code_review_graph/migrations.py +284 -0
  44. code_review_graph/parser.py +6957 -0
  45. code_review_graph/postprocessing.py +134 -0
  46. code_review_graph/prompts.py +159 -0
  47. code_review_graph/refactor.py +852 -0
  48. code_review_graph/registry.py +319 -0
  49. code_review_graph/rescript_resolver.py +206 -0
  50. code_review_graph/search.py +447 -0
  51. code_review_graph/skills.py +1481 -0
  52. code_review_graph/spring_resolver.py +200 -0
  53. code_review_graph/temporal_resolver.py +199 -0
  54. code_review_graph/token_benchmark.py +125 -0
  55. code_review_graph/tools/__init__.py +156 -0
  56. code_review_graph/tools/_common.py +176 -0
  57. code_review_graph/tools/analysis_tools.py +184 -0
  58. code_review_graph/tools/build.py +541 -0
  59. code_review_graph/tools/community_tools.py +246 -0
  60. code_review_graph/tools/context.py +152 -0
  61. code_review_graph/tools/docs.py +274 -0
  62. code_review_graph/tools/flows_tools.py +176 -0
  63. code_review_graph/tools/query.py +692 -0
  64. code_review_graph/tools/refactor_tools.py +168 -0
  65. code_review_graph/tools/registry_tools.py +125 -0
  66. code_review_graph/tools/review.py +477 -0
  67. code_review_graph/tsconfig_resolver.py +257 -0
  68. code_review_graph/visualization.py +2184 -0
  69. code_review_graph/wiki.py +305 -0
  70. code_review_graph_codeblackwell-2.3.6.post1.dist-info/METADATA +718 -0
  71. code_review_graph_codeblackwell-2.3.6.post1.dist-info/RECORD +74 -0
  72. code_review_graph_codeblackwell-2.3.6.post1.dist-info/WHEEL +4 -0
  73. code_review_graph_codeblackwell-2.3.6.post1.dist-info/entry_points.txt +3 -0
  74. 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
+ )