zeno-cli 0.3.4__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.
- zeno_adapters/__init__.py +17 -0
- zeno_adapters/_common.py +38 -0
- zeno_adapters/anthropic.py +68 -0
- zeno_adapters/claude_code.py +101 -0
- zeno_adapters/crewai.py +92 -0
- zeno_adapters/langgraph.py +49 -0
- zeno_adapters/openai.py +108 -0
- zeno_cli/__init__.py +1 -0
- zeno_cli/_hooks/cc_bridge.py +1016 -0
- zeno_cli/doctor.py +535 -0
- zeno_cli/hook_install.py +269 -0
- zeno_cli/hud/__init__.py +1 -0
- zeno_cli/hud/hud_install.py +652 -0
- zeno_cli/hud/zeno_attention.py +288 -0
- zeno_cli/hud/zeno_cognition.py +457 -0
- zeno_cli/hud/zeno_hud.py +496 -0
- zeno_cli/interview_invites.py +342 -0
- zeno_cli/login.py +241 -0
- zeno_cli/main.py +2534 -0
- zeno_cli/onboard.py +206 -0
- zeno_cli/outreach.py +456 -0
- zeno_cli/version.py +67 -0
- zeno_cli-0.3.4.dist-info/METADATA +161 -0
- zeno_cli-0.3.4.dist-info/RECORD +69 -0
- zeno_cli-0.3.4.dist-info/WHEEL +4 -0
- zeno_cli-0.3.4.dist-info/entry_points.txt +4 -0
- zeno_core/__init__.py +67 -0
- zeno_core/analytics.py +193 -0
- zeno_core/rtlx_s.py +460 -0
- zeno_core/streak.py +178 -0
- zeno_core/tlx_s.py +192 -0
- zeno_sdk/__init__.py +6 -0
- zeno_sdk/_generated/__init__.py +6 -0
- zeno_sdk/_generated/client.py +819 -0
- zeno_sdk/_migrations/alembic/env.py +33 -0
- zeno_sdk/_migrations/alembic/script.py.mako +18 -0
- zeno_sdk/_migrations/alembic/versions/0001_initial.py +79 -0
- zeno_sdk/_migrations/alembic/versions/0002_cognition_samples.py +53 -0
- zeno_sdk/_migrations/alembic/versions/0003_cognition_drivers.py +41 -0
- zeno_sdk/_migrations/alembic/versions/0004_transcript_intelligence.py +248 -0
- zeno_sdk/_migrations/alembic.ini +35 -0
- zeno_sdk/_runtime.py +12 -0
- zeno_sdk/adapters/__init__.py +15 -0
- zeno_sdk/adapters/anthropic.py +5 -0
- zeno_sdk/adapters/claude_code.py +5 -0
- zeno_sdk/adapters/crewai.py +5 -0
- zeno_sdk/adapters/langgraph.py +5 -0
- zeno_sdk/adapters/openai.py +5 -0
- zeno_sdk/auth.py +25 -0
- zeno_sdk/client.py +87 -0
- zeno_sdk/config.py +61 -0
- zeno_sdk/daemon.py +72 -0
- zeno_sdk/privacy.py +46 -0
- zeno_sdk/session.py +179 -0
- zeno_sdk/storage.py +487 -0
- zeno_sdk/types/__init__.py +121 -0
- zeno_session_intel/__init__.py +19 -0
- zeno_session_intel/analytics.py +588 -0
- zeno_session_intel/compression.py +123 -0
- zeno_session_intel/ingest.py +376 -0
- zeno_session_intel/model.py +129 -0
- zeno_session_intel/parsers/__init__.py +31 -0
- zeno_session_intel/parsers/claude_code.py +169 -0
- zeno_session_intel/parsers/codex.py +265 -0
- zeno_session_intel/parsers/cursor.py +198 -0
- zeno_session_intel/prices.py +281 -0
- zeno_session_intel/schema.py +277 -0
- zeno_session_intel/signals.py +319 -0
- zeno_session_intel/taxonomy.py +71 -0
zeno_cli/doctor.py
ADDED
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
"""zeno doctor - diagnostic suite for the local install.
|
|
2
|
+
|
|
3
|
+
One-shot health check the operator runs when something feels off. Each row
|
|
4
|
+
is a single check that prints a colored status (PASS/WARN/FAIL) + a detail
|
|
5
|
+
string. Exit code: 0 if every row is PASS, 1 if any row is FAIL. WARN rows
|
|
6
|
+
do NOT fail the run - they're "look here next" hints.
|
|
7
|
+
|
|
8
|
+
Checks (in execution order):
|
|
9
|
+
1. Python version (PASS >=3.12, FAIL <3.12)
|
|
10
|
+
2. zeno-cli package version (PASS if version_string() resolves)
|
|
11
|
+
3. Local SQLite DB exists at ~/.zeno/zeno.db
|
|
12
|
+
4. Last survey timestamp (WARN if >7 days, PASS otherwise, WARN if absent)
|
|
13
|
+
5. API reachability via GET /v1/health (WARN if unreachable; zeno is local-first)
|
|
14
|
+
6. Tailnet identity via GET /v1/me (PASS if identity comes back, WARN otherwise)
|
|
15
|
+
7. Cognitive-state cache freshness at ~/.zeno/cognitive-state.json (WARN if absent/stale)
|
|
16
|
+
8. Capture hook installed (checks settings.json AND settings.local.json; WARN if absent)
|
|
17
|
+
9. Session-intel (ingest/usage) analytics surface importable (WARN if absent)
|
|
18
|
+
10. Bunmail reachability (only when ZENO_BUNMAIL_BASE_URL set)
|
|
19
|
+
|
|
20
|
+
The doctor is intentionally chatty (one row per check, not collapsed) so
|
|
21
|
+
the operator can paste the output into a support thread or self-diagnose.
|
|
22
|
+
Network checks default to a 2s timeout each so a doctor run finishes in
|
|
23
|
+
under 10s even when everything is unreachable.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import os
|
|
29
|
+
import shutil
|
|
30
|
+
import sqlite3
|
|
31
|
+
import subprocess
|
|
32
|
+
import sys
|
|
33
|
+
from dataclasses import dataclass
|
|
34
|
+
from datetime import datetime, timedelta
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
from urllib.error import HTTPError, URLError
|
|
37
|
+
from urllib.request import Request, urlopen
|
|
38
|
+
|
|
39
|
+
from .version import version_string
|
|
40
|
+
|
|
41
|
+
# Status codes used by every row. Mirrors `zeno email check-dns`.
|
|
42
|
+
STATUS_PASS = "PASS"
|
|
43
|
+
STATUS_WARN = "WARN"
|
|
44
|
+
STATUS_FAIL = "FAIL"
|
|
45
|
+
|
|
46
|
+
# Timeout for every network probe (seconds). Total wall-time for a doctor
|
|
47
|
+
# run with two unreachable hosts is bounded by NUM_NET_CHECKS * NET_TIMEOUT.
|
|
48
|
+
NET_TIMEOUT = 2.0
|
|
49
|
+
|
|
50
|
+
# Last-survey staleness threshold. Past this, doctor flags WARN; past 14
|
|
51
|
+
# days it would arguably FAIL, but Mar's PM2 dogfood policy is "log every
|
|
52
|
+
# coding session", and weekly-only counts as WARN-worthy not broken.
|
|
53
|
+
SURVEY_STALE_DAYS = 7
|
|
54
|
+
|
|
55
|
+
# Cognitive-state cache file lives next to the SQLite DB. Per research 2
|
|
56
|
+
# the cache is touched on every /v1/cognitive-state read; older than 30
|
|
57
|
+
# minutes means either the API is unreachable or no GET has happened in
|
|
58
|
+
# a while - both worth flagging WARN.
|
|
59
|
+
COGNITIVE_STATE_STALE_MINUTES = 30
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(slots=True, frozen=True)
|
|
63
|
+
class DoctorCheck:
|
|
64
|
+
"""One row of the doctor output. label is the diagnostic name, status
|
|
65
|
+
is PASS/WARN/FAIL, detail is a short context string."""
|
|
66
|
+
|
|
67
|
+
label: str
|
|
68
|
+
status: str
|
|
69
|
+
detail: str
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _http_get(
|
|
73
|
+
url: str, *, timeout: float, headers: dict[str, str] | None = None
|
|
74
|
+
) -> tuple[int, str]:
|
|
75
|
+
"""Best-effort GET. Returns (status_code, body) or (0, error_message) on failure.
|
|
76
|
+
|
|
77
|
+
A status_code of 0 is the sentinel for "couldn't even open the socket";
|
|
78
|
+
the caller treats it as FAIL. HTTP error responses (401/500/etc.) still
|
|
79
|
+
surface their code so the operator can see the failure mode.
|
|
80
|
+
"""
|
|
81
|
+
request_headers = {"Accept": "application/json"}
|
|
82
|
+
if headers:
|
|
83
|
+
request_headers.update(headers)
|
|
84
|
+
request = Request(url=url, method="GET", headers=request_headers)
|
|
85
|
+
try:
|
|
86
|
+
with urlopen(request, timeout=timeout) as response:
|
|
87
|
+
return response.status, response.read().decode("utf-8", errors="replace")
|
|
88
|
+
except HTTPError as exc:
|
|
89
|
+
try:
|
|
90
|
+
body = exc.read().decode("utf-8", errors="replace")
|
|
91
|
+
except (OSError, AttributeError):
|
|
92
|
+
body = str(exc.reason)
|
|
93
|
+
return exc.code, body
|
|
94
|
+
except (URLError, OSError) as exc:
|
|
95
|
+
return 0, str(exc)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def check_python_version() -> DoctorCheck:
|
|
99
|
+
"""Pass when Python >= 3.12, the minimum declared in apps/cli/pyproject.toml."""
|
|
100
|
+
major, minor = sys.version_info[:2]
|
|
101
|
+
label = "Python version"
|
|
102
|
+
detail = f"{major}.{minor}.{sys.version_info.micro}"
|
|
103
|
+
if (major, minor) >= (3, 12):
|
|
104
|
+
return DoctorCheck(label=label, status=STATUS_PASS, detail=detail)
|
|
105
|
+
return DoctorCheck(
|
|
106
|
+
label=label,
|
|
107
|
+
status=STATUS_FAIL,
|
|
108
|
+
detail=f"{detail} (requires 3.12+)",
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def check_zeno_version() -> DoctorCheck:
|
|
113
|
+
"""Always passes when version_string() resolves to something printable.
|
|
114
|
+
|
|
115
|
+
The point of this row is to fingerprint the install in the doctor
|
|
116
|
+
output so support can answer "what version are you on" without a
|
|
117
|
+
follow-up question.
|
|
118
|
+
"""
|
|
119
|
+
version = version_string()
|
|
120
|
+
return DoctorCheck(
|
|
121
|
+
label="zeno-cli version",
|
|
122
|
+
status=STATUS_PASS,
|
|
123
|
+
detail=version,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def check_local_db(db_path: Path) -> DoctorCheck:
|
|
128
|
+
"""SQLite DB lives at $ZENO_DB_PATH or ~/.zeno/zeno.db. Verify it exists
|
|
129
|
+
and is openable. A missing DB is WARN not FAIL - a brand-new install
|
|
130
|
+
that hasn't run `zeno survey` yet is a valid state.
|
|
131
|
+
"""
|
|
132
|
+
label = "Local SQLite DB"
|
|
133
|
+
if not db_path.exists():
|
|
134
|
+
return DoctorCheck(
|
|
135
|
+
label=label,
|
|
136
|
+
status=STATUS_WARN,
|
|
137
|
+
detail=f"{db_path} (run 'zeno survey' to create)",
|
|
138
|
+
)
|
|
139
|
+
try:
|
|
140
|
+
conn = sqlite3.connect(str(db_path))
|
|
141
|
+
try:
|
|
142
|
+
# Read sqlite_master, not `SELECT 1`. `SELECT 1` is a constant that
|
|
143
|
+
# never touches the file, so a corrupt / non-sqlite file slips
|
|
144
|
+
# through - and inconsistently across sqlite builds (it raised on
|
|
145
|
+
# macOS sqlite but PASSed on the CI runner's sqlite). Reading the
|
|
146
|
+
# schema forces a page-1/header read, which raises SQLITE_NOTADB on
|
|
147
|
+
# a genuinely bad file uniformly.
|
|
148
|
+
conn.execute("SELECT count(*) FROM sqlite_master").fetchone()
|
|
149
|
+
finally:
|
|
150
|
+
conn.close()
|
|
151
|
+
except sqlite3.Error as exc:
|
|
152
|
+
return DoctorCheck(label=label, status=STATUS_FAIL, detail=f"{db_path}: {exc}")
|
|
153
|
+
return DoctorCheck(label=label, status=STATUS_PASS, detail=str(db_path))
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def check_last_survey(db_path: Path) -> DoctorCheck:
|
|
157
|
+
"""Read the most recent load_probes.responded_at and rate its staleness.
|
|
158
|
+
|
|
159
|
+
No DB / no probes -> WARN with the bootstrap hint. Today -> PASS.
|
|
160
|
+
1-7 days -> PASS with a soft hint. >7 days -> WARN (the streak is
|
|
161
|
+
probably broken too; doctor surfaces this so the next action is
|
|
162
|
+
obvious).
|
|
163
|
+
"""
|
|
164
|
+
label = "Last survey"
|
|
165
|
+
if not db_path.exists():
|
|
166
|
+
return DoctorCheck(
|
|
167
|
+
label=label,
|
|
168
|
+
status=STATUS_WARN,
|
|
169
|
+
detail="no DB yet (run 'zeno survey')",
|
|
170
|
+
)
|
|
171
|
+
try:
|
|
172
|
+
conn = sqlite3.connect(str(db_path))
|
|
173
|
+
try:
|
|
174
|
+
row = conn.execute(
|
|
175
|
+
"SELECT MAX(responded_at) FROM load_probes WHERE responded_at IS NOT NULL"
|
|
176
|
+
).fetchone()
|
|
177
|
+
finally:
|
|
178
|
+
conn.close()
|
|
179
|
+
except sqlite3.Error as exc:
|
|
180
|
+
return DoctorCheck(label=label, status=STATUS_WARN, detail=f"query failed: {exc}")
|
|
181
|
+
|
|
182
|
+
if row is None or row[0] is None:
|
|
183
|
+
return DoctorCheck(
|
|
184
|
+
label=label,
|
|
185
|
+
status=STATUS_WARN,
|
|
186
|
+
detail="no surveys yet (run 'zeno survey')",
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
try:
|
|
190
|
+
ts = datetime.fromisoformat(str(row[0]))
|
|
191
|
+
except ValueError:
|
|
192
|
+
return DoctorCheck(label=label, status=STATUS_WARN, detail=f"unparseable: {row[0]!r}")
|
|
193
|
+
|
|
194
|
+
now = datetime.now(ts.tzinfo) if ts.tzinfo else datetime.now()
|
|
195
|
+
delta = now - ts
|
|
196
|
+
iso = ts.isoformat(timespec="seconds")
|
|
197
|
+
if delta < timedelta(days=1):
|
|
198
|
+
return DoctorCheck(label=label, status=STATUS_PASS, detail=f"today ({iso})")
|
|
199
|
+
if delta <= timedelta(days=SURVEY_STALE_DAYS):
|
|
200
|
+
return DoctorCheck(
|
|
201
|
+
label=label,
|
|
202
|
+
status=STATUS_PASS,
|
|
203
|
+
detail=f"{delta.days}d ago ({iso})",
|
|
204
|
+
)
|
|
205
|
+
return DoctorCheck(
|
|
206
|
+
label=label,
|
|
207
|
+
status=STATUS_WARN,
|
|
208
|
+
detail=f"{delta.days}d ago ({iso}) - run 'zeno survey'",
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def check_capture_hook(settings_path: Path | None = None) -> DoctorCheck:
|
|
213
|
+
"""Is the zeno capture hook wired into Claude Code's settings.json?
|
|
214
|
+
|
|
215
|
+
A CLI install with no ``zeno hook install`` captures ZERO sessions, yet every
|
|
216
|
+
other row still PASSes - so doctor surfaces it. WARN, not FAIL: capture is opt-in
|
|
217
|
+
(some users only want the CLI), so a missing hook should not fail the run.
|
|
218
|
+
|
|
219
|
+
Presence uses the same dual-file resolution rule as the HUD installer's own
|
|
220
|
+
presence check (``hud.hud_install.capture_hook_present``) - an independent read here,
|
|
221
|
+
not a shared call, so keep the two in sync by hand: when no explicit path is given,
|
|
222
|
+
BOTH ``settings.json`` AND its sibling ``settings.local.json`` are consulted.
|
|
223
|
+
That dual lookup matters on a symlinked-dotfiles machine (Mar's own setup): when
|
|
224
|
+
``settings.json`` is a symlink into a shared dotfiles checkout, ``zeno hud install``
|
|
225
|
+
redirects the write to the host-local ``settings.local.json``, so reading only
|
|
226
|
+
``settings.json`` here FALSE-WARNed "not installed" even when capture was live.
|
|
227
|
+
|
|
228
|
+
An explicit ``settings_path`` is honored verbatim (single file) - that's the contract
|
|
229
|
+
the CLI's ``--settings-path`` and the test suite rely on; the settings.local.json
|
|
230
|
+
fallback only kicks in for the default (``settings_path is None``) path.
|
|
231
|
+
"""
|
|
232
|
+
from .hook_install import HOOK_EVENTS, default_settings_path, status # noqa: PLC0415
|
|
233
|
+
|
|
234
|
+
label = "Capture hook"
|
|
235
|
+
n = len(HOOK_EVENTS)
|
|
236
|
+
|
|
237
|
+
if settings_path is not None:
|
|
238
|
+
candidates: list[Path] = [settings_path]
|
|
239
|
+
else:
|
|
240
|
+
base = default_settings_path()
|
|
241
|
+
candidates = [base, base.with_name("settings.local.json")]
|
|
242
|
+
|
|
243
|
+
# Read every candidate; keep the status with the MOST installed events (so a hook in
|
|
244
|
+
# settings.local.json wins over an empty/symlinked settings.json). `read_ok` tracks
|
|
245
|
+
# whether any candidate was readable at all, to distinguish "not installed" from
|
|
246
|
+
# "could not read".
|
|
247
|
+
best = None
|
|
248
|
+
read_ok = False
|
|
249
|
+
for candidate in candidates:
|
|
250
|
+
try:
|
|
251
|
+
cand_st = status(candidate)
|
|
252
|
+
except Exception:
|
|
253
|
+
continue
|
|
254
|
+
read_ok = True
|
|
255
|
+
if best is None or len(cand_st["events_installed"]) > len(best["events_installed"]):
|
|
256
|
+
best = cand_st
|
|
257
|
+
|
|
258
|
+
if not read_ok or best is None:
|
|
259
|
+
return DoctorCheck(
|
|
260
|
+
label=label, status=STATUS_WARN, detail=f"could not read {candidates[0]}"
|
|
261
|
+
)
|
|
262
|
+
if best["all_events_installed"]:
|
|
263
|
+
return DoctorCheck(label=label, status=STATUS_PASS, detail=f"installed ({n} events)")
|
|
264
|
+
if best["events_installed"]:
|
|
265
|
+
got = len(best["events_installed"])
|
|
266
|
+
return DoctorCheck(
|
|
267
|
+
label=label,
|
|
268
|
+
status=STATUS_WARN,
|
|
269
|
+
detail=f"partial ({got}/{n} events); re-run 'zeno hook install'",
|
|
270
|
+
)
|
|
271
|
+
return DoctorCheck(
|
|
272
|
+
label=label,
|
|
273
|
+
status=STATUS_WARN,
|
|
274
|
+
detail="not installed - run 'zeno hook install' to capture sessions",
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def check_api_health(base_url: str) -> DoctorCheck:
|
|
279
|
+
"""GET /v1/health on the configured API base URL.
|
|
280
|
+
|
|
281
|
+
PASS on 200. Anything else is WARN, never FAIL: zeno is local-first, so an
|
|
282
|
+
unreachable API (an off-tailnet teammate) or a flaky server does not break
|
|
283
|
+
local capture + survey + curve. Only broken local dependencies (Python, the CLI,
|
|
284
|
+
the SQLite DB) FAIL on an end-user install, so a fresh local-only install is green.
|
|
285
|
+
(A repo checkout adds a dev-only ``Codegen`` row that FAILs on UNCOMMITTED generated
|
|
286
|
+
code - a real dev error, not a runtime dependency; it never exists on a wheel install.)
|
|
287
|
+
"""
|
|
288
|
+
label = "API reachability"
|
|
289
|
+
url = f"{base_url.rstrip('/')}/v1/health"
|
|
290
|
+
code, body = _http_get(url, timeout=NET_TIMEOUT)
|
|
291
|
+
if code == 200:
|
|
292
|
+
return DoctorCheck(label=label, status=STATUS_PASS, detail=f"200 {url}")
|
|
293
|
+
if code == 0:
|
|
294
|
+
return DoctorCheck(
|
|
295
|
+
label=label,
|
|
296
|
+
status=STATUS_WARN,
|
|
297
|
+
detail=f"unreachable - local-only mode (not on the tailnet?): {body[:50]}",
|
|
298
|
+
)
|
|
299
|
+
return DoctorCheck(label=label, status=STATUS_WARN, detail=f"{code} {url}")
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def check_tailnet_identity(base_url: str) -> DoctorCheck:
|
|
303
|
+
"""GET /v1/me. PASS if a tier is returned. WARN if 401 (no identity yet),
|
|
304
|
+
WARN if API unreachable (the previous check already FAILed that).
|
|
305
|
+
"""
|
|
306
|
+
label = "Tailnet identity"
|
|
307
|
+
# Centralized resolution: ZENO_API_TOKEN override, else the `zeno login`
|
|
308
|
+
# keyring token. Lazy import avoids a circular dependency (main imports
|
|
309
|
+
# doctor at module load).
|
|
310
|
+
from .main import resolve_api_token # noqa: PLC0415
|
|
311
|
+
|
|
312
|
+
token = resolve_api_token()
|
|
313
|
+
headers = {"Authorization": f"Bearer {token}"} if token else None
|
|
314
|
+
url = f"{base_url.rstrip('/')}/v1/me"
|
|
315
|
+
code, body = _http_get(url, timeout=NET_TIMEOUT, headers=headers)
|
|
316
|
+
if code == 200:
|
|
317
|
+
try:
|
|
318
|
+
import json # noqa: PLC0415
|
|
319
|
+
|
|
320
|
+
parsed = json.loads(body)
|
|
321
|
+
user = parsed.get("user_id", "<no user_id>")
|
|
322
|
+
tier = parsed.get("tier", "<no tier>")
|
|
323
|
+
return DoctorCheck(label=label, status=STATUS_PASS, detail=f"{user} tier={tier}")
|
|
324
|
+
except (ValueError, AttributeError):
|
|
325
|
+
return DoctorCheck(
|
|
326
|
+
label=label, status=STATUS_WARN, detail=f"200 but malformed: {body[:60]}"
|
|
327
|
+
)
|
|
328
|
+
if code == 401:
|
|
329
|
+
return DoctorCheck(
|
|
330
|
+
label=label,
|
|
331
|
+
status=STATUS_WARN,
|
|
332
|
+
detail="401 (tailnet identity not present; ok for non-tailnet callers)",
|
|
333
|
+
)
|
|
334
|
+
if code == 0:
|
|
335
|
+
return DoctorCheck(label=label, status=STATUS_WARN, detail="skipped (API unreachable)")
|
|
336
|
+
return DoctorCheck(label=label, status=STATUS_WARN, detail=f"{code}: {body[:60]}")
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def check_cognitive_state_cache(cache_path: Path) -> DoctorCheck:
|
|
340
|
+
"""~/.zeno/cognitive-state.json is the shared-memory cache from research 2.
|
|
341
|
+
|
|
342
|
+
Phase 0 contract: the API rewrites this file on every authed
|
|
343
|
+
/v1/cognitive-state read for the local operator. Doctor flags WARN
|
|
344
|
+
when the file is missing (Phase 0 not wired yet for this user) or
|
|
345
|
+
when the mtime is older than COGNITIVE_STATE_STALE_MINUTES (no recent
|
|
346
|
+
GET, so the gate is operating on stale info).
|
|
347
|
+
"""
|
|
348
|
+
label = "Cognitive-state cache"
|
|
349
|
+
if not cache_path.exists():
|
|
350
|
+
return DoctorCheck(
|
|
351
|
+
label=label,
|
|
352
|
+
status=STATUS_WARN,
|
|
353
|
+
detail=f"{cache_path} not present (call GET /v1/cognitive-state to populate)",
|
|
354
|
+
)
|
|
355
|
+
try:
|
|
356
|
+
mtime = datetime.fromtimestamp(cache_path.stat().st_mtime)
|
|
357
|
+
except OSError as exc:
|
|
358
|
+
return DoctorCheck(label=label, status=STATUS_WARN, detail=f"stat failed: {exc}")
|
|
359
|
+
age = datetime.now() - mtime
|
|
360
|
+
minutes = int(age.total_seconds() // 60)
|
|
361
|
+
if age > timedelta(minutes=COGNITIVE_STATE_STALE_MINUTES):
|
|
362
|
+
return DoctorCheck(
|
|
363
|
+
label=label,
|
|
364
|
+
status=STATUS_WARN,
|
|
365
|
+
detail=f"stale ({minutes}m old)",
|
|
366
|
+
)
|
|
367
|
+
return DoctorCheck(
|
|
368
|
+
label=label,
|
|
369
|
+
status=STATUS_PASS,
|
|
370
|
+
detail=f"fresh ({minutes}m old)",
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def check_bunmail() -> DoctorCheck | None:
|
|
375
|
+
"""Only runs when ZENO_BUNMAIL_BASE_URL is set. Returns None when env is
|
|
376
|
+
absent so doctor doesn't print a misleading "bunmail FAIL" row to
|
|
377
|
+
operators who don't use the outreach surface.
|
|
378
|
+
|
|
379
|
+
Bunmail's health endpoint is /health (no /v1 prefix - see bunmail repo).
|
|
380
|
+
Treating 200 as PASS and anything else as WARN; a flaky bunmail does
|
|
381
|
+
NOT break the rest of zeno.
|
|
382
|
+
"""
|
|
383
|
+
base = os.environ.get("ZENO_BUNMAIL_BASE_URL", "").strip()
|
|
384
|
+
if not base:
|
|
385
|
+
return None
|
|
386
|
+
label = "Bunmail reachability"
|
|
387
|
+
url = f"{base.rstrip('/')}/health"
|
|
388
|
+
code, body = _http_get(url, timeout=NET_TIMEOUT)
|
|
389
|
+
if code == 200:
|
|
390
|
+
return DoctorCheck(label=label, status=STATUS_PASS, detail=f"200 {url}")
|
|
391
|
+
if code == 0:
|
|
392
|
+
return DoctorCheck(label=label, status=STATUS_WARN, detail=f"unreachable: {body}")
|
|
393
|
+
return DoctorCheck(label=label, status=STATUS_WARN, detail=f"{code} {url}")
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _find_workspace_root() -> Path | None:
|
|
397
|
+
"""Nearest ancestor of this file that is a zeno workspace checkout.
|
|
398
|
+
|
|
399
|
+
Identified by a `.git` dir + the `tools/codegen` tree. Returns None for a
|
|
400
|
+
wheel install in site-packages (no repo nearby) so the dev row is omitted
|
|
401
|
+
for end users, mirroring how check_bunmail is conditional.
|
|
402
|
+
"""
|
|
403
|
+
here = Path(__file__).resolve()
|
|
404
|
+
for parent in [here.parent, *here.parents][:10]:
|
|
405
|
+
if (parent / ".git").exists() and (parent / "tools" / "codegen").is_dir():
|
|
406
|
+
return parent
|
|
407
|
+
return None
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def check_codegen_drift() -> DoctorCheck | None:
|
|
411
|
+
"""Dev-only: are the codegen tools present and the generated SDK committed?
|
|
412
|
+
|
|
413
|
+
Returns None outside a workspace checkout. In a workspace:
|
|
414
|
+
- FAIL if uv or pnpm is missing (you cannot run `pnpm codegen`).
|
|
415
|
+
- FAIL if the generated SDK tree (sdk-python _generated + types) has
|
|
416
|
+
uncommitted changes - a regenerate that was not committed, or a
|
|
417
|
+
hand-edit of a generated file - with a remediation hint.
|
|
418
|
+
- PASS otherwise (tools present, generated tree clean). This is a fast,
|
|
419
|
+
read-only proxy; it does NOT run codegen (too heavy for a doctor run),
|
|
420
|
+
so the detail points at `pnpm codegen` for the authoritative check.
|
|
421
|
+
"""
|
|
422
|
+
root = _find_workspace_root()
|
|
423
|
+
if root is None:
|
|
424
|
+
return None
|
|
425
|
+
label = "Codegen (dev)"
|
|
426
|
+
|
|
427
|
+
missing = [t for t in ("uv", "pnpm") if shutil.which(t) is None]
|
|
428
|
+
if missing:
|
|
429
|
+
# WARN not FAIL: a missing BUILD tool is not a broken runtime/local dependency,
|
|
430
|
+
# so it must not turn an otherwise-working checkout red (doctor_exit_code contract).
|
|
431
|
+
return DoctorCheck(
|
|
432
|
+
label=label,
|
|
433
|
+
status=STATUS_WARN,
|
|
434
|
+
detail=f"{', '.join(missing)} not on PATH - skipping the codegen drift check",
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
gen_paths = [
|
|
438
|
+
"packages/sdk-python/src/zeno_sdk/_generated",
|
|
439
|
+
"packages/sdk-python/src/zeno_sdk/types",
|
|
440
|
+
]
|
|
441
|
+
try:
|
|
442
|
+
out = subprocess.run(
|
|
443
|
+
["git", "status", "--porcelain", "--", *gen_paths],
|
|
444
|
+
capture_output=True,
|
|
445
|
+
text=True,
|
|
446
|
+
timeout=3,
|
|
447
|
+
cwd=str(root),
|
|
448
|
+
)
|
|
449
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
450
|
+
return DoctorCheck(label=label, status=STATUS_WARN, detail=f"git status failed: {exc}")
|
|
451
|
+
|
|
452
|
+
dirty = [ln for ln in out.stdout.splitlines() if ln.strip()]
|
|
453
|
+
if dirty:
|
|
454
|
+
return DoctorCheck(
|
|
455
|
+
label=label,
|
|
456
|
+
status=STATUS_FAIL,
|
|
457
|
+
detail=(
|
|
458
|
+
f"{len(dirty)} generated SDK file(s) uncommitted - run 'pnpm codegen' "
|
|
459
|
+
"and commit, or 'git restore' them"
|
|
460
|
+
),
|
|
461
|
+
)
|
|
462
|
+
return DoctorCheck(
|
|
463
|
+
label=label,
|
|
464
|
+
status=STATUS_PASS,
|
|
465
|
+
detail="uv+pnpm present, generated SDK committed (run 'pnpm codegen' to re-verify)",
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def check_session_intel() -> DoctorCheck:
|
|
470
|
+
"""Is the session-intel (ingest / usage) analytics surface importable?
|
|
471
|
+
|
|
472
|
+
``zeno ingest`` / ``zeno usage`` fold the standalone ``zeno_session_intel`` sub-CLIs
|
|
473
|
+
(ingest + analytics) under the main CLI. That package is a workspace package, NOT
|
|
474
|
+
bundled into the zeno-cli wheel, so a bare wheel install lacks it and those two
|
|
475
|
+
commands print a hint instead of running. WARN, not FAIL, when absent: it is an
|
|
476
|
+
additive analytics surface, not a hard runtime dependency (doctor_exit_code
|
|
477
|
+
contract), so its absence must never turn an otherwise-healthy install red. This row
|
|
478
|
+
fingerprints whether the surface is wired so the operator isn't surprised by a hint.
|
|
479
|
+
"""
|
|
480
|
+
import importlib # noqa: PLC0415
|
|
481
|
+
|
|
482
|
+
label = "Session-intel (ingest/usage)"
|
|
483
|
+
try:
|
|
484
|
+
importlib.import_module("zeno_session_intel.ingest")
|
|
485
|
+
importlib.import_module("zeno_session_intel.analytics")
|
|
486
|
+
except ImportError:
|
|
487
|
+
return DoctorCheck(
|
|
488
|
+
label=label,
|
|
489
|
+
status=STATUS_WARN,
|
|
490
|
+
detail="not importable - 'zeno ingest'/'zeno usage' need the "
|
|
491
|
+
"zeno-session-intel package (run from the workspace or install it)",
|
|
492
|
+
)
|
|
493
|
+
return DoctorCheck(
|
|
494
|
+
label=label,
|
|
495
|
+
status=STATUS_PASS,
|
|
496
|
+
detail="importable ('zeno ingest' / 'zeno usage' available)",
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def run_doctor(
|
|
501
|
+
*, base_url: str, db_path: Path, cache_path: Path, settings_path: Path | None = None
|
|
502
|
+
) -> list[DoctorCheck]:
|
|
503
|
+
"""Execute every doctor check and return the list of DoctorCheck rows.
|
|
504
|
+
|
|
505
|
+
Pure-ish: each row is a function call; the rendering layer in main.py
|
|
506
|
+
handles colors and the exit code computation. Keeping this list-of-rows
|
|
507
|
+
shape makes the doctor easy to unit-test (no terminal mocking) and
|
|
508
|
+
easy to extend (append a new check).
|
|
509
|
+
"""
|
|
510
|
+
checks: list[DoctorCheck] = [
|
|
511
|
+
check_python_version(),
|
|
512
|
+
check_zeno_version(),
|
|
513
|
+
check_local_db(db_path),
|
|
514
|
+
check_last_survey(db_path),
|
|
515
|
+
check_capture_hook(settings_path),
|
|
516
|
+
check_session_intel(),
|
|
517
|
+
check_api_health(base_url),
|
|
518
|
+
check_tailnet_identity(base_url),
|
|
519
|
+
check_cognitive_state_cache(cache_path),
|
|
520
|
+
]
|
|
521
|
+
bunmail = check_bunmail()
|
|
522
|
+
if bunmail is not None:
|
|
523
|
+
checks.append(bunmail)
|
|
524
|
+
codegen = check_codegen_drift()
|
|
525
|
+
if codegen is not None:
|
|
526
|
+
checks.append(codegen)
|
|
527
|
+
return checks
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def doctor_exit_code(checks: list[DoctorCheck]) -> int:
|
|
531
|
+
"""0 if every row is PASS or WARN, 1 if any row is FAIL. WARN does not
|
|
532
|
+
fail the run - that's the explicit contract documented at the top of
|
|
533
|
+
this file. A FAIL row means a hard dependency is broken.
|
|
534
|
+
"""
|
|
535
|
+
return 1 if any(c.status == STATUS_FAIL for c in checks) else 0
|