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
@@ -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
+ }