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.
- graphite/__init__.py +41 -0
- graphite/__main__.py +7 -0
- graphite/_cleanup_worker.py +525 -0
- graphite/activation.py +164 -0
- graphite/agent_hooks.py +577 -0
- graphite/agent_settings.py +226 -0
- graphite/analyze.py +146 -0
- graphite/answer_contract.py +420 -0
- graphite/bootstrap.py +210 -0
- graphite/buildlock.py +99 -0
- graphite/cache.py +131 -0
- graphite/channel.py +1325 -0
- graphite/cli.py +3053 -0
- graphite/cluster.py +111 -0
- graphite/config.py +209 -0
- graphite/context.py +355 -0
- graphite/daemon.py +745 -0
- graphite/daemon_health.py +733 -0
- graphite/debt.py +118 -0
- graphite/dependency_install.py +1597 -0
- graphite/detach.py +33 -0
- graphite/doctor.py +678 -0
- graphite/doctor_probes.py +2100 -0
- graphite/engine_identity.py +238 -0
- graphite/export/__init__.py +6 -0
- graphite/export/html.py +244 -0
- graphite/export/json.py +39 -0
- graphite/export/md.py +68 -0
- graphite/extract/__init__.py +4 -0
- graphite/extract/ast.py +1964 -0
- graphite/freshness.py +127 -0
- graphite/git.py +406 -0
- graphite/graph.py +117 -0
- graphite/graph_io.py +188 -0
- graphite/health.py +147 -0
- graphite/hook_entry.py +68 -0
- graphite/hookinstall.py +224 -0
- graphite/hookshim.py +86 -0
- graphite/incident_ledger.py +247 -0
- graphite/ingest.py +279 -0
- graphite/init.py +791 -0
- graphite/io.py +32 -0
- graphite/listing.py +51 -0
- graphite/llm.py +518 -0
- graphite/llm_probe.py +157 -0
- graphite/mcp.py +7 -0
- graphite/mcp_server.py +450 -0
- graphite/natural_query.py +252 -0
- graphite/overlays.py +713 -0
- graphite/probe_process.py +879 -0
- graphite/probe_workspace.py +728 -0
- graphite/process_contracts.py +22 -0
- graphite/provider_observer.py +397 -0
- graphite/query.py +646 -0
- graphite/query_plan.py +97 -0
- graphite/replacement_audit.py +291 -0
- graphite/resolve.py +660 -0
- graphite/review.py +782 -0
- graphite/routing/__init__.py +5 -0
- graphite/routing/approval.py +362 -0
- graphite/routing/classifier.py +169 -0
- graphite/routing/claude_executor.py +419 -0
- graphite/routing/claude_probe.py +102 -0
- graphite/routing/cli_identity.py +84 -0
- graphite/routing/codex_executor.py +383 -0
- graphite/routing/codex_probe.py +93 -0
- graphite/routing/context_builder.py +327 -0
- graphite/routing/contracts.py +802 -0
- graphite/routing/diff_policy.py +468 -0
- graphite/routing/edit_apply.py +166 -0
- graphite/routing/effort.py +43 -0
- graphite/routing/lifecycle.py +771 -0
- graphite/routing/lifecycle_operator.py +227 -0
- graphite/routing/lifecycle_service.py +555 -0
- graphite/routing/lifecycle_storage.py +977 -0
- graphite/routing/ollama_executor.py +341 -0
- graphite/routing/ollama_probe.py +72 -0
- graphite/routing/openrouter_executor.py +338 -0
- graphite/routing/openrouter_probe.py +188 -0
- graphite/routing/policy.py +815 -0
- graphite/routing/probe_runner.py +543 -0
- graphite/routing/process_runner.py +523 -0
- graphite/routing/profiles.py +554 -0
- graphite/routing/prompt.py +58 -0
- graphite/routing/registry.py +444 -0
- graphite/routing/route_pool.py +629 -0
- graphite/routing/route_pool_execution.py +275 -0
- graphite/routing/schema_validation.py +169 -0
- graphite/routing/service.py +1263 -0
- graphite/routing/settings.py +99 -0
- graphite/routing/shadow.py +201 -0
- graphite/routing/storage.py +4001 -0
- graphite/routing/telemetry.py +346 -0
- graphite/routing/worktree.py +259 -0
- graphite/routing/zai_edit.py +113 -0
- graphite/routing/zai_executor.py +191 -0
- graphite/routing/zai_probe.py +126 -0
- graphite/savings.py +84 -0
- graphite/ts_bridge.py +142 -0
- graphite/ts_resolver.mjs +314 -0
- graphite/typescript_activation.py +1586 -0
- graphite/usage_ledger.py +156 -0
- graphite/validation.py +148 -0
- graphite/watch.py +167 -0
- graphite/windows_job.py +368 -0
- graphite/windows_startup.py +144 -0
- graphite/windows_task.py +212 -0
- graphite_code-0.3.0.dist-info/METADATA +743 -0
- graphite_code-0.3.0.dist-info/RECORD +112 -0
- graphite_code-0.3.0.dist-info/WHEEL +4 -0
- graphite_code-0.3.0.dist-info/entry_points.txt +3 -0
- 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)
|