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
|
@@ -0,0 +1,733 @@
|
|
|
1
|
+
"""Operational health checks for the Graphite multi-project daemon."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import platform
|
|
6
|
+
import subprocess
|
|
7
|
+
import unicodedata
|
|
8
|
+
from collections.abc import Mapping
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Callable
|
|
13
|
+
|
|
14
|
+
from .daemon import DaemonStatusInvalidError, DaemonStatusTooLargeError, read_daemon_status
|
|
15
|
+
from .routing.lifecycle import LifecycleReasonCode, ProviderLifecycleState
|
|
16
|
+
from .windows_task import DEFAULT_TASK_NAME
|
|
17
|
+
from .windows_startup import startup_status
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class HealthOptions:
|
|
22
|
+
max_status_age_seconds: float = 180.0
|
|
23
|
+
max_project_success_age_seconds: float = 86_400.0
|
|
24
|
+
require_process: bool = True
|
|
25
|
+
require_startup: bool = True
|
|
26
|
+
startup_name: str = DEFAULT_TASK_NAME
|
|
27
|
+
|
|
28
|
+
def validate(self) -> None:
|
|
29
|
+
if self.max_status_age_seconds <= 0:
|
|
30
|
+
raise ValueError("max status age must be greater than zero")
|
|
31
|
+
if self.max_project_success_age_seconds <= 0:
|
|
32
|
+
raise ValueError("max project success age must be greater than zero")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
ProcessChecker = Callable[[Path], dict[str, Any]]
|
|
36
|
+
StartupChecker = Callable[[Path, str], dict[str, Any]]
|
|
37
|
+
|
|
38
|
+
_MAX_INPUT_PROJECTS = 1_000
|
|
39
|
+
_MAX_CATEGORY_DETAILS = 50
|
|
40
|
+
_MAX_ROOT_LENGTH = 512
|
|
41
|
+
_MAX_LAST_ERROR_LENGTH = 2_048
|
|
42
|
+
_MAX_TIMESTAMP_LENGTH = 128
|
|
43
|
+
_MAX_STATUS_LENGTH = 128
|
|
44
|
+
_MAX_REPORT_ISSUES = 100
|
|
45
|
+
_MAX_TEXT_FIELD_LENGTH = 2_048
|
|
46
|
+
_PROVIDER_STATUS_VALUES = frozenset({"observing", "ok", "degraded"})
|
|
47
|
+
_PROVIDER_REASON_CODES = frozenset(reason.value for reason in LifecycleReasonCode) | frozenset(
|
|
48
|
+
{
|
|
49
|
+
"lifecycle_invalidation_failed",
|
|
50
|
+
"lifecycle_observation_invalid",
|
|
51
|
+
"lifecycle_persistence_failed",
|
|
52
|
+
"observer_cycle_failed",
|
|
53
|
+
"probe_auth_unhealthy",
|
|
54
|
+
"probe_capability_missing",
|
|
55
|
+
"probe_dns_busy",
|
|
56
|
+
"probe_endpoint_invalid",
|
|
57
|
+
"probe_executable_invalid",
|
|
58
|
+
"probe_failed",
|
|
59
|
+
"probe_http_status",
|
|
60
|
+
"probe_identity_changed",
|
|
61
|
+
"probe_model_unavailable",
|
|
62
|
+
"probe_protocol_invalid",
|
|
63
|
+
"probe_request_invalid",
|
|
64
|
+
"probe_response_too_large",
|
|
65
|
+
"probe_timeout",
|
|
66
|
+
"probe_unavailable",
|
|
67
|
+
"probe_version_invalid",
|
|
68
|
+
"verification_manifest_invalid",
|
|
69
|
+
}
|
|
70
|
+
)
|
|
71
|
+
_PROVIDER_STATE_CODES = frozenset(state.value for state in ProviderLifecycleState)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def evaluate_daemon_health(
|
|
75
|
+
base: Path,
|
|
76
|
+
*,
|
|
77
|
+
state_dir: Path | None = None,
|
|
78
|
+
options: HealthOptions | None = None,
|
|
79
|
+
now: datetime | None = None,
|
|
80
|
+
process_checker: ProcessChecker | None = None,
|
|
81
|
+
startup_checker: StartupChecker | None = None,
|
|
82
|
+
) -> dict[str, Any]:
|
|
83
|
+
"""Evaluate daemon health from status artifacts and local runtime signals."""
|
|
84
|
+
opts = options or HealthOptions()
|
|
85
|
+
opts.validate()
|
|
86
|
+
base = base.resolve()
|
|
87
|
+
current_time = now or datetime.now(timezone.utc)
|
|
88
|
+
errors: list[dict[str, Any]] = []
|
|
89
|
+
warnings: list[dict[str, Any]] = []
|
|
90
|
+
status_path = (state_dir or (base / ".graphite-daemon")) / "status.json"
|
|
91
|
+
|
|
92
|
+
def _with_incidents(report: dict[str, Any], project_roots: list[str]) -> dict[str, Any]:
|
|
93
|
+
from .incident_ledger import fold_incidents, read_incident_entries, repo_ledger_dir
|
|
94
|
+
|
|
95
|
+
g_entries, _g_skipped = read_incident_entries(status_path.parent)
|
|
96
|
+
g_views = [v for v in fold_incidents(g_entries) if v.state != "resolved"]
|
|
97
|
+
by_class: dict[str, int] = {}
|
|
98
|
+
for v in g_views:
|
|
99
|
+
if v.state == "open":
|
|
100
|
+
by_class[v.klass] = by_class.get(v.klass, 0) + 1
|
|
101
|
+
projects: dict[str, int] = {}
|
|
102
|
+
for project_root in project_roots[:_MAX_INPUT_PROJECTS]:
|
|
103
|
+
p_entries, _ = read_incident_entries(repo_ledger_dir(Path(project_root)))
|
|
104
|
+
open_count = sum(1 for v in fold_incidents(p_entries) if v.state == "open")
|
|
105
|
+
if open_count:
|
|
106
|
+
projects[str(project_root)] = open_count
|
|
107
|
+
report["incidents"] = {
|
|
108
|
+
"open": sum(1 for v in g_views if v.state == "open"),
|
|
109
|
+
"acked": sum(1 for v in g_views if v.state == "acked"),
|
|
110
|
+
"by_class": by_class,
|
|
111
|
+
"projects": projects,
|
|
112
|
+
}
|
|
113
|
+
return report
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
raw_status = read_daemon_status(base, state_dir)
|
|
117
|
+
except FileNotFoundError:
|
|
118
|
+
return _with_incidents(
|
|
119
|
+
_finalize(
|
|
120
|
+
base,
|
|
121
|
+
status_path,
|
|
122
|
+
current_time,
|
|
123
|
+
status=None,
|
|
124
|
+
status_age_seconds=None,
|
|
125
|
+
errors=[{"code": "status_missing", "message": f"daemon status not found: {status_path}"}],
|
|
126
|
+
warnings=[],
|
|
127
|
+
process={"checked": False},
|
|
128
|
+
startup={"checked": False},
|
|
129
|
+
project_health={"failing": [], "pending": [], "not_built_recently": []},
|
|
130
|
+
),
|
|
131
|
+
[],
|
|
132
|
+
)
|
|
133
|
+
except DaemonStatusTooLargeError:
|
|
134
|
+
return _with_incidents(
|
|
135
|
+
_finalize(
|
|
136
|
+
base,
|
|
137
|
+
status_path,
|
|
138
|
+
current_time,
|
|
139
|
+
status=None,
|
|
140
|
+
status_age_seconds=None,
|
|
141
|
+
errors=[{
|
|
142
|
+
"code": "status_too_large",
|
|
143
|
+
"message": "daemon status exceeds the maximum allowed size",
|
|
144
|
+
}],
|
|
145
|
+
warnings=[],
|
|
146
|
+
process={"checked": False},
|
|
147
|
+
startup={"checked": False},
|
|
148
|
+
project_health={"failing": [], "pending": [], "not_built_recently": []},
|
|
149
|
+
),
|
|
150
|
+
[],
|
|
151
|
+
)
|
|
152
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError, DaemonStatusInvalidError):
|
|
153
|
+
return _with_incidents(
|
|
154
|
+
_finalize(
|
|
155
|
+
base,
|
|
156
|
+
status_path,
|
|
157
|
+
current_time,
|
|
158
|
+
status=None,
|
|
159
|
+
status_age_seconds=None,
|
|
160
|
+
errors=[{"code": "status_unreadable", "message": "daemon status is unreadable"}],
|
|
161
|
+
warnings=[],
|
|
162
|
+
process={"checked": False},
|
|
163
|
+
startup={"checked": False},
|
|
164
|
+
project_health={"failing": [], "pending": [], "not_built_recently": []},
|
|
165
|
+
),
|
|
166
|
+
[],
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
status, schema_issues, status_meta = _normalize_status(raw_status)
|
|
170
|
+
errors.extend(schema_issues)
|
|
171
|
+
|
|
172
|
+
updated_at_value = status.get("updated_at")
|
|
173
|
+
updated_at = _parse_time(updated_at_value if isinstance(updated_at_value, str) else "")
|
|
174
|
+
status_age = None
|
|
175
|
+
if updated_at is None:
|
|
176
|
+
errors.append({"code": "status_updated_at_invalid", "message": "status updated_at is missing or invalid"})
|
|
177
|
+
else:
|
|
178
|
+
status_age = max(0.0, (current_time - updated_at).total_seconds())
|
|
179
|
+
if status_age > opts.max_status_age_seconds:
|
|
180
|
+
errors.append({
|
|
181
|
+
"code": "status_stale",
|
|
182
|
+
"message": f"daemon status is {status_age:.1f}s old",
|
|
183
|
+
"age_seconds": round(status_age, 3),
|
|
184
|
+
"max_age_seconds": opts.max_status_age_seconds,
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
if status.get("status") not in ("ok", None):
|
|
188
|
+
errors.append({"code": "daemon_degraded", "message": f"daemon reported status {status.get('status')}"})
|
|
189
|
+
|
|
190
|
+
provider_lifecycle = status.get("provider_lifecycle")
|
|
191
|
+
if isinstance(provider_lifecycle, dict) and (
|
|
192
|
+
provider_lifecycle["status"] == "degraded"
|
|
193
|
+
or provider_lifecycle["failed"] > 0
|
|
194
|
+
or provider_lifecycle["state_counts"].get("unavailable", 0) > 0
|
|
195
|
+
):
|
|
196
|
+
warnings.append({
|
|
197
|
+
"code": "provider_observation_degraded",
|
|
198
|
+
"message": "one or more provider lifecycle observations require attention",
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
project_health, project_counts = _project_health(
|
|
202
|
+
status,
|
|
203
|
+
current_time,
|
|
204
|
+
opts.max_project_success_age_seconds,
|
|
205
|
+
)
|
|
206
|
+
for project in project_health["failing"]:
|
|
207
|
+
_append_bounded(errors, {
|
|
208
|
+
"code": "project_failing",
|
|
209
|
+
"message": f"project build failing: {project['root']}",
|
|
210
|
+
"project": project,
|
|
211
|
+
})
|
|
212
|
+
for project in project_health["pending"]:
|
|
213
|
+
_append_bounded(warnings, {
|
|
214
|
+
"code": "project_pending_initial_build",
|
|
215
|
+
"message": f"project has not completed initial build: {project['root']}",
|
|
216
|
+
"project": project,
|
|
217
|
+
})
|
|
218
|
+
for project in project_health["not_built_recently"]:
|
|
219
|
+
_append_bounded(warnings, {
|
|
220
|
+
"code": "project_not_built_recently",
|
|
221
|
+
"message": f"project not built recently: {project['root']}",
|
|
222
|
+
"project": project,
|
|
223
|
+
})
|
|
224
|
+
# The nested-repo warning (#6) is deliberately gone. Supervision follows
|
|
225
|
+
# activation markers, which carry absolute roots, so a nested repo is
|
|
226
|
+
# supervised on exactly the same terms as any other once it is opened --
|
|
227
|
+
# and an unopened repo being unsupervised is now correct rather than a
|
|
228
|
+
# defect worth warning about.
|
|
229
|
+
|
|
230
|
+
process = {"checked": False}
|
|
231
|
+
if opts.require_process:
|
|
232
|
+
checker = process_checker or check_daemon_process
|
|
233
|
+
process = checker(base)
|
|
234
|
+
if process.get("error"):
|
|
235
|
+
warnings.append({
|
|
236
|
+
"code": "daemon_process_check_unavailable",
|
|
237
|
+
"message": "Graphite daemon process observation is unavailable",
|
|
238
|
+
})
|
|
239
|
+
elif not process.get("running"):
|
|
240
|
+
errors.append({"code": "daemon_process_not_running", "message": "Graphite daemon process is not running"})
|
|
241
|
+
|
|
242
|
+
startup = {"checked": False}
|
|
243
|
+
if opts.require_startup:
|
|
244
|
+
checker = startup_checker or check_startup_launcher
|
|
245
|
+
startup = checker(base, opts.startup_name)
|
|
246
|
+
if startup.get("supported") is False:
|
|
247
|
+
warnings.append({"code": "startup_check_unsupported", "message": str(startup.get("error", "startup check unsupported"))})
|
|
248
|
+
elif not startup.get("installed"):
|
|
249
|
+
warnings.append({"code": "startup_not_installed", "message": "Graphite startup launcher is not installed"})
|
|
250
|
+
|
|
251
|
+
project_roots = [item["root"] for item in status["projects"]]
|
|
252
|
+
return _with_incidents(
|
|
253
|
+
_finalize(
|
|
254
|
+
base,
|
|
255
|
+
status_path,
|
|
256
|
+
current_time,
|
|
257
|
+
status=status,
|
|
258
|
+
status_age_seconds=status_age,
|
|
259
|
+
errors=errors,
|
|
260
|
+
warnings=warnings,
|
|
261
|
+
process=process,
|
|
262
|
+
startup=startup,
|
|
263
|
+
project_health=project_health,
|
|
264
|
+
project_counts=project_counts,
|
|
265
|
+
status_meta=status_meta,
|
|
266
|
+
),
|
|
267
|
+
project_roots,
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def check_daemon_process(base: Path) -> dict[str, Any]:
|
|
272
|
+
"""Check whether a daemon process for the base path is running."""
|
|
273
|
+
if platform.system().lower() != "windows":
|
|
274
|
+
return {"checked": True, "supported": False, "running": None, "processes": [], "error": "process check currently supports Windows"}
|
|
275
|
+
marker = str(base.resolve())
|
|
276
|
+
ps = (
|
|
277
|
+
"Get-CimInstance Win32_Process | "
|
|
278
|
+
"Where-Object { $_.CommandLine -like '*graphite*daemon*' "
|
|
279
|
+
"-and $_.CommandLine -like '*" + marker.replace("'", "''") + "*' "
|
|
280
|
+
"-and $_.CommandLine -notlike '*daemon-status*' "
|
|
281
|
+
"-and $_.CommandLine -notlike '*daemon-health*' "
|
|
282
|
+
"-and $_.CommandLine -notlike '*daemon-install*' "
|
|
283
|
+
"-and $_.CommandLine -notlike '*daemon-uninstall*' } | "
|
|
284
|
+
"Select-Object ProcessId,CommandLine | ConvertTo-Json -Compress"
|
|
285
|
+
)
|
|
286
|
+
try:
|
|
287
|
+
result = subprocess.run(
|
|
288
|
+
["powershell.exe", "-NoProfile", "-Command", ps],
|
|
289
|
+
capture_output=True,
|
|
290
|
+
text=True,
|
|
291
|
+
# `errors` but deliberately NO `encoding`: Windows PowerShell 5.1
|
|
292
|
+
# emits the console codepage, not UTF-8, so pinning a codec here
|
|
293
|
+
# would be wrong. What must not happen is an undecodable byte in a
|
|
294
|
+
# command line -- these are repository paths -- killing subprocess's
|
|
295
|
+
# reader thread and returning None in place of the JSON below.
|
|
296
|
+
errors="replace",
|
|
297
|
+
check=False,
|
|
298
|
+
timeout=10,
|
|
299
|
+
)
|
|
300
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
301
|
+
return {"checked": True, "supported": True, "running": False, "processes": [], "error": str(exc)}
|
|
302
|
+
if result.returncode != 0:
|
|
303
|
+
return {"checked": True, "supported": True, "running": False, "processes": [], "error": result.stderr.strip() or result.stdout.strip()}
|
|
304
|
+
stdout = result.stdout.strip()
|
|
305
|
+
if not stdout:
|
|
306
|
+
processes: list[dict[str, Any]] = []
|
|
307
|
+
else:
|
|
308
|
+
try:
|
|
309
|
+
parsed = json.loads(stdout)
|
|
310
|
+
processes = parsed if isinstance(parsed, list) else [parsed]
|
|
311
|
+
except json.JSONDecodeError:
|
|
312
|
+
processes = []
|
|
313
|
+
return {"checked": True, "supported": True, "running": bool(processes), "process_count": len(processes), "processes": processes}
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def check_startup_launcher(base: Path, name: str) -> dict[str, Any]:
|
|
317
|
+
if platform.system().lower() != "windows":
|
|
318
|
+
return {"checked": True, "supported": False, "installed": None, "error": "startup check only applies on Windows"}
|
|
319
|
+
try:
|
|
320
|
+
status = startup_status(base, name=name)
|
|
321
|
+
except Exception as exc:
|
|
322
|
+
return {"checked": True, "supported": True, "installed": False, "error": str(exc)}
|
|
323
|
+
return {"checked": True, "supported": True, **status}
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
_MAX_GROUP_DETAILS = 5
|
|
327
|
+
_MAX_ISSUE_LINES = 20
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _count_phrase(count: int, noun: str) -> str:
|
|
331
|
+
return f"{count} {noun}" + ("" if count == 1 else "s")
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _health_head_line(report: dict[str, Any]) -> str:
|
|
335
|
+
status = _safe_text(report["status"])
|
|
336
|
+
if report["status"] == "ok":
|
|
337
|
+
return "[graphite] daemon health: ok"
|
|
338
|
+
summary = report.get("summary", {})
|
|
339
|
+
error_count = summary.get("error_count", len(report.get("errors", [])))
|
|
340
|
+
warning_count = summary.get("warning_count", len(report.get("warnings", [])))
|
|
341
|
+
if error_count:
|
|
342
|
+
detail = f"{_count_phrase(error_count, 'error')}, {_count_phrase(warning_count, 'warning')}"
|
|
343
|
+
else:
|
|
344
|
+
detail = f"{_count_phrase(warning_count, 'warning')}, no errors"
|
|
345
|
+
return f"[graphite] daemon health: {status} ({detail})"
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _issue_lines(
|
|
349
|
+
label: str, issues: list[dict[str, Any]], *, cap: int = _MAX_ISSUE_LINES
|
|
350
|
+
) -> list[str]:
|
|
351
|
+
shown = issues[:cap]
|
|
352
|
+
dropped = len(issues) - len(shown)
|
|
353
|
+
lines = [f"{label}:"]
|
|
354
|
+
groups: dict[str, list[dict[str, Any]]] = {}
|
|
355
|
+
for issue in shown:
|
|
356
|
+
groups.setdefault(str(issue.get("code")), []).append(issue)
|
|
357
|
+
for code, members in groups.items():
|
|
358
|
+
if len(members) == 1:
|
|
359
|
+
lines.append(f" - {_safe_text(code)}: {_safe_text(members[0].get('message'))}")
|
|
360
|
+
continue
|
|
361
|
+
lines.append(f" - {_safe_text(code)} ({len(members)}):")
|
|
362
|
+
for issue in members[:_MAX_GROUP_DETAILS]:
|
|
363
|
+
lines.append(f" {_safe_text(issue.get('message'))}")
|
|
364
|
+
if len(members) > _MAX_GROUP_DETAILS:
|
|
365
|
+
lines.append(f" ... {len(members) - _MAX_GROUP_DETAILS} more")
|
|
366
|
+
if dropped > 0:
|
|
367
|
+
lines.append(f" ... {dropped} more")
|
|
368
|
+
return lines
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def format_health_text(report: dict[str, Any]) -> str:
|
|
372
|
+
lines = [
|
|
373
|
+
_health_head_line(report),
|
|
374
|
+
f" base: {_safe_text(report['base_path'])}",
|
|
375
|
+
f" status file: {_safe_text(report['status_path'])}",
|
|
376
|
+
]
|
|
377
|
+
if report.get("status_age_seconds") is not None:
|
|
378
|
+
lines.append(f" status age: {report['status_age_seconds']:.1f}s")
|
|
379
|
+
summary = report["summary"]
|
|
380
|
+
lines.append(
|
|
381
|
+
" projects: "
|
|
382
|
+
f"{summary['project_count']} total, {summary['failing_count']} failing, "
|
|
383
|
+
f"{summary['pending_count']} pending, {summary['not_built_recently_count']} not built recently"
|
|
384
|
+
)
|
|
385
|
+
process = report.get("process", {})
|
|
386
|
+
if process.get("checked"):
|
|
387
|
+
lines.append(f" process running: {process.get('running')} ({process.get('process_count', 0)} processes)")
|
|
388
|
+
startup = report.get("startup", {})
|
|
389
|
+
if startup.get("checked"):
|
|
390
|
+
lines.append(f" startup installed: {startup.get('installed')}")
|
|
391
|
+
if report["errors"]:
|
|
392
|
+
lines.extend(_issue_lines("Errors", report["errors"]))
|
|
393
|
+
if report["warnings"]:
|
|
394
|
+
lines.extend(_issue_lines("Warnings", report["warnings"]))
|
|
395
|
+
return "\n".join(lines) + "\n"
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _project_health(
|
|
399
|
+
status: dict[str, Any],
|
|
400
|
+
now: datetime,
|
|
401
|
+
max_success_age_seconds: float,
|
|
402
|
+
) -> tuple[dict[str, list[dict[str, Any]]], dict[str, int]]:
|
|
403
|
+
failing: list[dict[str, Any]] = []
|
|
404
|
+
pending: list[dict[str, Any]] = []
|
|
405
|
+
old: list[dict[str, Any]] = []
|
|
406
|
+
counts = {"failing": 0, "pending": 0, "not_built_recently": 0}
|
|
407
|
+
for item in status.get("projects", []):
|
|
408
|
+
project = _project_summary(item, now)
|
|
409
|
+
if item.get("last_error") is not None:
|
|
410
|
+
counts["failing"] += 1
|
|
411
|
+
if len(failing) < _MAX_CATEGORY_DETAILS:
|
|
412
|
+
failing.append(project)
|
|
413
|
+
if item.get("needs_initial_build") or int(item.get("build_count") or 0) == 0:
|
|
414
|
+
counts["pending"] += 1
|
|
415
|
+
if len(pending) < _MAX_CATEGORY_DETAILS:
|
|
416
|
+
pending.append(project)
|
|
417
|
+
age = project.get("last_success_age_seconds")
|
|
418
|
+
if age is None or age > max_success_age_seconds:
|
|
419
|
+
counts["not_built_recently"] += 1
|
|
420
|
+
if len(old) < _MAX_CATEGORY_DETAILS:
|
|
421
|
+
old.append(project)
|
|
422
|
+
return {"failing": failing, "pending": pending, "not_built_recently": old}, counts
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
_PROJECT_COUNT_FIELDS = ("build_count", "file_count", "failure_count")
|
|
426
|
+
_MAX_SCHEMA_ISSUES = 20
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _schema_issue(field: str, index: int | None = None) -> dict[str, Any]:
|
|
430
|
+
issue: dict[str, Any] = {
|
|
431
|
+
"code": "status_schema_invalid",
|
|
432
|
+
"message": "daemon status schema is invalid",
|
|
433
|
+
"field": field,
|
|
434
|
+
}
|
|
435
|
+
if index is not None:
|
|
436
|
+
issue["index"] = index
|
|
437
|
+
return issue
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _nonnegative_int(value: object) -> bool:
|
|
441
|
+
return isinstance(value, int) and not isinstance(value, bool) and value >= 0
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _bounded_clean_string(value: object, limit: int, *, nullable: bool) -> bool:
|
|
445
|
+
if value is None:
|
|
446
|
+
return nullable
|
|
447
|
+
return (
|
|
448
|
+
isinstance(value, str)
|
|
449
|
+
and len(value) <= limit
|
|
450
|
+
and all(unicodedata.category(character) not in {"Cc", "Cf"} for character in value)
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _append_bounded(target: list[dict[str, Any]], issue: dict[str, Any]) -> None:
|
|
455
|
+
if len(target) < _MAX_REPORT_ISSUES:
|
|
456
|
+
target.append(issue)
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _safe_text(value: object) -> str:
|
|
460
|
+
raw = str(value)
|
|
461
|
+
escaped: list[str] = []
|
|
462
|
+
for character in raw[:_MAX_TEXT_FIELD_LENGTH]:
|
|
463
|
+
codepoint = ord(character)
|
|
464
|
+
if character == "\n":
|
|
465
|
+
escaped.append("\\n")
|
|
466
|
+
elif character == "\r":
|
|
467
|
+
escaped.append("\\r")
|
|
468
|
+
elif character == "\t":
|
|
469
|
+
escaped.append("\\t")
|
|
470
|
+
elif unicodedata.category(character) in {"Cc", "Cf"}:
|
|
471
|
+
if codepoint <= 0xFFFF:
|
|
472
|
+
escaped.append(f"\\u{codepoint:04x}")
|
|
473
|
+
else:
|
|
474
|
+
escaped.append(f"\\U{codepoint:08x}")
|
|
475
|
+
else:
|
|
476
|
+
escaped.append(character)
|
|
477
|
+
if len(raw) > _MAX_TEXT_FIELD_LENGTH:
|
|
478
|
+
escaped.append("…")
|
|
479
|
+
return "".join(escaped)
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _invalid_project_field(item: Mapping[str, Any]) -> str | None:
|
|
483
|
+
root = item.get("root")
|
|
484
|
+
if (
|
|
485
|
+
not _bounded_clean_string(root, _MAX_ROOT_LENGTH, nullable=False)
|
|
486
|
+
or not root.strip()
|
|
487
|
+
):
|
|
488
|
+
return "root"
|
|
489
|
+
for field in _PROJECT_COUNT_FIELDS:
|
|
490
|
+
if field not in item or not _nonnegative_int(item[field]):
|
|
491
|
+
return field
|
|
492
|
+
if "needs_initial_build" not in item or not isinstance(item["needs_initial_build"], bool):
|
|
493
|
+
return "needs_initial_build"
|
|
494
|
+
for field in ("last_error", "last_success_at"):
|
|
495
|
+
limit = _MAX_LAST_ERROR_LENGTH if field == "last_error" else _MAX_TIMESTAMP_LENGTH
|
|
496
|
+
if field not in item or not _bounded_clean_string(item[field], limit, nullable=True):
|
|
497
|
+
return field
|
|
498
|
+
return None
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _normalize_status(
|
|
502
|
+
raw_status: object,
|
|
503
|
+
) -> tuple[dict[str, Any], list[dict[str, Any]], dict[str, int]]:
|
|
504
|
+
"""Return the bounded, non-leaking subset safe for health classification."""
|
|
505
|
+
if not isinstance(raw_status, Mapping):
|
|
506
|
+
return {"projects": [], "project_count": 0}, [_schema_issue("status")], {
|
|
507
|
+
"processed_count": 0,
|
|
508
|
+
"truncated_count": 0,
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
status = {
|
|
512
|
+
"status": raw_status.get("status"),
|
|
513
|
+
"updated_at": raw_status.get("updated_at"),
|
|
514
|
+
"project_count": raw_status.get("project_count"),
|
|
515
|
+
}
|
|
516
|
+
issues: list[dict[str, Any]] = []
|
|
517
|
+
if "provider_lifecycle" in raw_status:
|
|
518
|
+
provider_lifecycle = _normalize_provider_lifecycle(
|
|
519
|
+
raw_status.get("provider_lifecycle")
|
|
520
|
+
)
|
|
521
|
+
if provider_lifecycle is None:
|
|
522
|
+
issues.append(_schema_issue("provider_lifecycle"))
|
|
523
|
+
else:
|
|
524
|
+
status["provider_lifecycle"] = provider_lifecycle
|
|
525
|
+
# Bounded like every other field here: this dict is the non-leaking subset
|
|
526
|
+
# health is allowed to classify on, so a new key must be normalized
|
|
527
|
+
# explicitly or it is silently dropped (#6).
|
|
528
|
+
raw_active = raw_status.get("active_projects")
|
|
529
|
+
if isinstance(raw_active, list):
|
|
530
|
+
status["active_projects"] = [
|
|
531
|
+
str(item)[:_MAX_ROOT_LENGTH]
|
|
532
|
+
for item in raw_active[:_MAX_INPUT_PROJECTS]
|
|
533
|
+
if isinstance(item, str)
|
|
534
|
+
]
|
|
535
|
+
elif raw_active is not None:
|
|
536
|
+
issues.append(_schema_issue("active_projects"))
|
|
537
|
+
raw_projects = raw_status.get("projects")
|
|
538
|
+
valid_projects: list[dict[str, Any]] = []
|
|
539
|
+
processed_count = 0
|
|
540
|
+
truncated_count = 0
|
|
541
|
+
if not isinstance(raw_projects, list):
|
|
542
|
+
issues.append(_schema_issue("projects"))
|
|
543
|
+
else:
|
|
544
|
+
for index, item in enumerate(raw_projects):
|
|
545
|
+
if index >= _MAX_INPUT_PROJECTS:
|
|
546
|
+
truncated_count = len(raw_projects) - _MAX_INPUT_PROJECTS
|
|
547
|
+
break
|
|
548
|
+
processed_count += 1
|
|
549
|
+
if not isinstance(item, Mapping):
|
|
550
|
+
if len(issues) < _MAX_SCHEMA_ISSUES:
|
|
551
|
+
issues.append(_schema_issue("project", index))
|
|
552
|
+
continue
|
|
553
|
+
invalid_field = _invalid_project_field(item)
|
|
554
|
+
if invalid_field is not None:
|
|
555
|
+
if len(issues) < _MAX_SCHEMA_ISSUES:
|
|
556
|
+
issues.append(_schema_issue(invalid_field, index))
|
|
557
|
+
continue
|
|
558
|
+
valid_projects.append({
|
|
559
|
+
"root": item["root"],
|
|
560
|
+
"file_count": item["file_count"],
|
|
561
|
+
"build_count": item["build_count"],
|
|
562
|
+
"failure_count": item["failure_count"],
|
|
563
|
+
"last_error": item["last_error"],
|
|
564
|
+
"last_success_at": item["last_success_at"],
|
|
565
|
+
"needs_initial_build": item["needs_initial_build"],
|
|
566
|
+
})
|
|
567
|
+
if truncated_count:
|
|
568
|
+
issues.insert(0, {
|
|
569
|
+
"code": "status_truncated",
|
|
570
|
+
"message": "daemon status projects were truncated",
|
|
571
|
+
"processed_count": processed_count,
|
|
572
|
+
"truncated_count": truncated_count,
|
|
573
|
+
})
|
|
574
|
+
|
|
575
|
+
raw_project_count = raw_status.get("project_count")
|
|
576
|
+
if not _nonnegative_int(raw_project_count):
|
|
577
|
+
if len(issues) < _MAX_SCHEMA_ISSUES:
|
|
578
|
+
issues.append(_schema_issue("project_count"))
|
|
579
|
+
status["project_count"] = len(valid_projects)
|
|
580
|
+
|
|
581
|
+
raw_daemon_status = raw_status.get("status")
|
|
582
|
+
if raw_daemon_status is not None and (
|
|
583
|
+
not isinstance(raw_daemon_status, str)
|
|
584
|
+
or len(raw_daemon_status) > _MAX_STATUS_LENGTH
|
|
585
|
+
):
|
|
586
|
+
if len(issues) < _MAX_SCHEMA_ISSUES:
|
|
587
|
+
issues.append(_schema_issue("status"))
|
|
588
|
+
status["status"] = None
|
|
589
|
+
|
|
590
|
+
raw_updated_at = raw_status.get("updated_at")
|
|
591
|
+
if not _bounded_clean_string(raw_updated_at, _MAX_TIMESTAMP_LENGTH, nullable=False):
|
|
592
|
+
if len(issues) < _MAX_SCHEMA_ISSUES:
|
|
593
|
+
issues.append(_schema_issue("updated_at"))
|
|
594
|
+
status["updated_at"] = None
|
|
595
|
+
|
|
596
|
+
status["projects"] = valid_projects
|
|
597
|
+
return status, issues[:_MAX_REPORT_ISSUES], {
|
|
598
|
+
"processed_count": processed_count,
|
|
599
|
+
"truncated_count": truncated_count,
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def _normalize_provider_lifecycle(value: object) -> dict[str, Any] | None:
|
|
604
|
+
fields = {
|
|
605
|
+
"status",
|
|
606
|
+
"attempted",
|
|
607
|
+
"deferred",
|
|
608
|
+
"succeeded",
|
|
609
|
+
"failed",
|
|
610
|
+
"state_counts",
|
|
611
|
+
"reason_counts",
|
|
612
|
+
}
|
|
613
|
+
if not isinstance(value, Mapping) or set(value) != fields:
|
|
614
|
+
return None
|
|
615
|
+
if value.get("status") not in _PROVIDER_STATUS_VALUES:
|
|
616
|
+
return None
|
|
617
|
+
counts: dict[str, int] = {}
|
|
618
|
+
for field in ("attempted", "deferred", "succeeded", "failed"):
|
|
619
|
+
raw = value.get(field)
|
|
620
|
+
if not _nonnegative_int(raw) or raw > 1_000:
|
|
621
|
+
return None
|
|
622
|
+
counts[field] = raw
|
|
623
|
+
if counts["attempted"] != counts["succeeded"] + counts["failed"]:
|
|
624
|
+
return None
|
|
625
|
+
|
|
626
|
+
normalized_maps: dict[str, dict[str, int]] = {}
|
|
627
|
+
for field, allowed in (
|
|
628
|
+
("state_counts", _PROVIDER_STATE_CODES),
|
|
629
|
+
("reason_counts", _PROVIDER_REASON_CODES),
|
|
630
|
+
):
|
|
631
|
+
raw_map = value.get(field)
|
|
632
|
+
if not isinstance(raw_map, Mapping) or len(raw_map) > 32:
|
|
633
|
+
return None
|
|
634
|
+
normalized: dict[str, int] = {}
|
|
635
|
+
for key, count in raw_map.items():
|
|
636
|
+
if key not in allowed or not _nonnegative_int(count) or count > 1_000:
|
|
637
|
+
return None
|
|
638
|
+
normalized[key] = count
|
|
639
|
+
normalized_maps[field] = dict(sorted(normalized.items()))
|
|
640
|
+
if sum(normalized_maps["state_counts"].values()) > counts["attempted"]:
|
|
641
|
+
return None
|
|
642
|
+
if sum(normalized_maps["reason_counts"].values()) != counts["attempted"]:
|
|
643
|
+
return None
|
|
644
|
+
return {
|
|
645
|
+
"status": value["status"],
|
|
646
|
+
**counts,
|
|
647
|
+
**normalized_maps,
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def _project_summary(item: dict[str, Any], now: datetime) -> dict[str, Any]:
|
|
652
|
+
last_success = _parse_time(str(item.get("last_success_at") or ""))
|
|
653
|
+
age = None if last_success is None else max(0.0, (now - last_success).total_seconds())
|
|
654
|
+
return {
|
|
655
|
+
"root": item.get("root"),
|
|
656
|
+
"file_count": item.get("file_count"),
|
|
657
|
+
"build_count": item.get("build_count"),
|
|
658
|
+
"failure_count": item.get("failure_count"),
|
|
659
|
+
"last_error": item.get("last_error"),
|
|
660
|
+
"last_success_at": item.get("last_success_at"),
|
|
661
|
+
"last_success_age_seconds": None if age is None else round(age, 3),
|
|
662
|
+
"needs_initial_build": item.get("needs_initial_build"),
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def _parse_time(value: str) -> datetime | None:
|
|
667
|
+
if not value:
|
|
668
|
+
return None
|
|
669
|
+
try:
|
|
670
|
+
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
671
|
+
except ValueError:
|
|
672
|
+
return None
|
|
673
|
+
if parsed.tzinfo is None:
|
|
674
|
+
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
675
|
+
return parsed.astimezone(timezone.utc)
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def _finalize(
|
|
679
|
+
base: Path,
|
|
680
|
+
status_path: Path,
|
|
681
|
+
now: datetime,
|
|
682
|
+
*,
|
|
683
|
+
status: dict[str, Any] | None,
|
|
684
|
+
status_age_seconds: float | None,
|
|
685
|
+
errors: list[dict[str, Any]],
|
|
686
|
+
warnings: list[dict[str, Any]],
|
|
687
|
+
process: dict[str, Any],
|
|
688
|
+
startup: dict[str, Any],
|
|
689
|
+
project_health: dict[str, list[dict[str, Any]]],
|
|
690
|
+
project_counts: dict[str, int] | None = None,
|
|
691
|
+
status_meta: dict[str, int] | None = None,
|
|
692
|
+
) -> dict[str, Any]:
|
|
693
|
+
errors = errors[:_MAX_REPORT_ISSUES]
|
|
694
|
+
warnings = warnings[:max(0, _MAX_REPORT_ISSUES - len(errors))]
|
|
695
|
+
health_status = "degraded" if errors else "warning" if warnings else "ok"
|
|
696
|
+
project_count = status.get("project_count", 0) if status else 0
|
|
697
|
+
counts = project_counts or {
|
|
698
|
+
"failing": len(project_health.get("failing", [])),
|
|
699
|
+
"pending": len(project_health.get("pending", [])),
|
|
700
|
+
"not_built_recently": len(project_health.get("not_built_recently", [])),
|
|
701
|
+
}
|
|
702
|
+
summary = {
|
|
703
|
+
"project_count": project_count,
|
|
704
|
+
"failing_count": counts["failing"],
|
|
705
|
+
"pending_count": counts["pending"],
|
|
706
|
+
"not_built_recently_count": counts["not_built_recently"],
|
|
707
|
+
"error_count": len(errors),
|
|
708
|
+
"warning_count": len(warnings),
|
|
709
|
+
}
|
|
710
|
+
if status_meta and status_meta.get("truncated_count", 0):
|
|
711
|
+
summary["projects_processed_count"] = status_meta["processed_count"]
|
|
712
|
+
summary["projects_truncated_count"] = status_meta["truncated_count"]
|
|
713
|
+
return {
|
|
714
|
+
"ok": not errors,
|
|
715
|
+
"status": health_status,
|
|
716
|
+
"checked_at": now.isoformat(timespec="seconds"),
|
|
717
|
+
"base_path": str(base),
|
|
718
|
+
"status_path": str(status_path),
|
|
719
|
+
"status_age_seconds": None if status_age_seconds is None else round(status_age_seconds, 3),
|
|
720
|
+
"daemon_status": status.get("status") if status else None,
|
|
721
|
+
# The repos open in a coding agent -- the entire supervised set. Making
|
|
722
|
+
# it visible matters because the failure mode inverted: instead of
|
|
723
|
+
# building too much, graphite can now build nothing because activation
|
|
724
|
+
# never fired, and that is otherwise silent.
|
|
725
|
+
"active_projects": (status.get("active_projects") if status else None) or [],
|
|
726
|
+
"summary": summary,
|
|
727
|
+
"errors": errors,
|
|
728
|
+
"warnings": warnings,
|
|
729
|
+
"process": process,
|
|
730
|
+
"startup": startup,
|
|
731
|
+
"provider_lifecycle": status.get("provider_lifecycle") if status else None,
|
|
732
|
+
"projects": project_health,
|
|
733
|
+
}
|