graphite-code 0.3.0__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 (112) hide show
  1. graphite/__init__.py +41 -0
  2. graphite/__main__.py +7 -0
  3. graphite/_cleanup_worker.py +525 -0
  4. graphite/activation.py +164 -0
  5. graphite/agent_hooks.py +577 -0
  6. graphite/agent_settings.py +226 -0
  7. graphite/analyze.py +146 -0
  8. graphite/answer_contract.py +420 -0
  9. graphite/bootstrap.py +210 -0
  10. graphite/buildlock.py +99 -0
  11. graphite/cache.py +131 -0
  12. graphite/channel.py +1325 -0
  13. graphite/cli.py +3053 -0
  14. graphite/cluster.py +111 -0
  15. graphite/config.py +209 -0
  16. graphite/context.py +355 -0
  17. graphite/daemon.py +745 -0
  18. graphite/daemon_health.py +733 -0
  19. graphite/debt.py +118 -0
  20. graphite/dependency_install.py +1597 -0
  21. graphite/detach.py +33 -0
  22. graphite/doctor.py +678 -0
  23. graphite/doctor_probes.py +2100 -0
  24. graphite/engine_identity.py +238 -0
  25. graphite/export/__init__.py +6 -0
  26. graphite/export/html.py +244 -0
  27. graphite/export/json.py +39 -0
  28. graphite/export/md.py +68 -0
  29. graphite/extract/__init__.py +4 -0
  30. graphite/extract/ast.py +1964 -0
  31. graphite/freshness.py +127 -0
  32. graphite/git.py +406 -0
  33. graphite/graph.py +117 -0
  34. graphite/graph_io.py +188 -0
  35. graphite/health.py +147 -0
  36. graphite/hook_entry.py +68 -0
  37. graphite/hookinstall.py +224 -0
  38. graphite/hookshim.py +86 -0
  39. graphite/incident_ledger.py +247 -0
  40. graphite/ingest.py +279 -0
  41. graphite/init.py +791 -0
  42. graphite/io.py +32 -0
  43. graphite/listing.py +51 -0
  44. graphite/llm.py +518 -0
  45. graphite/llm_probe.py +157 -0
  46. graphite/mcp.py +7 -0
  47. graphite/mcp_server.py +450 -0
  48. graphite/natural_query.py +252 -0
  49. graphite/overlays.py +713 -0
  50. graphite/probe_process.py +879 -0
  51. graphite/probe_workspace.py +728 -0
  52. graphite/process_contracts.py +22 -0
  53. graphite/provider_observer.py +397 -0
  54. graphite/query.py +646 -0
  55. graphite/query_plan.py +97 -0
  56. graphite/replacement_audit.py +291 -0
  57. graphite/resolve.py +660 -0
  58. graphite/review.py +782 -0
  59. graphite/routing/__init__.py +5 -0
  60. graphite/routing/approval.py +362 -0
  61. graphite/routing/classifier.py +169 -0
  62. graphite/routing/claude_executor.py +419 -0
  63. graphite/routing/claude_probe.py +102 -0
  64. graphite/routing/cli_identity.py +84 -0
  65. graphite/routing/codex_executor.py +383 -0
  66. graphite/routing/codex_probe.py +93 -0
  67. graphite/routing/context_builder.py +327 -0
  68. graphite/routing/contracts.py +802 -0
  69. graphite/routing/diff_policy.py +468 -0
  70. graphite/routing/edit_apply.py +166 -0
  71. graphite/routing/effort.py +43 -0
  72. graphite/routing/lifecycle.py +771 -0
  73. graphite/routing/lifecycle_operator.py +227 -0
  74. graphite/routing/lifecycle_service.py +555 -0
  75. graphite/routing/lifecycle_storage.py +977 -0
  76. graphite/routing/ollama_executor.py +341 -0
  77. graphite/routing/ollama_probe.py +72 -0
  78. graphite/routing/openrouter_executor.py +338 -0
  79. graphite/routing/openrouter_probe.py +188 -0
  80. graphite/routing/policy.py +815 -0
  81. graphite/routing/probe_runner.py +543 -0
  82. graphite/routing/process_runner.py +523 -0
  83. graphite/routing/profiles.py +554 -0
  84. graphite/routing/prompt.py +58 -0
  85. graphite/routing/registry.py +444 -0
  86. graphite/routing/route_pool.py +629 -0
  87. graphite/routing/route_pool_execution.py +275 -0
  88. graphite/routing/schema_validation.py +169 -0
  89. graphite/routing/service.py +1263 -0
  90. graphite/routing/settings.py +99 -0
  91. graphite/routing/shadow.py +201 -0
  92. graphite/routing/storage.py +4001 -0
  93. graphite/routing/telemetry.py +346 -0
  94. graphite/routing/worktree.py +259 -0
  95. graphite/routing/zai_edit.py +113 -0
  96. graphite/routing/zai_executor.py +191 -0
  97. graphite/routing/zai_probe.py +126 -0
  98. graphite/savings.py +84 -0
  99. graphite/ts_bridge.py +142 -0
  100. graphite/ts_resolver.mjs +314 -0
  101. graphite/typescript_activation.py +1586 -0
  102. graphite/usage_ledger.py +156 -0
  103. graphite/validation.py +148 -0
  104. graphite/watch.py +167 -0
  105. graphite/windows_job.py +368 -0
  106. graphite/windows_startup.py +144 -0
  107. graphite/windows_task.py +212 -0
  108. graphite_code-0.3.0.dist-info/METADATA +743 -0
  109. graphite_code-0.3.0.dist-info/RECORD +112 -0
  110. graphite_code-0.3.0.dist-info/WHEEL +4 -0
  111. graphite_code-0.3.0.dist-info/entry_points.txt +3 -0
  112. graphite_code-0.3.0.dist-info/licenses/LICENSE +21 -0
graphite/daemon.py ADDED
@@ -0,0 +1,745 @@
1
+ """Multi-project Graphite daemon for keeping local graphs fresh."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import subprocess
7
+ import sys
8
+ import threading
9
+ import time
10
+ from dataclasses import dataclass, field
11
+ from datetime import datetime, timezone
12
+ from pathlib import Path
13
+ from typing import Callable
14
+
15
+ from . import activation, buildlock
16
+ from .config import Config
17
+ from .engine_identity import EngineIdentityError, engine_identity
18
+ from .freshness import graph_engine_changed
19
+ from .incident_ledger import record_incident, repo_ledger_dir
20
+ from .io import atomic_write_json
21
+ from .provider_observer import ProviderObservationSummary
22
+ from .watch import Snapshot, WatchChange, diff_snapshots, snapshot, wait_for_stable_snapshot
23
+
24
+ IGNORE_MARKER = ".graphite-ignore"
25
+
26
+ PROJECT_MARKERS: tuple[str, ...] = (
27
+ ".git",
28
+ "package.json",
29
+ "pyproject.toml",
30
+ "wrangler.toml",
31
+ "Cargo.toml",
32
+ "go.mod",
33
+ "deno.json",
34
+ "vite.config.ts",
35
+ "next.config.js",
36
+ "next.config.mjs",
37
+ )
38
+
39
+ MAX_DAEMON_STATUS_BYTES = 4 * 1024 * 1024
40
+ _PROVIDER_ENV_PREFIXES = (
41
+ "GRAPHITE_LLM",
42
+ "GRAPHITE_PROVIDER_",
43
+ "GRAPHITE_ROUTE_",
44
+ "ANTHROPIC_",
45
+ "CLAUDE_",
46
+ "CODEX_",
47
+ "GROQ_",
48
+ "LMSTUDIO_",
49
+ "OLLAMA_",
50
+ "OPENAI_",
51
+ "OPENROUTER_",
52
+ "VLLM_",
53
+ )
54
+
55
+
56
+ class DaemonStatusTooLargeError(OSError):
57
+ """Raised when a daemon status snapshot exceeds the parsing limit."""
58
+
59
+
60
+ class DaemonStatusInvalidError(ValueError):
61
+ """Raised when a bounded daemon status snapshot cannot be parsed safely."""
62
+
63
+
64
+ DISCOVERY_SKIP_DIRS: frozenset[str] = frozenset({
65
+ ".cache",
66
+ ".git",
67
+ ".graphite-daemon",
68
+ ".next",
69
+ ".open-next",
70
+ ".pytest_cache",
71
+ ".venv",
72
+ ".wrangler",
73
+ "__pycache__",
74
+ "_tools",
75
+ "build",
76
+ "coverage",
77
+ "dist",
78
+ "graph-out",
79
+ "graphify-out",
80
+ "node_modules",
81
+ "out",
82
+ "vendor",
83
+ "venv",
84
+ })
85
+
86
+
87
+ def utc_now() -> str:
88
+ return datetime.now(timezone.utc).isoformat(timespec="seconds")
89
+
90
+
91
+ def _tail(text: str, limit: int = 4000) -> str:
92
+ text = text.strip()
93
+ if len(text) <= limit:
94
+ return text
95
+ return text[-limit:]
96
+
97
+
98
+ def summarize_change(change: WatchChange, sample_limit: int = 20) -> dict[str, object]:
99
+ return {
100
+ "added_count": len(change.added),
101
+ "changed_count": len(change.changed),
102
+ "removed_count": len(change.removed),
103
+ "sample": list(change.paths[:sample_limit]),
104
+ }
105
+
106
+
107
+ @dataclass(frozen=True)
108
+ class DaemonOptions:
109
+ """Operational limits for the multi-project daemon."""
110
+
111
+ scan_interval_seconds: float = 10.0
112
+ discover_interval_seconds: float = 60.0
113
+ debounce_seconds: float = 1.0
114
+ max_depth: int = 6
115
+ max_projects: int = 128
116
+ max_files_per_project: int | None = 10_000
117
+ max_builds_per_cycle: int = 2
118
+ build_timeout_seconds: float = 300.0
119
+ build_now: bool = True
120
+ once: bool = False
121
+ max_cycles: int | None = None
122
+ state_dir: Path | None = None
123
+ log_max_bytes: int = 5_000_000
124
+ activation_ttl_seconds: float = 3600.0
125
+
126
+ def validate(self) -> None:
127
+ if self.scan_interval_seconds <= 0:
128
+ raise ValueError("daemon scan interval must be greater than zero")
129
+ if self.discover_interval_seconds <= 0:
130
+ raise ValueError("daemon discover interval must be greater than zero")
131
+ if self.debounce_seconds < 0:
132
+ raise ValueError("daemon debounce must be zero or greater")
133
+ if self.max_depth < 0:
134
+ raise ValueError("daemon max depth must be zero or greater")
135
+ if self.max_projects <= 0:
136
+ raise ValueError("daemon max projects must be greater than zero")
137
+ if self.max_files_per_project is not None and self.max_files_per_project <= 0:
138
+ raise ValueError("daemon max files per project must be greater than zero")
139
+ if self.max_builds_per_cycle <= 0:
140
+ raise ValueError("daemon max builds per cycle must be greater than zero")
141
+ if self.build_timeout_seconds <= 0:
142
+ raise ValueError("daemon build timeout must be greater than zero")
143
+ if self.max_cycles is not None and self.max_cycles <= 0:
144
+ raise ValueError("daemon max cycles must be greater than zero")
145
+ if self.log_max_bytes <= 0:
146
+ raise ValueError("daemon log max bytes must be greater than zero")
147
+
148
+
149
+ @dataclass(frozen=True)
150
+ class BuildResult:
151
+ success: bool
152
+ returncode: int | None
153
+ duration_seconds: float
154
+ stdout: str = ""
155
+ stderr: str = ""
156
+ error: str | None = None
157
+
158
+
159
+ @dataclass
160
+ class ProjectRuntime:
161
+ root: Path
162
+ snapshot: Snapshot
163
+ discovered_at: str = field(default_factory=utc_now)
164
+ last_seen_at: str = field(default_factory=utc_now)
165
+ last_build_started_at: str | None = None
166
+ last_build_finished_at: str | None = None
167
+ last_build_seconds: float | None = None
168
+ last_success_at: str | None = None
169
+ last_error: str | None = None
170
+ build_count: int = 0
171
+ failure_count: int = 0
172
+ needs_initial_build: bool = True
173
+ last_change: dict[str, object] | None = None
174
+
175
+ def to_status(self) -> dict[str, object]:
176
+ return {
177
+ "root": str(self.root),
178
+ "file_count": len(self.snapshot),
179
+ "discovered_at": self.discovered_at,
180
+ "last_seen_at": self.last_seen_at,
181
+ "last_build_started_at": self.last_build_started_at,
182
+ "last_build_finished_at": self.last_build_finished_at,
183
+ "last_build_seconds": self.last_build_seconds,
184
+ "last_success_at": self.last_success_at,
185
+ "last_error": self.last_error,
186
+ "build_count": self.build_count,
187
+ "failure_count": self.failure_count,
188
+ "needs_initial_build": self.needs_initial_build,
189
+ "last_change": self.last_change,
190
+ }
191
+
192
+
193
+ class DaemonLogger:
194
+ def __init__(self, state_dir: Path, max_bytes: int) -> None:
195
+ self.state_dir = state_dir
196
+ self.max_bytes = max_bytes
197
+ self.log_path = state_dir / "graphite-daemon.log"
198
+ self._lock = threading.Lock()
199
+ self.state_dir.mkdir(parents=True, exist_ok=True)
200
+
201
+ def event(self, event: str, **fields: object) -> None:
202
+ with self._lock:
203
+ self._rotate_if_needed()
204
+ payload = {"ts": utc_now(), "event": event, **fields}
205
+ with open(self.log_path, "a", encoding="utf-8") as f:
206
+ f.write(json.dumps(payload, ensure_ascii=False, sort_keys=True) + "\n")
207
+
208
+ def _rotate_if_needed(self) -> None:
209
+ try:
210
+ if self.log_path.exists() and self.log_path.stat().st_size > self.max_bytes:
211
+ rotated = self.log_path.with_suffix(".log.1")
212
+ if rotated.exists():
213
+ rotated.unlink()
214
+ self.log_path.rename(rotated)
215
+ except OSError:
216
+ return
217
+
218
+
219
+ def is_project_root(path: Path) -> bool:
220
+ return any((path / marker).exists() for marker in PROJECT_MARKERS)
221
+
222
+
223
+ def discover_projects(base: Path, *, max_depth: int = 6, max_projects: int = 128) -> list[Path]:
224
+ """Discover likely project roots under a base directory without entering heavy build folders."""
225
+ base = base.resolve()
226
+ if not base.exists():
227
+ raise FileNotFoundError(base)
228
+ if not base.is_dir():
229
+ raise NotADirectoryError(base)
230
+
231
+ projects: list[Path] = []
232
+ for dirpath, dirnames, _ in os.walk(base):
233
+ current = Path(dirpath)
234
+ try:
235
+ rel = current.relative_to(base)
236
+ except ValueError:
237
+ continue
238
+ depth = 0 if rel == Path(".") else len(rel.parts)
239
+ dirnames[:] = [
240
+ d for d in dirnames
241
+ if d not in DISCOVERY_SKIP_DIRS and not d.startswith(".")
242
+ ]
243
+
244
+ if depth > max_depth:
245
+ dirnames[:] = []
246
+ continue
247
+
248
+ if (current / IGNORE_MARKER).exists():
249
+ dirnames[:] = []
250
+ continue
251
+
252
+ if is_project_root(current):
253
+ projects.append(current)
254
+ if len(projects) >= max_projects:
255
+ break
256
+ # A project's graph covers its whole subtree, so do not descend
257
+ # into it looking for more projects. Without this, every monorepo
258
+ # workspace (package.json / wrangler.toml markers) became its own
259
+ # supervised project with a duplicate — and cross-workspace-blind —
260
+ # graph next to the root one.
261
+ dirnames[:] = []
262
+ continue
263
+
264
+ if depth >= max_depth:
265
+ dirnames[:] = []
266
+
267
+ return sorted(projects, key=lambda p: str(p).lower())
268
+
269
+
270
+ def nested_git_repos(project_root: Path, *, max_depth: int = 6) -> list[Path]:
271
+ """Directories strictly below `project_root` that are their own git repos.
272
+
273
+ `discover_projects` deliberately stops at a project root so monorepo
274
+ workspaces do not each become a duplicate, cross-workspace-blind project.
275
+ A nested *separate git repo* is collateral of that rule: it is a real
276
+ project the daemon never supervises, so its graph goes stale indefinitely
277
+ while status and health report everything healthy (#6).
278
+
279
+ The pruning stays -- this only makes the skip visible.
280
+ """
281
+ found: list[Path] = []
282
+ try:
283
+ root = project_root.resolve()
284
+ except OSError:
285
+ return found
286
+ for dirpath, dirnames, _ in os.walk(root):
287
+ current = Path(dirpath)
288
+ try:
289
+ rel = current.relative_to(root)
290
+ except ValueError:
291
+ continue
292
+ depth = 0 if rel == Path(".") else len(rel.parts)
293
+ dirnames[:] = [
294
+ d for d in dirnames
295
+ if d not in DISCOVERY_SKIP_DIRS and not d.startswith(".")
296
+ ]
297
+ if depth > max_depth:
298
+ dirnames[:] = []
299
+ continue
300
+ if depth > 0 and (current / ".git").exists():
301
+ found.append(current)
302
+ # Its own nested repos are its problem, not this project's.
303
+ dirnames[:] = []
304
+ return sorted(found, key=lambda p: str(p).lower())
305
+
306
+
307
+ def daemon_config_for_project(cfg: Config, root: Path, options: DaemonOptions) -> Config:
308
+ data = cfg.canonical_graph().to_dict()
309
+ if not Path(data["output_dir"]).is_absolute():
310
+ data["output_dir"] = root / data["output_dir"]
311
+ if not Path(data["cache_dir"]).is_absolute():
312
+ data["cache_dir"] = root / data["cache_dir"]
313
+ if options.max_files_per_project is not None:
314
+ current_max = data.get("max_files")
315
+ data["max_files"] = min(current_max, options.max_files_per_project) if current_max else options.max_files_per_project
316
+ return Config(**data)
317
+
318
+
319
+ def _build_command(cfg: Config, root: Path) -> tuple[list[str], dict[str, str]]:
320
+ cfg = cfg.canonical_graph()
321
+ cmd = [
322
+ sys.executable,
323
+ "-B",
324
+ "-P",
325
+ "-m",
326
+ "graphite",
327
+ "--output-dir",
328
+ str(cfg.output_dir),
329
+ "--cache-dir",
330
+ str(cfg.cache_dir),
331
+ "--workers",
332
+ str(cfg.workers),
333
+ "--typescript-resolver",
334
+ cfg.typescript_resolver,
335
+ "--typescript-resolver-timeout",
336
+ str(cfg.typescript_resolver_timeout_seconds),
337
+ ]
338
+ if not cfg.typescript_symbol_references:
339
+ cmd.append("--no-typescript-symbol-references")
340
+ cmd.extend(["--llm", "none", "build", str(root)])
341
+
342
+ env: dict[str, str] = {}
343
+ for key in os.environ:
344
+ if key.upper().startswith(_PROVIDER_ENV_PREFIXES):
345
+ continue
346
+ env[key] = os.environ[key]
347
+ src_root = str(Path(__file__).resolve().parents[1])
348
+ existing_pythonpath = env.get("PYTHONPATH")
349
+ env["PYTHONPATH"] = src_root if not existing_pythonpath else f"{src_root}{os.pathsep}{existing_pythonpath}"
350
+ # Mark this as a daemon-spawned build so the CLI activation backstop stays
351
+ # quiet. The child runs INSIDE the repo being supervised, so without this it
352
+ # would refresh that repo's activation marker on every build and the repo
353
+ # could never expire -- supervision would ratchet up and never release.
354
+ # NOTE: one extra key on the *filtered* env above. Never rebuild this from
355
+ # os.environ; the filtering is what keeps provider credentials out of a
356
+ # daemon child (test_daemon_child_build_has_no_provider_argv_or_environment).
357
+ env[activation.ENV_DAEMON_CHILD] = "1"
358
+ # The cycle already holds the repo build lock; without this the child would
359
+ # contend with its own parent and skip the build it was spawned to do.
360
+ env[buildlock.ENV_LOCK_HELD] = "1"
361
+ return cmd, env
362
+
363
+
364
+ def run_graphite_build(root: Path, cfg: Config, timeout_seconds: float) -> BuildResult:
365
+ """Build one project in an isolated child process with bounded runtime."""
366
+ start = time.time()
367
+ cmd, env = _build_command(cfg, root)
368
+ try:
369
+ result = subprocess.run(
370
+ cmd,
371
+ cwd=root,
372
+ stdin=subprocess.DEVNULL,
373
+ capture_output=True,
374
+ text=True,
375
+ # graphite forces UTF-8 on redirected output (#17). Without this the
376
+ # daemon decodes a supervised build's log with the locale codec, and
377
+ # a single non-Latin-1 path in the output would return None rather
378
+ # than raise -- recording a build failure nobody can explain.
379
+ encoding="utf-8",
380
+ errors="replace",
381
+ check=False,
382
+ timeout=timeout_seconds,
383
+ env=env,
384
+ )
385
+ return BuildResult(
386
+ success=result.returncode == 0,
387
+ returncode=result.returncode,
388
+ duration_seconds=time.time() - start,
389
+ stdout=_tail(result.stdout),
390
+ stderr=_tail(result.stderr),
391
+ )
392
+ except subprocess.TimeoutExpired as exc:
393
+ return BuildResult(
394
+ success=False,
395
+ returncode=None,
396
+ duration_seconds=time.time() - start,
397
+ stdout=_tail(exc.stdout or ""),
398
+ stderr=_tail(exc.stderr or ""),
399
+ error=f"build timed out after {timeout_seconds:.1f}s",
400
+ )
401
+ except OSError as exc:
402
+ return BuildResult(
403
+ success=False,
404
+ returncode=None,
405
+ duration_seconds=time.time() - start,
406
+ error=str(exc),
407
+ )
408
+
409
+
410
+ BuildProject = Callable[[Path, Config, float], BuildResult]
411
+ SleepFn = Callable[[float], None]
412
+ ProviderObservationCycle = Callable[[], ProviderObservationSummary]
413
+
414
+
415
+ class _ProviderObservationWorker:
416
+ """Run bounded provider work outside the graph scheduling thread."""
417
+
418
+ def __init__(
419
+ self,
420
+ cycle: ProviderObservationCycle,
421
+ logger: DaemonLogger,
422
+ interval_seconds: float,
423
+ ) -> None:
424
+ self._cycle = cycle
425
+ self._logger = logger
426
+ self._interval_seconds = interval_seconds
427
+ self._stop = threading.Event()
428
+ self._lock = threading.Lock()
429
+ self._status: dict[str, object] = {
430
+ "status": "observing",
431
+ "attempted": 0,
432
+ "deferred": 0,
433
+ "succeeded": 0,
434
+ "failed": 0,
435
+ "state_counts": {},
436
+ "reason_counts": {},
437
+ }
438
+ self._thread = threading.Thread(
439
+ target=self._run,
440
+ name="graphite-provider-observer",
441
+ daemon=True,
442
+ )
443
+
444
+ def start(self) -> None:
445
+ self._thread.start()
446
+
447
+ def stop(self) -> None:
448
+ self._stop.set()
449
+
450
+ def snapshot(self) -> dict[str, object]:
451
+ with self._lock:
452
+ return {
453
+ **self._status,
454
+ "state_counts": dict(self._status["state_counts"]),
455
+ "reason_counts": dict(self._status["reason_counts"]),
456
+ }
457
+
458
+ def _run(self) -> None:
459
+ while not self._stop.is_set():
460
+ try:
461
+ result = self._cycle()
462
+ if not isinstance(result, ProviderObservationSummary):
463
+ raise TypeError("provider_observation_result_invalid")
464
+ status = result.to_status()
465
+ status["status"] = "degraded" if result.failed else "ok"
466
+ except Exception:
467
+ status = {
468
+ "status": "degraded",
469
+ "attempted": 1,
470
+ "deferred": 0,
471
+ "succeeded": 0,
472
+ "failed": 1,
473
+ "state_counts": {},
474
+ "reason_counts": {"observer_cycle_failed": 1},
475
+ }
476
+ record_incident(
477
+ self._logger.state_dir,
478
+ klass="daemon",
479
+ code="provider_probe_failed",
480
+ subject="daemon",
481
+ detail="observer_cycle_failed",
482
+ )
483
+ with self._lock:
484
+ self._status = status
485
+ self._logger.event("provider_observation_cycle", **status)
486
+ if self._stop.wait(self._interval_seconds):
487
+ return
488
+
489
+
490
+ def _write_status(
491
+ state_dir: Path,
492
+ base: Path,
493
+ states: dict[Path, ProjectRuntime],
494
+ options: DaemonOptions,
495
+ cycle: int,
496
+ provider_lifecycle: dict[str, object] | None = None,
497
+ ) -> dict[str, object]:
498
+ projects = [state.to_status() for _, state in sorted(states.items(), key=lambda item: str(item[0]).lower())]
499
+ healthy = sum(1 for state in states.values() if state.last_error is None and not state.needs_initial_build)
500
+ failing = sum(1 for state in states.values() if state.last_error is not None)
501
+ pending = sum(1 for state in states.values() if state.needs_initial_build)
502
+ payload: dict[str, object] = {
503
+ "status": "ok" if failing == 0 else "degraded",
504
+ "updated_at": utc_now(),
505
+ "base_path": str(base),
506
+ "cycle": cycle,
507
+ "project_count": len(projects),
508
+ "healthy_projects": healthy,
509
+ "failing_projects": failing,
510
+ "pending_projects": pending,
511
+ # Repositories currently open in a coding agent -- the whole supervised
512
+ # set. An unopened repo is absent by construction, not by pruning.
513
+ "active_projects": sorted(str(root) for root in states),
514
+ "limits": {
515
+ "scan_interval_seconds": options.scan_interval_seconds,
516
+ "discover_interval_seconds": options.discover_interval_seconds,
517
+ "debounce_seconds": options.debounce_seconds,
518
+ "max_depth": options.max_depth,
519
+ "max_projects": options.max_projects,
520
+ "max_files_per_project": options.max_files_per_project,
521
+ "max_builds_per_cycle": options.max_builds_per_cycle,
522
+ "build_timeout_seconds": options.build_timeout_seconds,
523
+ },
524
+ "projects": projects,
525
+ }
526
+ if provider_lifecycle is not None:
527
+ payload["provider_lifecycle"] = provider_lifecycle
528
+ atomic_write_json(state_dir / "status.json", payload, indent=2)
529
+ return payload
530
+
531
+
532
+ def read_daemon_status(base: Path, state_dir: Path | None = None) -> dict[str, object]:
533
+ base = base.resolve()
534
+ path = (state_dir or (base / ".graphite-daemon")) / "status.json"
535
+ with open(path, "rb") as f:
536
+ payload = f.read(MAX_DAEMON_STATUS_BYTES + 1)
537
+ if len(payload) > MAX_DAEMON_STATUS_BYTES:
538
+ raise DaemonStatusTooLargeError("daemon status exceeds size limit")
539
+ decoded = payload.decode("utf-8", errors="strict")
540
+ try:
541
+ return json.loads(decoded)
542
+ except json.JSONDecodeError:
543
+ raise
544
+ except ValueError:
545
+ raise DaemonStatusInvalidError("daemon status is invalid") from None
546
+
547
+
548
+ def _record_build_result(state: ProjectRuntime, change: WatchChange, result: BuildResult) -> None:
549
+ state.last_build_finished_at = utc_now()
550
+ state.last_build_seconds = round(result.duration_seconds, 3)
551
+ state.build_count += 1
552
+ state.last_change = summarize_change(change)
553
+ if result.success:
554
+ state.last_success_at = state.last_build_finished_at
555
+ state.last_error = None
556
+ state.needs_initial_build = False
557
+ else:
558
+ state.failure_count += 1
559
+ state.last_error = result.error or result.stderr or result.stdout or f"build failed with code {result.returncode}"
560
+ record_incident(
561
+ repo_ledger_dir(state.root),
562
+ klass="daemon",
563
+ code="daemon_build_failed",
564
+ subject=str(state.root),
565
+ detail=state.last_error or "build failed",
566
+ )
567
+
568
+
569
+ def run_daemon(
570
+ base: Path,
571
+ cfg: Config,
572
+ options: DaemonOptions,
573
+ *,
574
+ build_project: BuildProject = run_graphite_build,
575
+ sleep: SleepFn = time.sleep,
576
+ provider_observation_cycle: ProviderObservationCycle | None = None,
577
+ ) -> dict[str, object]:
578
+ """Run the multi-project daemon loop and return the last written status."""
579
+ options.validate()
580
+ base = base.resolve()
581
+ if not base.exists():
582
+ raise FileNotFoundError(base)
583
+ state_dir = (options.state_dir or (base / ".graphite-daemon")).resolve()
584
+ logger = DaemonLogger(state_dir, options.log_max_bytes)
585
+ logger.event("daemon_start", base_path=str(base), once=options.once)
586
+ provider_worker = None
587
+ if provider_observation_cycle is not None:
588
+ provider_options = cfg.provider_observer_options()
589
+ provider_worker = _ProviderObservationWorker(
590
+ provider_observation_cycle,
591
+ logger,
592
+ provider_options.interval_seconds,
593
+ )
594
+ provider_worker.start()
595
+
596
+ states: dict[Path, ProjectRuntime] = {}
597
+ last_discovery = 0.0
598
+ last_status: dict[str, object] = {}
599
+ cycle = 0
600
+
601
+ while True:
602
+ now = time.time()
603
+ if cycle == 0 or now - last_discovery >= options.discover_interval_seconds:
604
+ # Supervision follows activation markers, not a filesystem walk: a
605
+ # repository nobody has open is never snapshotted and never built.
606
+ # Because markers carry absolute roots, a repo outside `base` -- or
607
+ # nested inside another repo -- is supervised on equal terms, which
608
+ # is what makes the nested-repo blindness (#6) structurally
609
+ # impossible rather than merely reported.
610
+ records = activation.read_active(ttl_seconds=options.activation_ttl_seconds)
611
+ projects = [record.root for record in records]
612
+ discovered = set(projects)
613
+ for project in projects:
614
+ if project not in states:
615
+ try:
616
+ project_cfg = daemon_config_for_project(cfg, project, options)
617
+ snap = snapshot(project, project_cfg)
618
+ except Exception as exc:
619
+ logger.event("project_snapshot_failed", project=str(project), error=str(exc))
620
+ continue
621
+ states[project] = ProjectRuntime(
622
+ root=project,
623
+ snapshot=snap,
624
+ needs_initial_build=options.build_now,
625
+ )
626
+ logger.event("project_activated", project=str(project), file_count=len(snap))
627
+ else:
628
+ states[project].last_seen_at = utc_now()
629
+ for project in list(states):
630
+ if project not in discovered:
631
+ logger.event("project_deactivated", project=str(project))
632
+ del states[project]
633
+
634
+ # An engine upgrade invalidates every supervised graph at once, but
635
+ # the file-diff path cannot see it: a repo whose files are untouched
636
+ # was never rebuilt on engine grounds (#18). Activation transitions
637
+ # bound the window -- they force a full rebuild -- but a repo held
638
+ # continuously open across an upgrade never transitions.
639
+ #
640
+ # Only correct because #21 keys the extraction cache on engine
641
+ # identity. Before that, this rebuild would have served the old
642
+ # engine's cached extraction and rewritten the recorded fingerprint
643
+ # to the new value, so `check` would report fresh -- automating the
644
+ # self-concealing failure rather than fixing it.
645
+ #
646
+ # Computed once per discovery cycle, not per project: it hashes the
647
+ # packaged engine files, and the discovery interval is what
648
+ # rate-limits it.
649
+ try:
650
+ current_engine: dict[str, str] | None = engine_identity(cfg.cache_version)
651
+ except EngineIdentityError as exc:
652
+ logger.event("engine_identity_unavailable", error=str(exc))
653
+ current_engine = None
654
+ if current_engine is not None:
655
+ for project, state in states.items():
656
+ if state.needs_initial_build:
657
+ continue
658
+ if graph_engine_changed(
659
+ daemon_config_for_project(cfg, project, options), current_engine
660
+ ):
661
+ state.needs_initial_build = True
662
+ logger.event("engine_changed", project=str(project))
663
+
664
+ last_discovery = now
665
+
666
+ builds_this_cycle = 0
667
+ for project, state in sorted(states.items(), key=lambda item: str(item[0]).lower()):
668
+ if builds_this_cycle >= options.max_builds_per_cycle:
669
+ break
670
+ project_cfg = daemon_config_for_project(cfg, project, options)
671
+ try:
672
+ if state.needs_initial_build:
673
+ change = WatchChange(added=tuple(sorted(state.snapshot)))
674
+ else:
675
+ current = snapshot(project, project_cfg)
676
+ change = diff_snapshots(state.snapshot, current)
677
+ if not change.has_changes:
678
+ continue
679
+ stable = wait_for_stable_snapshot(project, project_cfg, current, options.debounce_seconds)
680
+ change = diff_snapshots(state.snapshot, stable)
681
+ if not change.has_changes:
682
+ state.snapshot = stable
683
+ continue
684
+ current = stable
685
+ except Exception as exc:
686
+ state.last_error = str(exc)
687
+ state.failure_count += 1
688
+ logger.event("project_scan_failed", project=str(project), error=str(exc))
689
+ # Failures before a build is attempted (git enumeration,
690
+ # snapshotting, watcher errors) never reached the incident
691
+ # writer, so they were durable only in status.json, which ages
692
+ # out. A distinct code separates them from build failures (#3).
693
+ record_incident(
694
+ repo_ledger_dir(state.root),
695
+ klass="daemon",
696
+ code="daemon_cycle_failed",
697
+ subject=str(state.root),
698
+ detail=str(exc) or "project scan failed",
699
+ )
700
+ continue
701
+
702
+ with buildlock.build_lock(project_cfg.cache_dir) as acquired:
703
+ if not acquired:
704
+ # Another builder (a git hook, or a manual `graphite build`)
705
+ # owns this repo right now. Deliberately NOT recorded as a
706
+ # failure: _record_build_result would set last_error and
707
+ # write a ledger incident, and daemon_health counts any
708
+ # project with last_error as failing. Retry next cycle --
709
+ # needs_initial_build is intentionally left untouched.
710
+ logger.event("build_skipped_locked", project=str(project))
711
+ continue
712
+
713
+ was_initial_build = state.needs_initial_build
714
+ state.last_build_started_at = utc_now()
715
+ logger.event("build_started", project=str(project), change=summarize_change(change))
716
+ result = build_project(project, project_cfg, options.build_timeout_seconds)
717
+ _record_build_result(state, change, result)
718
+ if result.success:
719
+ if not was_initial_build:
720
+ state.snapshot = current
721
+ logger.event("build_succeeded", project=str(project), seconds=state.last_build_seconds)
722
+ else:
723
+ logger.event("build_failed", project=str(project), seconds=state.last_build_seconds, error=state.last_error)
724
+ builds_this_cycle += 1
725
+
726
+ last_status = _write_status(
727
+ state_dir,
728
+ base,
729
+ states,
730
+ options,
731
+ cycle,
732
+ None if provider_worker is None else provider_worker.snapshot(),
733
+ )
734
+ if options.once:
735
+ if provider_worker is not None:
736
+ provider_worker.stop()
737
+ logger.event("daemon_stop", reason="once", cycle=cycle)
738
+ return last_status
739
+ cycle += 1
740
+ if options.max_cycles is not None and cycle >= options.max_cycles:
741
+ if provider_worker is not None:
742
+ provider_worker.stop()
743
+ logger.event("daemon_stop", reason="max_cycles", cycle=cycle)
744
+ return last_status
745
+ sleep(options.scan_interval_seconds)