返回 last30days-skill
health.py
根目录 / skills / last30days / scripts / lib / health.py
1 """Typed source health: classify a source/tool outcome honestly.
2
3 The pipeline historically collapsed every failure into "returned nothing" or a
4 flat ``errors_by_source`` entry, which hides the difference between a tool that
5 is *absent*, one that is *present but broken* (the classic stale-venv-shim after
6 a Python upgrade), one that *timed out*, and one that merely *degraded* (fewer
7 results than expected). This module gives callers a small typed vocabulary so
8 warnings can say what actually happened and prescribe the right fix.
9
10 It complements ``preflight.py`` (which gates doomed *queries*); this gates
11 doomed *sources/tools*.
12 """
13
14 from __future__ import annotations
15
16 import os
17 import shutil
18 import subprocess
19 from dataclasses import dataclass
20 from pathlib import Path
21 from typing import Dict, Iterable, List, Optional, Tuple
22
23 # Health states, best to worst.
24 OK = "ok"
25 DEGRADED = "degraded" # ran, but returned less than expected
26 MISSING = "missing" # tool/binary/credential absent
27 BROKEN = "broken" # present but won't execute (stale shim, bad perms)
28 TIMEOUT = "timeout" # exceeded the probe deadline
29 ERROR = "error" # ran and failed for another reason
30
31 # Per-run outcomes. Doctor does not emit these: it predicts source readiness
32 # before retrieval, while Report.source_status records what happened in one run.
33 NO_RESULTS = "no-results"
34 PARTIAL = "partial"
35 RATE_LIMITED = "rate-limited"
36 AUTH_FAILED = "auth-failed"
37 UNREACHABLE = "unreachable"
38 SCHEMA_DRIFT = "schema-drift"
39 SKIPPED_UNCONFIGURED = "skipped-unconfigured"
40
41
42 @dataclass
43 class SourceHealth:
44 """Typed outcome for a source or the tool backing it.
45
46 ``state`` is one of the module-level constants. ``reason`` is a short,
47 human-readable explanation suitable for a run warning.
48 """
49
50 name: str
51 state: str
52 reason: str = ""
53
54 @property
55 def ok(self) -> bool:
56 return self.state == OK
57
58 @property
59 def usable(self) -> bool:
60 """True when the source produced something worth keeping (ok/degraded)."""
61 return self.state in (OK, DEGRADED)
62
63
64 def probe_command(
65 command: list[str],
66 timeout: float = 5.0,
67 ) -> SourceHealth:
68 """Probe an external command, distinguishing missing/broken/timeout/ok.
69
70 Separating these is what lets the caller emit a correct repair prescription
71 instead of a generic "failed":
72 - ``missing``: the executable is not on PATH.
73 - ``broken``: on PATH but won't run — FileNotFoundError/OSError on exec, or
74 shell exit 126/127 (not-executable / not-found-after-resolution), the
75 signature of a stale interpreter shim after an upgrade.
76 - ``timeout``: exceeded ``timeout`` seconds.
77 - ``ok``: exited 0.
78 - ``error``: ran but exited non-zero for another reason.
79
80 The command should be side-effect-free (e.g. ``["gh", "auth", "status"]``);
81 callers pass a status/version subcommand, not a mutating one.
82 """
83 name = command[0] if command else ""
84 if not name or shutil.which(name) is None:
85 return SourceHealth(name=name, state=MISSING, reason=f"{name or 'command'} not found on PATH")
86
87 try:
88 proc = subprocess.run(
89 command,
90 capture_output=True,
91 text=True,
92 timeout=timeout,
93 )
94 except (FileNotFoundError, OSError) as exc:
95 return SourceHealth(name=name, state=BROKEN, reason=f"{name} present but won't execute: {exc}")
96 except subprocess.TimeoutExpired:
97 return SourceHealth(name=name, state=TIMEOUT, reason=f"{name} timed out after {timeout:g}s")
98
99 if proc.returncode == 0:
100 return SourceHealth(name=name, state=OK)
101 if proc.returncode in (126, 127):
102 return SourceHealth(name=name, state=BROKEN, reason=f"{name} not executable (exit {proc.returncode})")
103 detail = (proc.stderr or proc.stdout or "").strip().splitlines()
104 first = detail[0] if detail else f"exit {proc.returncode}"
105 return SourceHealth(name=name, state=ERROR, reason=f"{name}: {first}")
106
107
108 # ---------------------------------------------------------------------------
109 # Dependency probes (doctor command, issue #692).
110 #
111 # ``probe_dependency`` generalizes ``probe_command`` for the skill's external
112 # binaries (yt-dlp, Printing Press CLIs, node for the vendored bird client,
113 # ffmpeg). It answers three questions the bare shutil.which gate cannot:
114 # - Is the binary genuinely runnable (a stale shim that resolves on PATH but
115 # cannot exec is BROKEN, not available)?
116 # - If not, WHICH fix applies (install vs reinstall vs a PATH edit), keyed to
117 # the package manager that owns the binary on this machine?
118 # - Is an on-disk binary merely off the agent-subprocess PATH (the Digg
119 # ~/.local/bin case) — MISSING with a PATH-fix, never "installed"?
120 #
121 # Semantics follow the engine gate: availability means PATH-resolvable in THIS
122 # process, not present-on-disk. Probes are one short-timeout version exec each
123 # and memoized per process, so doctor and setup can consult them freely.
124 # ---------------------------------------------------------------------------
125
126 # Per-probe budget in seconds: a healthy --version exec is near-instant, so a
127 # slow probe is itself a diagnostic (network-mounted shim, hung interpreter).
128 PROBE_TIMEOUT = 5.0
129
130 _PP_CLI_SUFFIX = "-pp-cli"
131 # Matches setup_wizard.PRINTING_PRESS_NPM (pinned catalog installer).
132 _PRINTING_PRESS_NPM = "@mvanhorn/printing-press-library@0.1.16"
133
134 # Dependencies the doctor probes by default.
135 KNOWN_DEPENDENCIES: Tuple[str, ...] = ("yt-dlp", "digg-pp-cli", "node", "ffmpeg")
136
137 # Cheap side-effect-free version invocation per dependency (default --version).
138 _VERSION_ARGS: Dict[str, List[str]] = {
139 "ffmpeg": ["-version"],
140 }
141
142 # Package managers each dependency may be owned by, in preference order, and
143 # the (install, reinstall) prescription for each. "reinstall" wording matters:
144 # a BROKEN binary is present, so telling the user to "install" it reads as a
145 # no-op ("it's already installed") — the stale-shim trap this module exists
146 # to name.
147 _MANAGER_PRESCRIPTIONS: Dict[str, Dict[str, Tuple[str, str]]] = {
148 "yt-dlp": {
149 "brew": ("brew install yt-dlp", "brew reinstall yt-dlp"),
150 "pipx": ("pipx install yt-dlp", "pipx reinstall yt-dlp"),
151 },
152 "node": {
153 "brew": ("brew install node", "brew reinstall node"),
154 "nvm": ("nvm install --lts", "reinstall node via nvm: nvm install --lts && nvm use --lts"),
155 },
156 "ffmpeg": {
157 "brew": ("brew install ffmpeg", "brew reinstall ffmpeg"),
158 "apt": ("sudo apt-get install -y ffmpeg", "sudo apt-get install -y --reinstall ffmpeg"),
159 },
160 }
161
162 # Last-resort prescriptions when no known package manager is detected.
163 _FALLBACK_PRESCRIPTIONS: Dict[str, Tuple[str, str]] = {
164 "yt-dlp": (
165 "install yt-dlp (https://github.com/yt-dlp/yt-dlp#installation) and ensure it is on PATH",
166 "reinstall yt-dlp (https://github.com/yt-dlp/yt-dlp#installation); the current binary won't run",
167 ),
168 "node": (
169 "install Node.js 22+ (https://nodejs.org) and ensure `node` is on PATH",
170 "reinstall Node.js 22+ (https://nodejs.org); the current binary won't run",
171 ),
172 "ffmpeg": (
173 "install ffmpeg (https://ffmpeg.org/download.html) and ensure it is on PATH",
174 "reinstall ffmpeg (https://ffmpeg.org/download.html); the current binary won't run",
175 ),
176 }
177
178
179 @dataclass
180 class DependencyProbe:
181 """Uniform probe result for one external dependency.
182
183 ``status`` is one of the module-level constants (OK/MISSING/BROKEN/TIMEOUT).
184 ``detail`` says what was observed (version string, exec error, off-PATH
185 location). ``prescription`` is the copy-pasteable fix, empty when OK.
186 ``owner_pkg_manager`` names the manager the prescription targets
187 ("brew", "pipx", "apt", "nvm", "npx"), or "" for PATH fixes / fallbacks.
188 """
189
190 name: str
191 status: str
192 detail: str = ""
193 prescription: str = ""
194 owner_pkg_manager: str = ""
195 # True for the on-disk-but-off-PATH case: MISSING (the engine gate would
196 # not pass) but the fix is a PATH edit, not an install.
197 off_path: bool = False
198
199 @property
200 def ok(self) -> bool:
201 return self.status == OK
202
203
204 # Safe under the GIL (dict get/set are atomic) and each dependency name is
205 # probed from a single builder today; worst case is one redundant probe.
206 _dependency_probe_cache: Dict[str, DependencyProbe] = {}
207
208
209 def clear_dependency_probe_cache() -> None:
210 """Reset memoized probes (tests, or a doctor re-run after a fix)."""
211 _dependency_probe_cache.clear()
212
213
214 def _nvm_present() -> bool:
215 return bool(os.environ.get("NVM_DIR")) or (Path.home() / ".nvm").is_dir()
216
217
218 def _manager_available(manager: str) -> bool:
219 if manager == "nvm":
220 return _nvm_present()
221 if manager == "apt":
222 return shutil.which("apt-get") is not None
223 return shutil.which(manager) is not None
224
225
226 def _is_pp_cli(name: str) -> bool:
227 return name.endswith(_PP_CLI_SUFFIX) and len(name) > len(_PP_CLI_SUFFIX)
228
229
230 def _pp_install_cmd(name: str) -> str:
231 slug = name[: -len(_PP_CLI_SUFFIX)]
232 return f"npx -y {_PRINTING_PRESS_NPM} install {slug} --cli-only"
233
234
235 def pp_install_cmd(slug: str) -> str:
236 """Public catalog-install command for the Printing Press CLI ``<slug>-pp-cli``."""
237 return _pp_install_cmd(f"{slug}{_PP_CLI_SUFFIX}")
238
239
240 def static_prescription(name: str, manager: str) -> Tuple[str, str]:
241 """Public ``(install, reinstall)`` strings for one dependency/manager pair.
242
243 Reads the static table without probing manager availability; raises
244 KeyError for unknown pairs so consumers fail loudly at import time.
245 """
246 return _MANAGER_PRESCRIPTIONS[name][manager]
247
248
249 def _prescription(name: str, kind: str) -> Tuple[str, str]:
250 """Return ``(prescription, owner_pkg_manager)`` for install/reinstall.
251
252 ``kind`` is "install" (MISSING) or "reinstall" (BROKEN). Printing Press
253 CLIs always re-run the catalog installer; other deps pick the first
254 detected manager from their preference table, falling back to a generic
255 but still actionable instruction.
256 """
257 idx = 0 if kind == "install" else 1
258 if _is_pp_cli(name):
259 cmd = _pp_install_cmd(name)
260 if kind == "reinstall":
261 return f"re-run the Printing Press install: {cmd}", "npx"
262 return cmd, "npx"
263 for manager, prescriptions in _MANAGER_PRESCRIPTIONS.get(name, {}).items():
264 if _manager_available(manager):
265 return prescriptions[idx], manager
266 fallback = _FALLBACK_PRESCRIPTIONS.get(name)
267 if fallback:
268 return fallback[idx], ""
269 verb = "install" if kind == "install" else "reinstall"
270 return f"{verb} {name} and ensure it is on PATH", ""
271
272
273 def windows_printing_press_bin_dir() -> Optional[Path]:
274 """Windows managed install dir for Printing Press CLIs, when applicable.
275
276 Returns ``%LOCALAPPDATA%/Programs/PrintingPress/bin`` on Windows when
277 LOCALAPPDATA is set; ``None`` otherwise.
278 """
279 if os.name != "nt":
280 return None
281 local_app = os.environ.get("LOCALAPPDATA") or os.environ.get("LocalAppData")
282 if not local_app:
283 return None
284 return Path(local_app) / "Programs" / "PrintingPress" / "bin"
285
286
287 def installer_bin_dirs() -> List[Path]:
288 """Installer-managed bin dirs shared with setup_wizard's Digg candidates.
289
290 Single source of truth for where installers drop binaries: the Printing
291 Press library default (~/.local/bin), Go bins, and — on Windows — the
292 managed %LOCALAPPDATA%/Programs/PrintingPress/bin dir.
293 ``setup_wizard._digg_bin_candidate_paths`` derives its Digg-specific
294 paths from this list; keep the two in lockstep by editing only here.
295 """
296 home = Path.home()
297 dirs = [home / ".local" / "bin"]
298 gopath = os.environ.get("GOPATH")
299 if gopath:
300 dirs.append(Path(gopath) / "bin")
301 dirs.append(home / "go" / "bin")
302 win_dir = windows_printing_press_bin_dir()
303 if win_dir is not None:
304 dirs.append(win_dir)
305 return dirs
306
307
308 def _off_path_candidate_dirs() -> List[Path]:
309 """Directories where installers drop binaries that PATH may not cover.
310
311 The shared installer dirs (``installer_bin_dirs``, which also backs
312 setup_wizard's Digg candidates) plus the Homebrew prefixes (an agent
313 subprocess PATH sometimes omits even those).
314 """
315 dirs = installer_bin_dirs()
316 dirs.extend([Path("/opt/homebrew/bin"), Path("/usr/local/bin")])
317 return dirs
318
319
320 def _off_path_binary(name: str) -> Optional[Path]:
321 """Return an executable for ``name`` in a known dir that PATH misses."""
322 names = [name, f"{name}.exe"] if os.name == "nt" else [name]
323 for directory in _off_path_candidate_dirs():
324 for candidate_name in names:
325 candidate = directory / candidate_name
326 if candidate.is_file() and os.access(candidate, os.X_OK):
327 return candidate
328 return None
329
330
331 def _path_hint(directory: Path) -> str:
332 """Render a bin dir with $HOME substituted for copy-pasteable PATH edits."""
333 raw = str(directory)
334 if os.name == "nt":
335 return raw
336 home = str(Path.home())
337 if raw == home:
338 return "$HOME"
339 if raw.startswith(home + os.sep):
340 return "$HOME/" + raw[len(home) + 1:].replace(os.sep, "/")
341 return raw
342
343
344 def probe_dependency(name: str, timeout: float = PROBE_TIMEOUT) -> DependencyProbe:
345 """Probe one external dependency: OK | MISSING | BROKEN | TIMEOUT.
346
347 - MISSING: not resolvable on this process's PATH. If the binary exists in
348 a known install dir, the prescription is a PATH edit, not an install —
349 installing again would not fix anything.
350 - BROKEN: shutil.which resolves it but a cheap version exec fails
351 (OSError/exec-format, or any non-zero exit). Prescription says
352 *reinstall* — the #692 stale-shim class must never read as available.
353 - TIMEOUT: the version exec exceeded the per-probe budget.
354 - OK: version exec exited 0; ``detail`` carries the version line.
355
356 Memoized per process; ``clear_dependency_probe_cache()`` resets.
357 """
358 cached = _dependency_probe_cache.get(name)
359 if cached is not None:
360 return cached
361 probe = _probe_dependency_uncached(name, timeout)
362 _dependency_probe_cache[name] = probe
363 return probe
364
365
366 def _probe_dependency_uncached(name: str, timeout: float) -> DependencyProbe:
367 resolved = shutil.which(name)
368 if resolved is None:
369 off_path = _off_path_binary(name)
370 if off_path is not None:
371 hint = _path_hint(off_path.parent)
372 return DependencyProbe(
373 name=name,
374 status=MISSING,
375 detail=f"{name} is installed at {off_path} but that directory is not on this process's PATH",
376 prescription=f'add {hint} to PATH (e.g. export PATH="{hint}:$PATH") so {name} resolves',
377 owner_pkg_manager="",
378 off_path=True,
379 )
380 prescription, manager = _prescription(name, "install")
381 return DependencyProbe(
382 name=name,
383 status=MISSING,
384 detail=f"{name} not found on PATH",
385 prescription=prescription,
386 owner_pkg_manager=manager,
387 )
388
389 command = [name] + _VERSION_ARGS.get(name, ["--version"])
390 try:
391 proc = subprocess.run(
392 command,
393 capture_output=True,
394 text=True,
395 timeout=timeout,
396 )
397 except (FileNotFoundError, OSError) as exc:
398 prescription, manager = _prescription(name, "reinstall")
399 return DependencyProbe(
400 name=name,
401 status=BROKEN,
402 detail=f"{name} resolves to {resolved} but won't execute: {exc}",
403 prescription=prescription,
404 owner_pkg_manager=manager,
405 )
406 except subprocess.TimeoutExpired:
407 prescription, manager = _prescription(name, "reinstall")
408 return DependencyProbe(
409 name=name,
410 status=TIMEOUT,
411 detail=f"{name} version probe timed out after {timeout:g}s",
412 prescription=f"re-run doctor; if the timeout persists: {prescription}",
413 owner_pkg_manager=manager,
414 )
415
416 if proc.returncode == 0:
417 lines = (proc.stdout or proc.stderr or "").strip().splitlines()
418 version = lines[0].strip() if lines else ""
419 return DependencyProbe(name=name, status=OK, detail=version)
420
421 lines = (proc.stderr or proc.stdout or "").strip().splitlines()
422 why = lines[0].strip() if lines else f"exit {proc.returncode}"
423 prescription, manager = _prescription(name, "reinstall")
424 return DependencyProbe(
425 name=name,
426 status=BROKEN,
427 detail=f"{name} resolves to {resolved} but the version probe failed: {why}",
428 prescription=prescription,
429 owner_pkg_manager=manager,
430 )
431
432
433 def probe_dependencies(names: Optional[Iterable[str]] = None) -> Dict[str, DependencyProbe]:
434 """Probe every known dependency (or ``names``), memoized per process."""
435 return {name: probe_dependency(name) for name in (names or KNOWN_DEPENDENCIES)}
436
436 lines PYTHON