返回 last30days-skill
doctor.py
根目录 / skills / last30days / scripts / lib / doctor.py
1 """Unified `doctor` health surface: aggregate, tier rollup, render (U4).
2
3 One command answers "what's broken, what's serving, and what do I run to
4 fix it" by composing the existing health layers instead of replacing them:
5
6 - U1 ``lib/health.py`` dependency probes (ok/missing/broken/timeout)
7 - U2 ``lib/backends.py`` chain descriptors + "will use" prediction
8 - U3 ``lib/prescriptions.py`` the single remediation vocabulary
9 - ``lib/pipeline.diagnose`` + ``lib/permission_preflight`` for the
10 engine-level availability and permission summary
11
12 The legacy ``--diagnose`` / ``--preflight`` flags keep their frozen JSON
13 shapes (see ``tests/test_diagnose_compat.py``); anything new appears ONLY
14 in ``doctor --json``.
15
16 Tier rollup (the R1 machine contract). Per source, ``tier`` is the
17 four-value rollup and ``status`` preserves the most specific state:
18
19 | condition | status | tier |
20 |------------------------------------------------------|---------------|-------|
21 | probes pass, credentials (if any) present | ok | ok |
22 | usable but degraded (fallback serving, partial) | degraded | warn |
23 | opt-in not enabled / key-gated unconfigured | opt-in / | off |
24 | | unconfigured | |
25 | configured but missing / broken / timeout / error | that status | error |
26
27 Semantics and guarantees:
28
29 - ``active_backend`` is a PREDICTION ("will use"), never an observation
30 (KTD 4). Reddit is conditional mode: honest wording, no single winner.
31 - On a native-search host with no web keys, engine-side web search is
32 intentionally off — doctor reports tier ``off`` with a host-native note,
33 never a false-alarm error. Web search has NO env pin, only the
34 ``--web-backend`` flag; the record says so.
35 - No cookie reads (plan-only, like ``--diagnose``); no secret values
36 anywhere — key presence is booleans only.
37 - Per-source exception isolation: one failing probe becomes that source's
38 ``error`` record; it can never blank the report.
39 - Reporting problems is a successful run: the exit code is always 0.
40 """
41
42 from __future__ import annotations
43
44 import concurrent.futures
45 import datetime
46 import hashlib
47 import json
48 import os
49 import shutil
50 import sys
51 import time
52 import urllib.error
53 import urllib.request
54 from pathlib import Path
55 from typing import Any, Callable, Dict, List, Optional
56
57 from . import backends, brightdata, env, health, http, prescriptions, reddit_search, x_api
58 from .backends import TIER_ERROR, TIER_OK, TIER_WARN
59
60 # Rollup tiers (R1). ok/warn/error are U2's; only "off" is doctor's own.
61 TIER_OFF = "off"
62
63 # Specific statuses and the rollup row each maps to. The doctor-local
64 # statuses "opt-in" and "unconfigured" have no health constant.
65 TIER_BY_STATUS: Dict[str, str] = {
66 health.OK: TIER_OK,
67 health.DEGRADED: TIER_WARN,
68 "opt-in": TIER_OFF,
69 "unconfigured": TIER_OFF,
70 health.MISSING: TIER_ERROR,
71 health.BROKEN: TIER_ERROR,
72 health.TIMEOUT: TIER_ERROR,
73 health.ERROR: TIER_ERROR,
74 }
75
76 # Tier -> glyph, still used by the cached-report shape validator to confirm a
77 # record carries a known tier. The user-facing render uses AUDIT_GLYPHS below.
78 GLYPHS = {TIER_OK: "✓", TIER_WARN: "!", TIER_OFF: "○", TIER_ERROR: "✗"}
79
80 # Four-state audit (R1). A presentation layer derived from the tier rollup +
81 # last-run evidence + optional live probe - it augments the per-source records,
82 # it does not replace them (the tier/status fields stay in the record and JSON).
83 AUDIT_WORKING = "working"
84 AUDIT_UNVERIFIED = "unverified"
85 AUDIT_NOT_WORKING = "not-working"
86 AUDIT_COULD_BE_ON = "could-be-on"
87
88 AUDIT_GLYPHS = {
89 AUDIT_WORKING: "●", # ●
90 AUDIT_UNVERIFIED: "◐", # ◐
91 AUDIT_NOT_WORKING: "✕", # ✕
92 AUDIT_COULD_BE_ON: "○", # ○
93 }
94
95 AUDIT_GROUPS = (
96 (AUDIT_WORKING, "WORKING"),
97 (AUDIT_UNVERIFIED, "TURNED ON - UNVERIFIED"),
98 (AUDIT_NOT_WORKING, "NOT WORKING"),
99 (AUDIT_COULD_BE_ON, "COULD BE ON"),
100 )
101
102 # Sources that need neither credentials nor a CLI: they always serve, so with
103 # no run evidence and no probe they are WORKING, not UNVERIFIED.
104 KEYLESS_ALWAYS_ON = frozenset(
105 {"reddit", "hackernews", "polymarket", "github", "library"}
106 )
107
108 # Fresh-run outcome states -> audit bucket for a tier-ok source. Anything not
109 # listed here (error / timeout / rate-limited / auth-failed / unreachable /
110 # schema-drift) means the source ran and failed -> NOT WORKING.
111 _RUN_WORKING_STATES = frozenset({health.OK, health.NO_RESULTS})
112 _RUN_UNVERIFIED_STATES = frozenset({health.PARTIAL, health.SKIPPED_UNCONFIGURED})
113
114
115 def audit_state(
116 name: str,
117 record: Dict[str, Any],
118 run_outcome: Optional[Dict[str, Any]] = None,
119 probe_result: Optional[Dict[str, Any]] = None,
120 ) -> str:
121 """Map a source's (tier, run evidence, probe) to one of four audit states.
122
123 Precedence: a failing/degraded tier is NOT WORKING regardless of history;
124 an off tier (opt-in / unconfigured) is COULD BE ON. For a tier-ok source,
125 fresh run evidence wins (ok/no-results -> WORKING; partial/skipped ->
126 UNVERIFIED; any error/timeout/rate-limit/etc -> NOT WORKING), then a live
127 probe, then the keyless-always-on fallback, else UNVERIFIED.
128 """
129 tier = record.get("tier")
130 if tier in (TIER_ERROR, TIER_WARN):
131 return AUDIT_NOT_WORKING
132 if tier == TIER_OFF:
133 return AUDIT_COULD_BE_ON
134 # tier ok
135 if run_outcome:
136 state = run_outcome.get("state")
137 if state in _RUN_WORKING_STATES:
138 return AUDIT_WORKING
139 if state in _RUN_UNVERIFIED_STATES:
140 return AUDIT_UNVERIFIED
141 return AUDIT_NOT_WORKING
142 if probe_result is not None:
143 if probe_result.get("ok"):
144 return AUDIT_WORKING
145 # A transient refusal (host rate-limited THIS probe) is not evidence the
146 # source is broken: the lane retries with backoff and serves fine. Report
147 # it as unverified instead of claiming a working source is down.
148 if probe_result.get("transient"):
149 return AUDIT_UNVERIFIED
150 return AUDIT_NOT_WORKING
151 if name in KEYLESS_ALWAYS_ON:
152 return AUDIT_WORKING
153 return AUDIT_UNVERIFIED
154
155 # Report order: chained sources first, then free, then key-gated/opt-in.
156 SOURCE_ORDER = (
157 "reddit",
158 "x",
159 "youtube",
160 "web",
161 "hackernews",
162 "polymarket",
163 "github",
164 "digg",
165 "techmeme",
166 "arxiv",
167 "trustpilot",
168 "amazon",
169 "meta_ads",
170 "tiktok",
171 "instagram",
172 "threads",
173 "telegram",
174 "bluesky",
175 "truthsocial",
176 "perplexity",
177 "linkedin",
178 "pinterest",
179 "xiaohongshu",
180 "jobs",
181 "library",
182 )
183
184 # Sources whose availability depends on a downloaded CLI binary. doctor probes
185 # each (installed AND functional, via health.probe_dependency) and surfaces a
186 # per-source marker plus a dedicated CLI-health block (R2). Everything not
187 # listed is keyless - it needs no CLI. gh is OPTIONAL for GitHub (the REST tier
188 # works without it), so its absence is a note, never a failure.
189 CLI_DEPENDENCIES = {
190 "youtube": "yt-dlp",
191 "digg": "digg-pp-cli",
192 "techmeme": "techmeme-pp-cli",
193 "arxiv": "arxiv-pp-cli",
194 "trustpilot": "trustpilot-pp-cli",
195 # The only entry that also needs auth; _amazon_record reports the
196 # installed-but-unauthenticated state the shared CLI helper cannot.
197 "amazon": "brightdata",
198 "github": "gh",
199 }
200 _OPTIONAL_CLI_SOURCES = frozenset({"github"})
201
202 # Key-presence booleans for the setup block. NEVER values.
203 KEY_PRESENCE_VARS = (
204 "SCRAPECREATORS_API_KEY",
205 "XAI_API_KEY",
206 "XQUIK_API_KEY",
207 "X_BEARER_TOKEN",
208 "BRAVE_API_KEY",
209 "EXA_API_KEY",
210 "SERPER_API_KEY",
211 "PARALLEL_API_KEY",
212 "GROQ_API_KEY",
213 "OPENAI_API_KEY",
214 "GOOGLE_API_KEY",
215 "GEMINI_API_KEY",
216 "OPENROUTER_API_KEY",
217 "PERPLEXITY_API_KEY",
218 "GITHUB_TOKEN",
219 "TRUTHSOCIAL_TOKEN",
220 "BSKY_APP_PASSWORD",
221 )
222
223 # Failing statuses ranked most-specific-first for chained rollups: a broken
224 # shim outranks a timeout outranks a generic error when naming the source's
225 # status (all three roll up to tier error regardless).
226 _SPECIFIC_FAILURES = (health.BROKEN, health.TIMEOUT, health.ERROR)
227
228
229 def _fix_text(entry: prescriptions.Prescription) -> str:
230 """Render a registry entry as one actionable fix line (NL + CLI forms)."""
231 if entry.fix_cli and entry.fix_cli not in entry.fix_nl:
232 return f"{entry.fix_nl} (cli: {entry.fix_cli})"
233 return entry.fix_nl
234
235
236 def _record(
237 *,
238 status: str,
239 mode: str = "single",
240 backends_list: Optional[List[Dict[str, Any]]] = None,
241 active_backend: Optional[str] = None,
242 fix: str = "",
243 requires: str = "",
244 note: str = "",
245 detail: str = "",
246 pin_var: Optional[str] = None,
247 pin_flag: Optional[str] = None,
248 pinned: bool = False,
249 ) -> Dict[str, Any]:
250 return {
251 "tier": TIER_BY_STATUS[status],
252 "status": status,
253 "mode": mode,
254 "backends": backends_list,
255 "active_backend": active_backend,
256 "fix": fix,
257 "requires": requires,
258 "note": note,
259 "detail": detail,
260 "pin_var": pin_var,
261 "pin_flag": pin_flag,
262 "pinned": pinned,
263 }
264
265
266 # ---------------------------------------------------------------------------
267 # Chained sources (via U2 descriptors)
268 # ---------------------------------------------------------------------------
269
270 def _finding_json(finding: backends.BackendFinding) -> Dict[str, Any]:
271 return {
272 "name": finding.name,
273 "status": finding.status,
274 "detail": finding.detail,
275 "requires": finding.requires,
276 "fix": finding.prescription,
277 }
278
279
280 def _host_native_web_note(config: Dict[str, Any]) -> str:
281 """Doctor-local note when the host's own web search serves this run.
282
283 Keys on LAST30DAYS_NATIVE_SEARCH (via env.is_native_search) AND on
284 CLAUDECODE as a host signal - Claude Code always exposes a web-search tool,
285 but `doctor` run in a plain shell never sees the LAST30DAYS_NATIVE_SEARCH
286 the engine exports only for its own run, so without the CLAUDECODE signal it
287 would mislabel a fine setup as "degraded/keyless". Messaging only: it does
288 not change env.is_native_search or the engine's keyless-floor behavior. The
289 note names the signal actually detected so it never cites an env var the
290 user did not set.
291 """
292 if env.is_native_search(config):
293 return (
294 "host-native search active (LAST30DAYS_NATIVE_SEARCH): the host's "
295 "own web search serves this run; set a web key only if you want "
296 "engine-side web search"
297 )
298 if config.get("CLAUDECODE") or os.environ.get("CLAUDECODE"):
299 return (
300 "host-native web search active (Claude Code): the host's own web "
301 "search serves this run; set a web key only if you want "
302 "engine-side web search"
303 )
304 return ""
305
306
307 def _chained_record(source: str, config: Dict[str, Any]) -> Dict[str, Any]:
308 descriptor = backends.get_descriptor(source)
309 res = backends.resolve(source, config)
310 findings_json = [_finding_json(f) for f in res.findings]
311 common = dict(
312 mode=res.mode,
313 backends_list=findings_json,
314 active_backend=res.active_backend,
315 pin_var=descriptor.pin_var,
316 pin_flag=descriptor.pin_flag,
317 pinned=res.pinned,
318 )
319
320 if res.mode == backends.MODE_CONDITIONAL:
321 # Reddit: honest conditional wording (U2, verbatim), never a winner.
322 return _record(status=health.OK, note=res.conditional,
323 requires=res.findings[0].requires if res.findings else "",
324 **common)
325
326 by_name = {f.name: f for f in res.findings}
327 if res.tier == backends.TIER_OK:
328 active = by_name.get(res.active_backend)
329 return _record(status=health.OK, note=res.summary,
330 requires=active.requires if active else "", **common)
331
332 # Doctor-local (KTD-3): on a host that brings its own web search, the
333 # engine's web lanes (keyless floor or nothing configured) are intentionally
334 # dormant - report that, not an alarming "degraded/keyless". This must run
335 # before the WARN branch, because the keyless floor resolves to WARN and
336 # would otherwise return first. Messaging only; it never touches
337 # env.is_native_search or the engine's keyless-floor runtime behavior.
338 if source == "web":
339 host_note = _host_native_web_note(config)
340 if host_note:
341 return _record(
342 status="unconfigured",
343 note=host_note,
344 requires=res.findings[0].requires if res.findings else "",
345 **common,
346 )
347
348 if res.tier == backends.TIER_WARN:
349 active = by_name.get(res.active_backend)
350 return _record(status=health.DEGRADED, note=res.summary,
351 detail=active.detail if active else "",
352 fix=res.prescription,
353 requires=active.requires if active else "", **common)
354
355 # res.tier == error: separate "nothing configured" (tier off) from
356 # "configured but broken" (tier error).
357 if res.findings and all(f.status == health.MISSING for f in res.findings):
358 return _record(
359 status="unconfigured",
360 fix=res.prescription,
361 requires=res.findings[0].requires if res.findings else "",
362 note=f"no backend configured (chain: {' -> '.join(res.chain)})",
363 **common,
364 )
365 # Something IS configured/installed but won't serve: name the most
366 # specific failure in chain order.
367 status = health.ERROR
368 fix = res.prescription
369 detail = ""
370 failed: Optional[backends.BackendFinding] = None
371 for wanted in _SPECIFIC_FAILURES:
372 failed = next((f for f in res.findings if f.status == wanted), None)
373 if failed is not None:
374 status = wanted
375 fix = failed.prescription or res.prescription
376 detail = failed.detail
377 break
378 # Mirror the OK/WARN branches: the requirement named is the FAILED
379 # backend's, not chain[0]'s (which may be a different, merely-missing
380 # backend when the failure came from later in the chain).
381 return _record(status=status, fix=fix, detail=detail,
382 requires=failed.requires if failed
383 else (res.findings[0].requires if res.findings else ""),
384 **common)
385
386
387 # ---------------------------------------------------------------------------
388 # Single-backend sources
389 # ---------------------------------------------------------------------------
390
391 def _sc_fix() -> str:
392 return _fix_text(prescriptions.get("scrapecreators", "key_missing"))
393
394
395 def _sc_gated_record(config: Dict[str, Any], purpose: str) -> Dict[str, Any]:
396 if config.get("SCRAPECREATORS_API_KEY"):
397 return _record(status=health.OK, requires="SCRAPECREATORS_API_KEY",
398 detail=f"SCRAPECREATORS_API_KEY present ({purpose})")
399 return _record(status="unconfigured", requires="SCRAPECREATORS_API_KEY",
400 fix=_sc_fix())
401
402
403 def _sc_optin_record(config: Dict[str, Any], source: str, purpose: str) -> Dict[str, Any]:
404 """SC-gated source that ALSO requires an INCLUDE_SOURCES opt-in to run.
405
406 Unlike ``_sc_gated_record`` (used by the on-by-default TikTok/Instagram),
407 a key alone is not enough here: the pipeline only fires this source when it
408 is in INCLUDE_SOURCES. Reporting a bare key as Ready is the Threads
409 false-Ready bug - this mirrors ``_linkedin_record``'s correct gating so
410 doctor and the pipeline cannot disagree.
411 """
412 requires = f"SCRAPECREATORS_API_KEY + INCLUDE_SOURCES={source}"
413 if not config.get("SCRAPECREATORS_API_KEY"):
414 return _record(status="unconfigured", requires=requires, fix=_sc_fix())
415 if source in env.include_sources(config):
416 return _record(status=health.OK, requires=requires,
417 detail=f"SCRAPECREATORS_API_KEY present ({purpose})")
418 return _record(
419 status="opt-in", requires=requires,
420 fix=f"add {source} to INCLUDE_SOURCES (or request it via --search {source})",
421 note="key present; opt-in, never auto-activates",
422 )
423
424
425 def _reddit_record(config):
426 return _chained_record("reddit", config)
427
428
429 # Official-path wording. The bearer path is never described as
430 # parity with the connector lane.
431 X_BEARER_CAVEAT = x_api.BEARER_COVERAGE_NOTE
432 X_CONNECTOR_NOTE = "will use: X connector (host-fetched at run time)"
433 X_CONNECTOR_ARMED = "X connector lane armed"
434
435
436 def _x_will_use_note(record: Dict[str, Any], policy: env.XPolicy) -> str:
437 """The will-use line for a predicted X backend, shaped by the policy.
438
439 On an official-only host a pinned backend renders as ``will use: <name>
440 (pinned)`` and nothing else names it (the pin variable is not
441 advertised). Off it the backends summary is kept verbatim.
442 The bearer prediction carries the about-a-week caveat on every host.
443 """
444 name = record["active_backend"]
445 if policy.official_only:
446 qualifiers: List[str] = []
447 if record.get("pinned"):
448 qualifiers.append("pinned")
449 if name == "xapi":
450 qualifiers.append(X_BEARER_CAVEAT)
451 return f"will use: {name}" + (f" ({'; '.join(qualifiers)})" if qualifiers else "")
452 note = record.get("note") or f"will use: {name}"
453 if name == "xapi":
454 if note.endswith(")"):
455 return f"{note[:-1]}; {X_BEARER_CAVEAT})"
456 return f"{note} ({X_BEARER_CAVEAT})"
457 return note
458
459
460 def _x_record(config):
461 record = _chained_record("x", config)
462 policy = env.x_policy(config)
463 if policy.official_only and not record.get("pinned"):
464 # The pin is documented in CONFIGURATION.md only; doctor never
465 # advertises the knob on an official-only host.
466 record["pin_var"] = None
467 if record.get("active_backend"):
468 record["note"] = _x_will_use_note(record, policy)
469 return record
470 # The declared X connector lane serves X when no engine backend is
471 # predicted (the same precedence as diagnose.x_backend). Host-independent:
472 # the envelope is accepted anywhere.
473 if env.x_host_lane_declared(config):
474 record["status"] = health.OK
475 record["tier"] = TIER_BY_STATUS[health.OK]
476 record["active_backend"] = "connector"
477 record["note"] = X_CONNECTOR_NOTE
478 record["detail"] = ""
479 record["fix"] = ""
480 return record
481 # Diagnose/doctor load config in plan_only mode, so browser cookies are not
482 # extracted and every X backend reads as statically missing -> unconfigured.
483 # But if bird is installed and FROM_BROWSER will authenticate X at run time,
484 # a normal run serves X fine (this is how the reporting user pulled 29 posts
485 # while doctor said "Off"). Reuse the existing shared predicate so doctor and
486 # diagnose cannot drift. It reads no cookie *values*, so it confirms a run
487 # will *attempt* browser auth, not that the session is currently valid -
488 # keep the note honest and point at the verified key-backed path.
489 #
490 # Policy-gated: on an official-only host no run-time cookie source
491 # exists unless bird is pinned, and then the note names only the pin.
492 #
493 # This check MUST come before grok normalization: a pending bird path takes
494 # precedence over marking X as unconfigured due to an unused grok store.
495 # Handle both "unconfigured" (all backends missing) and "error" (grok present
496 # but opt-in, no auto-chain backend usable) when pending bird applies.
497 #
498 # HOWEVER: pending bird must NOT replace a record that has a configured
499 # auto-chain backend in ERROR/DEGRADED/BROKEN/TIMEOUT. Same rule as the
500 # grok normalizer: only upgrade when no auto backend is configured-but-broken.
501 auto_chain_names = set(env.x_auto_chain(config))
502 pending_bird = policy.cookie_discovery and env.x_pending_browser_auth(
503 config, local_only=True
504 )
505 if pending_bird and record["status"] in ("unconfigured", health.ERROR):
506 backends_list = record.get("backends", [])
507 auto_backends = [b for b in backends_list if b.get("name") in auto_chain_names]
508 # Only apply pending-bird upgrade if ALL auto-chain backends are MISSING.
509 # If any auto backend is configured but broken, keep that error.
510 all_auto_missing = all(
511 b.get("status") == health.MISSING for b in auto_backends
512 )
513 if all_auto_missing:
514 record["status"] = health.OK
515 record["tier"] = TIER_BY_STATUS[health.OK]
516 if policy.official_only:
517 record["note"] = "will use: bird (pinned)"
518 else:
519 record["note"] = (
520 "will use: bird (browser cookies; session not verified until a run "
521 "- add XAI_API_KEY for a verified, cookie-free path)"
522 )
523 record["fix"] = ""
524 return record
525 #
526 # Grok is opt-in only: a leftover ~/.grok/auth.json must never steal the X
527 # lane. The grok backend appears in the chain findings (for visibility) but
528 # is never auto-selected. Doctor reports it as "available, unused - pin
529 # LAST30DAYS_X_BACKEND=grok to enable" rather than "will use: grok".
530 #
531 # Policy-gated: on an official-only host the CLI is not probed
532 # unless pinned and is never offered, so this branch does not run there
533 # (no "pin LAST30DAYS_X_BACKEND=grok" note on a Grok Bot host).
534 #
535 # R3/R8: When no auto-chain backend is CONFIGURED (all MISSING) but grok has
536 # any non-MISSING status, X is unconfigured/skipped - NOT broken/auth-failed.
537 # The tier must be "off" (unconfigured), not "error" (NOT WORKING).
538 #
539 # HOWEVER: if an auto-chain backend IS configured but broken (ERROR/DEGRADED),
540 # do NOT normalize to unconfigured. Keep that backend's error and repair
541 # guidance. Unused grok must not swallow a genuine auto-chain failure.
542 #
543 # Do NOT apply this normalization when pending browser auth would make bird
544 # usable — check pending_bird first (handled above via early return).
545 if (
546 not policy.official_only
547 and record["tier"] == TIER_ERROR
548 and not record.get("pinned")
549 and record.get("active_backend") is None
550 and not pending_bird
551 ):
552 backends_list = record.get("backends", [])
553 auto_backends = [b for b in backends_list if b.get("name") in auto_chain_names]
554 # Only normalize if ALL auto-chain backends are MISSING (not configured).
555 # If any auto backend is ERROR/DEGRADED/BROKEN/TIMEOUT, keep that error.
556 all_auto_missing = all(
557 b.get("status") == health.MISSING for b in auto_backends
558 )
559 if not all_auto_missing:
560 # An auto-chain backend is configured but broken — do NOT normalize.
561 # Keep the original error and its repair guidance.
562 return record
563 grok_finding = next(
564 (b for b in backends_list if b.get("name") == "grok"),
565 None,
566 )
567 if grok_finding and grok_finding.get("status") in (
568 health.OK,
569 health.DEGRADED,
570 health.ERROR,
571 ):
572 record["status"] = "unconfigured"
573 record["tier"] = TIER_OFF
574 if grok_finding.get("status") == health.ERROR:
575 record["note"] = (
576 "X unconfigured; grok CLI store is broken but unused (opt-in only) — "
577 "pin LAST30DAYS_X_BACKEND=grok to enable, then fix the store"
578 )
579 else:
580 record["note"] = (
581 "X unconfigured; grok CLI available but opt-in only — "
582 "pin LAST30DAYS_X_BACKEND=grok to enable"
583 )
584 record["fix"] = ""
585 return record
586 xapi_finding = next(
587 (b for b in backends_list if b.get("name") == "xapi"),
588 None,
589 )
590 if xapi_finding and xapi_finding.get("status") in (health.OK, health.DEGRADED):
591 # Same shape as the grok note: a configured opt-in backend is not
592 # a broken X, it is an unconfigured X with a one-line enable.
593 record["status"] = "unconfigured"
594 record["tier"] = TIER_OFF
595 record["note"] = (
596 "X unconfigured; X_BEARER_TOKEN is set but the X API backend is opt-in "
597 "on this host — pin LAST30DAYS_X_BACKEND=xapi to enable"
598 )
599 record["fix"] = ""
600 return record
601 return record
602
603
604 def _youtube_record(config):
605 record = _chained_record("youtube", config)
606 if record["status"] != health.OK:
607 return record
608 notes: List[str] = []
609 # yt-dlp already provides search + transcripts. A transcription key only
610 # backfills captions for the occasional caption-free video - an enhancement,
611 # not a sign YouTube is broken.
612 if not env.transcription_providers(config):
613 entry = prescriptions.get("youtube", "transcription_key_missing")
614 notes.append(
615 "search + transcripts work; a transcription key only adds "
616 "captions for caption-free videos"
617 )
618 record["fix"] = _fix_text(entry)
619 # Comment *text* is free via yt-dlp, so this caveat only fires when yt-dlp
620 # is absent and the legacy ScrapeCreators path is the only one left. Never
621 # prescribe a paid key for something the installed toolchain already does.
622 if not env.is_youtube_comments_available(config):
623 notes.append(
624 "comment text needs yt-dlp (free) or a ScrapeCreators key "
625 "+ youtube_comments opt-in"
626 )
627 # Actionable fix, matching the transcription branch. The transcription
628 # fix takes precedence when both caveats fire (one fix line per record).
629 if not record["fix"]:
630 if not config.get("SCRAPECREATORS_API_KEY"):
631 record["fix"] = _sc_fix()
632 else:
633 record["fix"] = (
634 "add youtube_comments to INCLUDE_SOURCES in "
635 "~/.config/last30days/.env to enable YouTube comment text"
636 )
637 if notes:
638 joined = "; ".join(notes)
639 record["note"] = (record["note"] + "; " + joined) if record["note"] else joined
640 return record
641
642
643 def _web_record(config):
644 return _chained_record("web", config)
645
646
647 def _hackernews_record(config):
648 return _record(status=health.OK, requires="none (free Algolia API)")
649
650
651 def _polymarket_record(config):
652 return _record(status=health.OK, requires="none (public API)")
653
654
655 def _github_record(config):
656 authed = bool(config.get("GITHUB_TOKEN") or env.read_secret_env("GITHUB_TOKEN") or shutil.which("gh"))
657 detail = (
658 "authenticated tier (GITHUB_TOKEN or gh CLI)"
659 if authed
660 else "unauthenticated REST tier (lower rate limits; GITHUB_TOKEN or gh raises them)"
661 )
662 return _record(status=health.OK, detail=detail,
663 requires="none (GITHUB_TOKEN or gh CLI optional)")
664
665
666 def _digg_record(config):
667 probe = health.probe_dependency("digg-pp-cli")
668 requires = "digg-pp-cli on the agent-subprocess PATH"
669 if probe.ok:
670 return _record(status=health.OK, detail=probe.detail, requires=requires)
671 entry = prescriptions.for_dependency_probe(probe)
672 fix = _fix_text(entry) if entry else probe.prescription
673 if probe.status == health.MISSING and not probe.off_path:
674 # Never installed: an optional source that simply isn't enabled.
675 return _record(status="opt-in", fix=fix, detail=probe.detail, requires=requires)
676 # Installed but off-PATH, broken, or timing out: configured-but-broken.
677 return _record(status=probe.status, fix=fix, detail=probe.detail, requires=requires)
678
679
680 def _cli_gated_record(config, cli_name: str, purpose: str):
681 """A source gated purely on a keyless downloaded CLI (mirrors _digg_record).
682
683 ok -> installed and functional; opt-in -> never installed (an optional
684 source simply not enabled); its failing status -> installed off-PATH,
685 broken, or timing out (configured-but-broken).
686 """
687 probe = health.probe_dependency(cli_name)
688 requires = f"{cli_name} on the agent-subprocess PATH"
689 if probe.ok:
690 return _record(status=health.OK, detail=probe.detail, requires=requires)
691 entry = prescriptions.for_dependency_probe(probe)
692 fix = _fix_text(entry) if entry else probe.prescription
693 if probe.status == health.MISSING and not probe.off_path:
694 return _record(status="opt-in", fix=fix, detail=probe.detail, requires=requires)
695 return _record(status=probe.status, fix=fix, detail=probe.detail, requires=requires)
696
697
698 def _techmeme_record(config):
699 return _cli_gated_record(config, "techmeme-pp-cli", "techmeme")
700
701
702 def _arxiv_record(config):
703 return _cli_gated_record(config, "arxiv-pp-cli", "arxiv")
704
705
706 def _trustpilot_record(config):
707 return _cli_gated_record(config, "trustpilot-pp-cli", "trustpilot")
708
709
710 def _amazon_record(config):
711 """Amazon buyer signals: CLI-gated *and* auth-gated.
712
713 Unlike the other CLI-gated sources, a present binary is not enough --
714 the Bright Data CLI owns its own login, so a user can have `brightdata`
715 on PATH and still get nothing. Report those states separately: an
716 unauthenticated install is configured-but-broken (a real fix exists and
717 the user wants to hear it), while a missing binary is just an optional
718 source nobody opted into.
719 """
720 probe = health.probe_dependency(brightdata.CLI_BIN)
721 requires = f"{brightdata.CLI_BIN} on the agent-subprocess PATH, logged in"
722 if probe.ok:
723 if brightdata.has_credentials(config):
724 return _record(status=health.OK, detail=probe.detail, requires=requires)
725 return _record(
726 status="unconfigured",
727 fix="run `brightdata login` to activate the amazon source",
728 detail="brightdata is installed but has no credentials",
729 requires=requires,
730 )
731 entry = prescriptions.for_dependency_probe(probe)
732 fix = _fix_text(entry) if entry else probe.prescription
733 if probe.status == health.MISSING and not probe.off_path:
734 return _record(
735 status="opt-in",
736 fix="npm i -g @brightdata/cli && brightdata login",
737 detail=probe.detail,
738 requires=requires,
739 )
740 return _record(status=probe.status, fix=fix, detail=probe.detail, requires=requires)
741
742
743 def _tiktok_record(config):
744 return _sc_gated_record(config, "tiktok")
745
746
747 def _instagram_record(config):
748 return _sc_gated_record(config, "instagram")
749
750
751 def _threads_record(config):
752 # Threads needs the key AND an INCLUDE_SOURCES=threads opt-in to run, so it
753 # is opt-in-gated (not on-by-default like TikTok/Instagram).
754 return _sc_optin_record(config, "threads", "threads")
755
756
757 def _telegram_record(config):
758 # Telegram needs the key AND an INCLUDE_SOURCES=telegram opt-in AND a
759 # channel list (TELEGRAM_SOURCES). Without named channels there is no
760 # discovery endpoint to call.
761 requires = "SCRAPECREATORS_API_KEY + INCLUDE_SOURCES=telegram + TELEGRAM_SOURCES"
762 if not config.get("SCRAPECREATORS_API_KEY"):
763 return _record(status="unconfigured", requires=requires, fix=_sc_fix())
764 from . import telegram
765 channels = telegram._get_channel_sources(config)
766 if "telegram" in env.include_sources(config):
767 if channels:
768 return _record(
769 status=health.OK,
770 requires=requires,
771 detail=f"SCRAPECREATORS_API_KEY present, {len(channels)} channel(s) configured",
772 )
773 return _record(
774 status="unconfigured",
775 requires=requires,
776 fix="set TELEGRAM_SOURCES to a comma-separated list of public channel handles",
777 note="key present and opt-in active, but no channels configured",
778 )
779 return _record(
780 status="opt-in",
781 requires=requires,
782 fix="add telegram to INCLUDE_SOURCES and set TELEGRAM_SOURCES to channel handles",
783 note="key present; opt-in only, channels required",
784 )
785
786
787 def _bluesky_record(config):
788 if env.is_bluesky_available(config):
789 return _record(status=health.OK, requires="BSKY_HANDLE + BSKY_APP_PASSWORD")
790 return _record(
791 status="unconfigured",
792 requires="BSKY_HANDLE + BSKY_APP_PASSWORD",
793 fix=_fix_text(prescriptions.get("bluesky", "app_password_missing")),
794 )
795
796
797 def _truthsocial_record(config):
798 if env.is_truthsocial_available(config):
799 return _record(status=health.OK, requires="TRUTHSOCIAL_TOKEN")
800 return _record(
801 status="unconfigured",
802 requires="TRUTHSOCIAL_TOKEN",
803 fix=_fix_text(prescriptions.get("truthsocial", "token_missing")),
804 )
805
806
807 def _perplexity_record(config):
808 requires = (
809 "PERPLEXITY_API_KEY or OPENROUTER_API_KEY + "
810 "INCLUDE_SOURCES=perplexity"
811 )
812 has_direct_key = bool(config.get("PERPLEXITY_API_KEY"))
813 has_openrouter_key = bool(config.get("OPENROUTER_API_KEY"))
814 has_key = has_direct_key or has_openrouter_key
815 include = env.include_sources(config)
816 if not has_key:
817 return _record(
818 status="unconfigured", requires=requires,
819 fix=(
820 "set PERPLEXITY_API_KEY or OPENROUTER_API_KEY in "
821 "~/.config/last30days/.env, then add perplexity to INCLUDE_SOURCES"
822 ),
823 )
824 if "perplexity" in include:
825 return _record(
826 status=health.OK,
827 requires=requires,
828 note=(
829 "direct Agent/Search APIs"
830 if has_direct_key
831 else "OpenRouter Sonar compatibility fallback"
832 ),
833 )
834 return _record(
835 status="opt-in", requires=requires,
836 fix="add perplexity to INCLUDE_SOURCES (or request it via --search perplexity)",
837 note="key present; source runs only when opted in",
838 )
839
840
841 def _meta_ads_record(config):
842 return _sc_optin_record(config, "meta_ads", "Meta Ad Library")
843
844
845 def _linkedin_record(config):
846 requires = "SCRAPECREATORS_API_KEY + INCLUDE_SOURCES=linkedin"
847 if not config.get("SCRAPECREATORS_API_KEY"):
848 return _record(status="unconfigured", requires=requires, fix=_sc_fix())
849 if "linkedin" in env.include_sources(config):
850 return _record(status=health.OK, requires=requires)
851 return _record(
852 status="opt-in", requires=requires,
853 fix="add linkedin to INCLUDE_SOURCES (or request it via --search linkedin)",
854 note="key present; power-user opt-in, never auto-activates",
855 )
856
857
858 def _pinterest_record(config):
859 requires = "SCRAPECREATORS_API_KEY; requested-only (--search pinterest)"
860 if not config.get("SCRAPECREATORS_API_KEY"):
861 return _record(status="unconfigured", requires=requires, fix=_sc_fix())
862 return _record(
863 status="opt-in", requires=requires,
864 fix="request it explicitly via --search pinterest (or INCLUDE_SOURCES)",
865 note="key present; runs only when requested",
866 )
867
868
869 def _xiaohongshu_record(config):
870 requires = (
871 "logged-in Xiaohongshu browser-session service; requested-only "
872 "(--search xhs)"
873 )
874 entry = prescriptions.get("xiaohongshu", "service_unreachable")
875 if config.get("XIAOHONGSHU_API_BASE"):
876 return _record(
877 status=health.OK, requires=requires,
878 note=(
879 "XIAOHONGSHU_API_BASE configured; service reachability is not "
880 "probed (doctor makes no network calls)"
881 ),
882 )
883 return _record(
884 status="opt-in",
885 requires=requires,
886 fix=_fix_text(entry),
887 note=(
888 "auto-probes http://localhost:18060 first, then "
889 "http://host.docker.internal:18060"
890 ),
891 )
892
893
894 def _jobs_record(config):
895 return _record(
896 status="opt-in",
897 requires="none; activates for company topics or --hiring-signals",
898 note="on-demand source: no configuration needed",
899 )
900
901
902 def _count_saved_briefs(memory_dir) -> int:
903 """Cheap count of saved research briefs (directory listing, no file parse).
904
905 Globs the ``*-raw*.md`` artifacts the engine writes, deliberately avoiding
906 library.scan_library's read_text+parse of every file - a count does not
907 need the parsed content, and the full scan adds real latency to every
908 `doctor` run on a large library.
909 """
910 path = Path(memory_dir).expanduser()
911 return sum(1 for _ in path.glob("*-raw*.md"))
912
913
914 def _library_record(config):
915 """Local research library that feeds the report's 'From your library' block.
916
917 This is not a network source - it reports how many saved briefs are indexed
918 so the 'From your library' block's presence is explained on the health
919 surface. Read-only and never fails the run: an empty store, a missing store,
920 or a SQLite build without FTS5 all resolve to an informational OK line.
921 """
922 from . import library, library_index
923
924 if not library_index.fts5_available():
925 return _record(
926 status=health.OK,
927 requires="none (local SQLite)",
928 note=(
929 "search index unavailable (this SQLite build lacks FTS5); "
930 "saved briefs still render, `library search` is disabled"
931 ),
932 )
933 try:
934 count = _count_saved_briefs(
935 config.get("LAST30DAYS_MEMORY_DIR") or library.DEFAULT_MEMORY_DIR
936 )
937 except Exception:
938 return _record(
939 status=health.OK,
940 requires="none (local SQLite)",
941 note="local research library (powers the 'From your library' block)",
942 )
943 if count == 0:
944 note = "no saved briefs yet - runs you save build this over time"
945 else:
946 plural = "brief" if count == 1 else "briefs"
947 note = (
948 f"{count} saved {plural}; powers the 'From your library' block "
949 "(LAST30DAYS_LIBRARY_CONTEXT=off to hide)"
950 )
951 return _record(status=health.OK, requires="none (local SQLite)", note=note)
952
953
954 _SOURCE_BUILDERS: Dict[str, Callable[[Dict[str, Any]], Dict[str, Any]]] = {
955 "reddit": _reddit_record,
956 "x": _x_record,
957 "youtube": _youtube_record,
958 "web": _web_record,
959 "hackernews": _hackernews_record,
960 "polymarket": _polymarket_record,
961 "github": _github_record,
962 "digg": _digg_record,
963 "techmeme": _techmeme_record,
964 "arxiv": _arxiv_record,
965 "trustpilot": _trustpilot_record,
966 "amazon": _amazon_record,
967 "meta_ads": _meta_ads_record,
968 "tiktok": _tiktok_record,
969 "instagram": _instagram_record,
970 "threads": _threads_record,
971 "telegram": _telegram_record,
972 "bluesky": _bluesky_record,
973 "truthsocial": _truthsocial_record,
974 "perplexity": _perplexity_record,
975 "linkedin": _linkedin_record,
976 "pinterest": _pinterest_record,
977 "xiaohongshu": _xiaohongshu_record,
978 "jobs": _jobs_record,
979 "library": _library_record,
980 }
981
982
983 # ---------------------------------------------------------------------------
984 # Run-evidence overlay (U1): read the engine's last-report.json
985 #
986 # doctor predicts config health; a research run records what ACTUALLY happened
987 # per source in Report.source_status. Reading the last run lets doctor tell
988 # "configured" from "working" (the four-state audit) and powers --postmortem.
989 # This is a read-only reuse of the engine's existing report cache - no new
990 # writer. The schema stamp + filename mirror REPORT_CACHE_VERSION /
991 # _last_report_cache_path() in last30days.py (the same mirror pattern the
992 # doctor-cache block below already uses for its own schema stamp).
993 # ---------------------------------------------------------------------------
994
995 REPORT_CACHE_SCHEMA_VERSION = "last30days-report-cache/v1"
996 REPORT_CACHE_FILENAME = "last-report.json"
997 DEFAULT_REPORT_CACHE_TTL_SECONDS = 3600
998
999
1000 def _last_report_path() -> Optional[Path]:
1001 """The engine's last-report.json, beside the doctor cache (None in clean mode)."""
1002 if env.CONFIG_DIR is None:
1003 return None
1004 return env.CONFIG_DIR / REPORT_CACHE_FILENAME
1005
1006
1007 def load_run_evidence(
1008 config: Dict[str, Any], ttl_seconds: int = DEFAULT_REPORT_CACHE_TTL_SECONDS
1009 ) -> Dict[str, Any]:
1010 """Return the last research run's per-source outcomes, read-only.
1011
1012 Shape: ``{"outcomes": {source: {state, items_returned, detail, fix_hint,
1013 at}}, "topic": str|None, "at": str|None, "fresh": bool, "present": bool}``.
1014
1015 Any failure mode - absent file, unreadable, invalid JSON, schema mismatch,
1016 wrong shape - yields the empty, not-present result and never raises
1017 (doctor's exit-0 contract is absolute). ``fresh`` reflects the report TTL:
1018 ``--postmortem`` reads regardless of freshness (labeling the age), while the
1019 plain-``doctor`` overlay consumes only fresh evidence so a week-old run
1020 cannot mislabel a source as WORKING today.
1021 """
1022 empty = {"outcomes": {}, "topic": None, "at": None, "fresh": False, "present": False}
1023 path = _last_report_path()
1024 if path is None or not path.exists():
1025 return empty
1026 try:
1027 payload = json.loads(path.read_text(encoding="utf-8"))
1028 except Exception:
1029 return empty
1030 if not isinstance(payload, dict):
1031 return empty
1032 if payload.get("schema") != REPORT_CACHE_SCHEMA_VERSION:
1033 return empty
1034 reports = payload.get("reports") or []
1035 if not reports or not isinstance(reports[0], dict):
1036 return empty
1037 report = reports[0].get("report")
1038 if not isinstance(report, dict):
1039 return empty
1040 raw_status = report.get("source_status") or {}
1041 outcomes: Dict[str, Any] = {}
1042 if isinstance(raw_status, dict):
1043 for source, outcome in raw_status.items():
1044 if not isinstance(outcome, dict):
1045 continue
1046 state = outcome.get("state")
1047 if not isinstance(state, str):
1048 continue
1049 outcomes[source] = {
1050 "state": state,
1051 "items_returned": int(outcome.get("items_returned") or 0),
1052 "detail": outcome.get("detail"),
1053 "fix_hint": outcome.get("fix_hint"),
1054 "at": outcome.get("at"),
1055 }
1056 timestamp = payload.get("timestamp")
1057 return {
1058 "outcomes": outcomes,
1059 "topic": payload.get("topic"),
1060 "at": timestamp or report.get("generated_at"),
1061 "fresh": bool(env.is_timestamp_fresh(timestamp, ttl_seconds)),
1062 "present": True,
1063 }
1064
1065
1066 # ---------------------------------------------------------------------------
1067 # Backup + comment sub-lanes (U7 / R8, R9)
1068 #
1069 # Backups (Reddit's SC backfill, YouTube's SC transcript/search backstop, X's
1070 # cookie-vs-key dual path) and comment lanes (youtube/tiktok/instagram) are not
1071 # independent sources - they are capabilities of their parent. doctor surfaces
1072 # them as indented sub-lines so "is a backup armed when yt-dlp is rate-limited?"
1073 # is answerable at a glance without inventing fake sources.
1074 # ---------------------------------------------------------------------------
1075
1076 def _x_auth_path(config: Dict[str, Any]) -> Dict[str, Any]:
1077 """The "X auth path" sub-lane, routed through the X policy.
1078
1079 Off an official-only host the wording is unchanged (key-backed, cookie
1080 path, or nothing armed). On one it names only the official path: the
1081 connector lane when declared, else the bearer (with the about-a-week
1082 caveat), the xAI key, or xurl; a pinned non-official backend is named
1083 once, on the will-use line, never here.
1084 """
1085 policy = env.x_policy(config)
1086 if not policy.official_only:
1087 has_key = bool(config.get("XAI_API_KEY") or config.get("XQUIK_API_KEY"))
1088 cookie = bool(env.x_pending_browser_auth(config, local_only=True))
1089 if has_key:
1090 note = "XAI_API_KEY key-backed path (verified, cookie-free)"
1091 elif cookie:
1092 note = (
1093 "browser-cookie path primary; add XAI_API_KEY for a verified "
1094 "cookie-free backup"
1095 )
1096 else:
1097 note = "no auth path armed"
1098 return {"name": "X auth path", "armed": has_key or cookie, "note": note}
1099 if env.x_host_lane_declared(config):
1100 return {"name": "X auth path", "armed": True, "note": X_CONNECTOR_ARMED}
1101 source = env.get_x_source(config, local_only=True)
1102 if source == "xapi":
1103 note = f"X_BEARER_TOKEN path ({X_BEARER_CAVEAT})"
1104 elif source == "xai":
1105 note = "XAI_API_KEY licensed path (console.x.ai)"
1106 elif source == "xurl":
1107 note = "X API through the xurl CLI (stored OAuth2 login)"
1108 elif source:
1109 note = "explicit backend pin"
1110 else:
1111 note = (
1112 "no official X path armed (add the X for Grok Bot plugin and connect X in Grok Bot settings, or set "
1113 "X_BEARER_TOKEN or XAI_API_KEY)"
1114 )
1115 return {"name": "X auth path", "armed": bool(source), "note": note}
1116
1117
1118 def _sub_lanes_for(source: str, config: Dict[str, Any]):
1119 """Return (backups, comments) metadata for a source, or ([], None)."""
1120 backups: List[Dict[str, Any]] = []
1121 comments: Optional[Dict[str, Any]] = None
1122 has_sc = bool(config.get("SCRAPECREATORS_API_KEY"))
1123 if source == "reddit":
1124 floor = env.reddit_sc_min_items(config)
1125 if floor > 0:
1126 note = f"fills in when results fall below the {floor}-item floor"
1127 else:
1128 note = "fills in when the free public path returns nothing"
1129 backups.append({"name": "ScrapeCreators backfill", "armed": has_sc, "note": note})
1130 elif source == "youtube":
1131 backups.append({
1132 "name": "ScrapeCreators transcript/search backstop", "armed": has_sc,
1133 "note": "used when yt-dlp is rate-limited or bot-gated",
1134 })
1135 comments = {"enabled": bool(env.is_youtube_comments_available(config))}
1136 elif source == "x":
1137 backups.append(_x_auth_path(config))
1138 elif source == "tiktok":
1139 comments = {"enabled": bool(env.is_tiktok_comments_available(config))}
1140 elif source == "instagram":
1141 comments = {"enabled": bool(env.is_instagram_comments_available(config))}
1142 return backups, comments
1143
1144
1145 # ---------------------------------------------------------------------------
1146 # Aggregation
1147 # ---------------------------------------------------------------------------
1148
1149 def _engine_version() -> str:
1150 try:
1151 from . import render
1152
1153 version = render._skill_version()
1154 except Exception:
1155 version = None
1156 # render's parent-walking helper falls back to "?"; doctor says "unknown".
1157 if not version or version == "?":
1158 return "unknown"
1159 return version
1160
1161
1162 def _setup_block(config: Dict[str, Any]) -> Dict[str, Any]:
1163 keys_present = {var: bool(config.get(var)) for var in KEY_PRESENCE_VARS}
1164 keys_present["x_browser_cookies"] = bool(
1165 config.get("AUTH_TOKEN") and config.get("CT0")
1166 )
1167 keys_present["bluesky_app_password"] = bool(
1168 config.get("BSKY_HANDLE") and config.get("BSKY_APP_PASSWORD")
1169 )
1170 return {
1171 "setup_complete": env.is_setup_complete(config),
1172 "keys_present": keys_present,
1173 # get_config() rejected these before any presence check ran, so
1174 # keys_present already reads them as absent. Carrying the names here is
1175 # what lets the text renderer say *why* they are absent instead of
1176 # leaving the user to read "credentials present: none". Only keys left
1177 # unset are listed: one whose placeholder fell through to a real
1178 # lower-priority credential is configured and does not appear.
1179 "unsubstituted_templates": env.templated_config_keys(config),
1180 }
1181
1182
1183 def _permissions_block(config: Dict[str, Any]) -> Dict[str, Any]:
1184 """Secret-free permission summary via the existing preflight provider."""
1185 from . import pipeline
1186
1187 diag = pipeline.diagnose(config, None, safe=True)
1188 return diag["permission_preflight"]
1189
1190
1191 def build_report(config: Dict[str, Any]) -> Dict[str, Any]:
1192 """Aggregate every health provider into one report dict.
1193
1194 Per-source exceptions are isolated: a failing builder yields an
1195 ``error`` record for that source and the rest of the report survives.
1196 """
1197 def _build_one(name: str) -> Dict[str, Any]:
1198 try:
1199 return _SOURCE_BUILDERS[name](config)
1200 except Exception as exc: # one bad probe must not blank the report
1201 return _record(
1202 status=health.ERROR,
1203 detail=f"probe failed: {type(exc).__name__}: {exc}",
1204 fix=_fix_text(prescriptions.get(name, "probe_error")),
1205 )
1206
1207 # Builders are independent probes (subprocess/filesystem bound), so run
1208 # them concurrently. ``pool.map`` preserves SOURCE_ORDER, keeping the
1209 # sources dict insertion order — and render grouping — deterministic.
1210 with concurrent.futures.ThreadPoolExecutor(
1211 max_workers=min(8, len(SOURCE_ORDER))
1212 ) as pool:
1213 sources: Dict[str, Dict[str, Any]] = dict(
1214 zip(SOURCE_ORDER, pool.map(_build_one, SOURCE_ORDER))
1215 )
1216
1217 # U1: overlay the last research run's per-source outcome onto each record
1218 # so the audit layer can tell "configured" from "actually working".
1219 # U2: derive each record's four-state audit bucket (probe evidence, when a
1220 # live probe runs, is layered on in run() before render).
1221 evidence = load_run_evidence(config)
1222 for source, record in sources.items():
1223 record["run_outcome"] = evidence["outcomes"].get(source) if evidence["fresh"] else None
1224 record["audit_state"] = audit_state(source, record, record["run_outcome"])
1225
1226 # U3: annotate each CLI-dependent source with its binary's health so the
1227 # per-source marker and the dedicated CLI-health block can render (R2).
1228 for source, cli_name in CLI_DEPENDENCIES.items():
1229 record = sources.get(source)
1230 if record is None:
1231 continue
1232 try:
1233 probe = health.probe_dependency(cli_name)
1234 except Exception:
1235 continue
1236 record["cli"] = {
1237 "name": cli_name,
1238 "status": probe.status,
1239 "off_path": bool(getattr(probe, "off_path", False)),
1240 "detail": probe.detail,
1241 "optional": source in _OPTIONAL_CLI_SOURCES,
1242 }
1243
1244 # U7: attach backup + comment sub-lanes to their parent source.
1245 for source, record in sources.items():
1246 backups, comments = _sub_lanes_for(source, config)
1247 if backups:
1248 record["backups"] = backups
1249 if comments is not None:
1250 record["comments"] = comments
1251
1252 # Sequential on purpose: the permission preflight composes pipeline
1253 # diagnostics and must not race the source builders.
1254 try:
1255 permissions = _permissions_block(config)
1256 except Exception as exc:
1257 permissions = {"status": "unavailable", "error": f"{type(exc).__name__}: {exc}"}
1258
1259 return {
1260 "engine_version": _engine_version(),
1261 "config": {
1262 "global_env": str(env.CONFIG_FILE) if env.CONFIG_FILE else None,
1263 "config_source": config.get("_CONFIG_SOURCE"),
1264 # The resolved host value, so a missing export is visible.
1265 "host": env.x_policy(config).host or None,
1266 # A .env line cannot declare the X connector lane.
1267 "host_lane_file_ignored": bool(config.get("_X_HOST_LANE_FILE_IGNORED")),
1268 },
1269 "setup": _setup_block(config),
1270 "permissions": permissions,
1271 "sources": sources,
1272 "mode": "config",
1273 "run_evidence": {
1274 "present": evidence["present"],
1275 "fresh": evidence["fresh"],
1276 "topic": evidence["topic"],
1277 "at": evidence["at"],
1278 },
1279 }
1280
1281
1282 # ---------------------------------------------------------------------------
1283 # Renderers
1284 # ---------------------------------------------------------------------------
1285
1286 def render_json(report: Dict[str, Any]) -> str:
1287 return json.dumps(report, indent=2, sort_keys=True)
1288
1289
1290 def _cli_marker(record: Dict[str, Any]) -> str:
1291 """Inline `[CLI: name ✓]` / `[keyless]` marker (populated by U3)."""
1292 cli = record.get("cli")
1293 if not cli:
1294 return ""
1295 name = cli.get("name")
1296 if cli.get("status") == health.OK:
1297 return f" [CLI: {name} ✓]"
1298 if cli.get("off_path"):
1299 return f" [CLI: {name} ✗ off-PATH]"
1300 return f" [CLI: {name} ✗ {cli.get('status')}]"
1301
1302
1303 def _run_evidence_suffix(record: Dict[str, Any], state: str) -> str:
1304 """Last-run outcome tail for a source line (R4)."""
1305 ro = record.get("run_outcome")
1306 if not ro:
1307 if state == AUDIT_UNVERIFIED:
1308 return " [no recent run]"
1309 return ""
1310 st = ro.get("state")
1311 count = ro.get("items_returned") or 0
1312 detail = ro.get("detail")
1313 if st in _RUN_WORKING_STATES:
1314 if count:
1315 return f" [✓ {count} items last run]"
1316 return " [✓ ran clean, 0 matches last run]"
1317 if st == health.PARTIAL:
1318 tail = f" ({detail})" if detail else ""
1319 return f" [⚠ partial last run{tail}]"
1320 tail = detail or st
1321 return f" [✕ {tail} last run]"
1322
1323
1324 def _audit_source_line(name: str, record: Dict[str, Any], state: str) -> str:
1325 glyph = AUDIT_GLYPHS.get(state, "?")
1326 parts = [f" {glyph} {name}{_cli_marker(record)}"]
1327 descriptors: List[str] = []
1328 if record.get("status") not in (health.OK,):
1329 descriptors.append(record["status"])
1330 if record.get("note"):
1331 descriptors.append(record["note"])
1332 elif record.get("detail") and record.get("tier") != TIER_OK:
1333 descriptors.append(record["detail"])
1334 if descriptors:
1335 parts.append(" — " + "; ".join(descriptors))
1336 evidence = _run_evidence_suffix(record, state)
1337 if evidence:
1338 parts.append(evidence)
1339 # fix is only ever populated when there is something actionable, so
1340 # render it whenever present — an ok-tier record can carry one (the
1341 # youtube transcription-key note) and must not lose it in text mode.
1342 if record.get("fix"):
1343 parts.append(f"; fix: {record['fix']}")
1344 # Backup / comment sub-lanes render on their own indented lines (U7),
1345 # after the primary line (with its fix) is complete.
1346 for sub in _sub_lane_lines(record):
1347 parts.append("\n" + sub)
1348 return "".join(parts)
1349
1350
1351 def _sub_lane_lines(record: Dict[str, Any]) -> List[str]:
1352 """Indented backup/comment sub-lane lines under a source (R8, R9)."""
1353 lines: List[str] = []
1354 for backup in record.get("backups") or []:
1355 state = "armed" if backup.get("armed") else "off"
1356 note = f" - {backup['note']}" if backup.get("note") else ""
1357 lines.append(f" backup: {backup['name']} — {state}{note}")
1358 comments = record.get("comments")
1359 if comments is not None:
1360 state = "on" if comments.get("enabled") else "off"
1361 lines.append(f" comments: {state}")
1362 return lines
1363
1364
1365 def _cli_health_lines(report: Dict[str, Any]) -> List[str]:
1366 """Dedicated CLI-health block (R2): one row per CLI-dependent source,
1367 plus a note naming the keyless sources that need no CLI at all.
1368 """
1369 sources = report.get("sources") or {}
1370 rows: List[str] = []
1371 for source in SOURCE_ORDER:
1372 cli = (sources.get(source) or {}).get("cli")
1373 if not cli:
1374 continue
1375 ok = cli.get("status") == health.OK
1376 glyph = "✓" if ok else "✗"
1377 detail = cli.get("detail") or cli.get("status")
1378 tail = ""
1379 if not ok:
1380 if cli.get("off_path"):
1381 tail = " (installed off-PATH)"
1382 elif cli.get("optional"):
1383 tail = " (optional)"
1384 rows.append(f" {glyph} {cli['name']} — {source}{tail}: {detail}")
1385 if not rows:
1386 return []
1387 return (
1388 ["CLI health (downloaded binaries):"]
1389 + rows
1390 + [" · Reddit, Hacker News, Polymarket need no CLI (keyless)"]
1391 )
1392
1393
1394 def render_text(report: Dict[str, Any]) -> str:
1395 lines: List[str] = [f"last30days doctor — engine v{report['engine_version']}"]
1396 config_block = report.get("config") or {}
1397 if config_block.get("global_env"):
1398 line = f"config: {config_block['global_env']}"
1399 if config_block.get("config_source"):
1400 line += f" (source: {config_block['config_source']})"
1401 lines.append(line)
1402 host = config_block.get("host")
1403 if host:
1404 lines.append(f"host: {host} ({env.X_HOST_VAR})")
1405 else:
1406 lines.append(f"host: not set ({env.X_HOST_VAR} unset; default X policy)")
1407 if config_block.get("host_lane_file_ignored"):
1408 lines.append(
1409 f"note: a {env.X_HOST_LANE_VAR} line in .env cannot declare the X "
1410 "connector lane (ignored); export it in the process environment "
1411 "for the session instead"
1412 )
1413
1414 setup = report.get("setup") or {}
1415 present = sorted(
1416 name for name, is_set in (setup.get("keys_present") or {}).items() if is_set
1417 )
1418 setup_state = "complete" if setup.get("setup_complete") else "not recorded"
1419 lines.append(
1420 f"setup: {setup_state}; credentials present: "
1421 + (", ".join(present) if present else "none")
1422 + " (values never shown)"
1423 )
1424 templated = setup.get("unsubstituted_templates") or []
1425 if templated:
1426 lines.append(
1427 "note: unsubstituted config template(s), counted as unset: "
1428 + ", ".join(templated)
1429 + " (set a real value or remove each)"
1430 )
1431
1432 permissions = report.get("permissions") or {}
1433 if permissions.get("status"):
1434 browser = ((permissions.get("local_reads") or {}).get("browser_cookies") or {})
1435 lines.append(
1436 f"permissions: {permissions['status']}"
1437 + (f"; browser cookies: {browser.get('status')}" if browser else "")
1438 )
1439
1440 run_ev = report.get("run_evidence") or {}
1441 if run_ev.get("present") and run_ev.get("fresh"):
1442 topic = run_ev.get("topic") or "last run"
1443 lines.append(
1444 f"last run: {topic} - overlaying actual source outcomes below"
1445 )
1446 elif run_ev.get("present"):
1447 lines.append(
1448 "last run: found but stale - run `doctor --postmortem` to inspect it"
1449 )
1450
1451 grouped: Dict[str, List[str]] = {state: [] for state, _ in AUDIT_GROUPS}
1452 for name, record in (report.get("sources") or {}).items():
1453 state = record.get("audit_state") or audit_state(
1454 name, record, record.get("run_outcome")
1455 )
1456 grouped.setdefault(state, []).append(_audit_source_line(name, record, state))
1457
1458 for state, header in AUDIT_GROUPS:
1459 entries = grouped.get(state) or []
1460 lines.append("")
1461 lines.append(f"{AUDIT_GLYPHS.get(state, '')} {header}:")
1462 if entries:
1463 lines.extend(entries)
1464 else:
1465 lines.append(" (none)")
1466
1467 cli_block = _cli_health_lines(report)
1468 if cli_block:
1469 lines.append("")
1470 lines.extend(cli_block)
1471
1472 lines.append("")
1473 lines.append(
1474 "doctor reports problems without failing; run the printed fixes, "
1475 "then re-run doctor"
1476 )
1477 return "\n".join(lines) + "\n"
1478
1479
1480 # ---------------------------------------------------------------------------
1481 # Post-mortem (U4 / R5): what actually happened on the last run
1482 #
1483 # Unlike plain doctor (config prediction), --postmortem reads the last run's
1484 # per-source SourceOutcome and reports what broke, at any age (labeled). It is
1485 # a reader of the same last-report.json the overlay uses - no new persistence.
1486 # ---------------------------------------------------------------------------
1487
1488 def _age_label(iso: Any) -> str:
1489 if not iso:
1490 return ""
1491 try:
1492 ts = datetime.datetime.fromisoformat(iso)
1493 except (TypeError, ValueError):
1494 return ""
1495 if ts.tzinfo is None:
1496 ts = ts.replace(tzinfo=datetime.timezone.utc)
1497 secs = int(
1498 (datetime.datetime.now(datetime.timezone.utc) - ts).total_seconds()
1499 )
1500 if secs < 0:
1501 return ""
1502 if secs < 3600:
1503 return f"{secs // 60}m ago"
1504 if secs < 86400:
1505 return f"{secs // 3600}h ago"
1506 return f"{secs // 86400}d ago"
1507
1508
1509 def build_postmortem(config: Dict[str, Any]) -> Dict[str, Any]:
1510 """Assemble the last run's per-source outcomes (any age) for --postmortem."""
1511 evidence = load_run_evidence(config)
1512 return {
1513 "engine_version": _engine_version(),
1514 "mode": "postmortem",
1515 "present": evidence["present"],
1516 "topic": evidence["topic"],
1517 "at": evidence["at"],
1518 "outcomes": evidence["outcomes"],
1519 }
1520
1521
1522 def _postmortem_state_label(source: str, outcome: Dict[str, Any]) -> str:
1523 """State label for a failed post-mortem line.
1524
1525 ``payment-required`` reads as "credits exhausted" ("X API credits
1526 exhausted" for X) so the fix is obvious at a glance: top up, not re-login.
1527 Every other state prints as its raw name.
1528 """
1529 state = str(outcome.get("state") or "")
1530 if state == health.PAYMENT_REQUIRED:
1531 return health.credits_exhausted_label(source)
1532 return state
1533
1534
1535 def render_postmortem_text(pm: Dict[str, Any]) -> str:
1536 lines = [f"last30days post-mortem — engine v{pm['engine_version']}"]
1537 if not pm.get("present"):
1538 lines.append("")
1539 lines.append(
1540 "No saved run found - run `/last30days <topic>` first, or "
1541 "`doctor --probe` for a live check."
1542 )
1543 return "\n".join(lines) + "\n"
1544 topic = pm.get("topic") or "last run"
1545 age = _age_label(pm.get("at"))
1546 lines.append(f"last run: {topic}" + (f" ({age})" if age else ""))
1547
1548 failed, partial, succeeded, skipped = [], [], [], []
1549 for source, outcome in (pm.get("outcomes") or {}).items():
1550 state = outcome.get("state")
1551 if state in _RUN_WORKING_STATES:
1552 succeeded.append((source, outcome))
1553 elif state == health.PARTIAL:
1554 partial.append((source, outcome))
1555 elif state == health.SKIPPED_UNCONFIGURED:
1556 skipped.append((source, outcome))
1557 else:
1558 failed.append((source, outcome))
1559
1560 if failed:
1561 lines.append("")
1562 lines.append("Failed:")
1563 for source, outcome in failed:
1564 detail = outcome.get("detail") or outcome.get("state")
1565 lines.append(f" ✕ {source} — {_postmortem_state_label(source, outcome)}: {detail}")
1566 if outcome.get("fix_hint"):
1567 lines.append(f" fix: {outcome['fix_hint']}")
1568 if partial:
1569 lines.append("")
1570 lines.append("Partial:")
1571 for source, outcome in partial:
1572 count = outcome.get("items_returned") or 0
1573 detail = outcome.get("detail")
1574 tail = f" — {detail}" if detail else ""
1575 lines.append(f" ⚠ {source} ({count} items){tail}")
1576 if outcome.get("fix_hint"):
1577 lines.append(f" fix: {outcome['fix_hint']}")
1578 if succeeded:
1579 lines.append("")
1580 def _succeeded_label(source: str, outcome: Dict[str, Any]) -> str:
1581 count = outcome.get("items_returned") or 0
1582 detail = outcome.get("detail")
1583 if detail:
1584 noun = "item" if count == 1 else "items"
1585 return f"{source} ({count} {noun}; {detail})"
1586 return f"{source} ({count})"
1587
1588 names = ", ".join(_succeeded_label(s, o) for s, o in succeeded)
1589 lines.append(f"Succeeded: {names}")
1590 if skipped:
1591 lines.append("")
1592 lines.append(
1593 "Skipped (not configured): " + ", ".join(s for s, _ in skipped)
1594 )
1595 if not failed and not partial:
1596 lines.append("")
1597 lines.append("No failures on the last run.")
1598 return "\n".join(lines) + "\n"
1599
1600
1601 # ---------------------------------------------------------------------------
1602 # Cross-invocation cache (U5 / R5, KTD 8)
1603 #
1604 # Doctor writes its JSON result beside the existing ``last-run.json``
1605 # convention (env.CONFIG_DIR) so the SKILL.md standing rule's pre-research
1606 # check costs one file read on the healthy path instead of a dozen probe
1607 # subprocesses. ``--cached`` serves the stored report within the TTL and
1608 # falls through to a live run (rewriting the cache) when the file is stale,
1609 # absent, or corrupt — corruption is treated as absence, never a crash.
1610 # An explicit ``doctor`` (no ``--cached``) always runs live and refreshes.
1611 #
1612 # The payload carries a schema stamp (mirrors REPORT_CACHE_VERSION in
1613 # last30days.py) and a config fingerprint — a sha256 over the same
1614 # non-secret signals doctor already reports (key-presence booleans, backend
1615 # pin values, INCLUDE_SOURCES). A schema or fingerprint mismatch is treated
1616 # as stale, so a credential or pin change can never serve yesterday's
1617 # conclusions. Served reports carry ``from_cache`` + ``generated_at`` so
1618 # consumers can see staleness instead of inferring it.
1619 # ---------------------------------------------------------------------------
1620
1621 CACHE_FILENAME = "doctor-cache.json"
1622
1623 # Bump when the cached payload/report shape changes incompatibly; a
1624 # mismatched (or absent) stamp is treated as an absent cache.
1625 DOCTOR_CACHE_SCHEMA_VERSION = "last30days-doctor-cache/v1"
1626
1627 # TTL in SECONDS (env-tunable via LAST30DAYS_DOCTOR_TTL; registered in
1628 # lib/env.py's get_config key list so a .env-set value is not swallowed).
1629 DEFAULT_CACHE_TTL_SECONDS = 900
1630
1631 # Config vars whose values must never land in the cache file. Doctor output
1632 # carries no secrets by design (key presence is booleans only); this belt-and-
1633 # suspenders check refuses to persist the cache if a seeded value ever leaks.
1634 _SECRET_CONFIG_VARS = KEY_PRESENCE_VARS + (
1635 "AUTH_TOKEN", "CT0", "APIFY_API_TOKEN", "GOOGLE_GENAI_API_KEY",
1636 )
1637
1638 # Backend pin vars folded into the config fingerprint. Pin values are
1639 # backend names (e.g. "bird"), never secrets. The host key and the X
1640 # connector lane signal join them: both are non-secret switches that
1641 # change every X conclusion, so a cached report must not outlive them.
1642 _FINGERPRINT_PIN_VARS = (
1643 env.X_BACKEND_PIN_VAR,
1644 env.REDDIT_BACKEND_PIN_VAR,
1645 env.X_HOST_VAR,
1646 env.X_HOST_LANE_VAR,
1647 )
1648
1649 # Top-level report keys the renderers read unguarded; a cached report
1650 # missing any of them is treated as corrupt (absent), never rendered.
1651 _REQUIRED_REPORT_KEYS = ("engine_version", "config", "setup", "permissions", "sources")
1652
1653
1654 def cache_path() -> Optional[Path]:
1655 """The doctor cache file, beside last-run.json (None in clean mode)."""
1656 if env.CONFIG_DIR is None:
1657 return None
1658 return env.CONFIG_DIR / CACHE_FILENAME
1659
1660
1661 def cache_ttl_seconds(config: Dict[str, Any]) -> int:
1662 """LAST30DAYS_DOCTOR_TTL in seconds; process env > config; default 900."""
1663 raw: Any = os.environ.get("LAST30DAYS_DOCTOR_TTL")
1664 if raw is None:
1665 raw = (config or {}).get("LAST30DAYS_DOCTOR_TTL")
1666 if raw is None or raw == "":
1667 return DEFAULT_CACHE_TTL_SECONDS
1668 try:
1669 return max(0, int(raw))
1670 except (TypeError, ValueError):
1671 return DEFAULT_CACHE_TTL_SECONDS
1672
1673
1674 def _is_fresh(timestamp: Any, ttl_seconds: int) -> bool:
1675 return env.is_timestamp_fresh(timestamp, ttl_seconds)
1676
1677
1678 def _config_fingerprint(config: Dict[str, Any]) -> str:
1679 """sha256 over the non-secret config signals doctor already reports.
1680
1681 Inputs are key-presence BOOLEANS (never credential values — the same
1682 ``keys_present`` set the setup block renders), backend pin values
1683 (backend names, not secrets), and INCLUDE_SOURCES (not a secret).
1684 Adding or removing a credential, changing a pin, or toggling an opt-in
1685 source yields a new fingerprint, so ``read_cached_report`` treats the
1686 old cache as stale instead of serving pre-change conclusions.
1687 """
1688 config = config or {}
1689 signals = {
1690 "keys_present": _setup_block(config)["keys_present"],
1691 "pins": {var: str(config.get(var) or "") for var in _FINGERPRINT_PIN_VARS},
1692 "include_sources": str(config.get("INCLUDE_SOURCES") or ""),
1693 }
1694 canonical = json.dumps(signals, sort_keys=True, separators=(",", ":"))
1695 return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
1696
1697
1698 def _report_shape_ok(report: Any) -> bool:
1699 """True when a cached report satisfies the render contract.
1700
1701 Validates everything the renderers read unguarded: the required
1702 top-level keys exist (dict-valued where render calls ``.get`` on them),
1703 and every sources record is a dict carrying a known tier and a str
1704 status. Anything else is corrupt — treated as absent, never rendered.
1705 """
1706 if not isinstance(report, dict):
1707 return False
1708 if any(key not in report for key in _REQUIRED_REPORT_KEYS):
1709 return False
1710 if any(
1711 not isinstance(report[key], dict)
1712 for key in ("config", "setup", "permissions", "sources")
1713 ):
1714 return False
1715 sources = report["sources"]
1716 if not sources:
1717 return False
1718 for record in sources.values():
1719 if not isinstance(record, dict):
1720 return False
1721 if record.get("tier") not in GLYPHS:
1722 return False
1723 if not isinstance(record.get("status"), str):
1724 return False
1725 return True
1726
1727
1728 def read_cached_report(config: Dict[str, Any]) -> Optional[Dict[str, Any]]:
1729 """Return the cached report when present, well-formed, and within TTL.
1730
1731 Any failure mode — unreadable file, invalid JSON, schema mismatch,
1732 config-fingerprint mismatch, wrong shape, bad or stale timestamp —
1733 returns None (cache treated as absent, never a crash).
1734
1735 A served report is stamped with ``from_cache: True`` and
1736 ``generated_at`` (the cache write time) so consumers see staleness.
1737 """
1738 path = cache_path()
1739 if path is None:
1740 return None
1741 try:
1742 payload = json.loads(path.read_text(encoding="utf-8"))
1743 except Exception:
1744 return None
1745 if not isinstance(payload, dict):
1746 return None
1747 if payload.get("schema") != DOCTOR_CACHE_SCHEMA_VERSION:
1748 return None # absent or mismatched schema stamp: treat as absent
1749 if payload.get("fingerprint") != _config_fingerprint(config):
1750 return None # credentials/pins/opt-ins changed: cache is stale
1751 report = payload.get("report")
1752 if not _report_shape_ok(report):
1753 return None
1754 if not _is_fresh(payload.get("timestamp"), cache_ttl_seconds(config)):
1755 return None
1756 report["generated_at"] = payload.get("timestamp")
1757 report["from_cache"] = True
1758 return report
1759
1760
1761 def _write_cache(report: Dict[str, Any], config: Dict[str, Any]) -> bool:
1762 """Best-effort cache write; refuses to persist any secret value.
1763
1764 Never fatal: any failure returns False after a one-line stderr warning
1765 (doctor's exit-0 contract is unaffected; only ``--cached`` reuse is).
1766 """
1767 try:
1768 path = cache_path()
1769 if path is None:
1770 return False
1771 payload = {
1772 "schema": DOCTOR_CACHE_SCHEMA_VERSION,
1773 "fingerprint": _config_fingerprint(config),
1774 "timestamp": report.get("generated_at")
1775 or datetime.datetime.now(datetime.timezone.utc).isoformat(),
1776 "report": report,
1777 }
1778 raw = json.dumps(payload, indent=2, sort_keys=True)
1779 for var in _SECRET_CONFIG_VARS:
1780 value = (config or {}).get(var)
1781 if isinstance(value, str) and value and value in raw:
1782 return False # never write a cache containing a secret
1783 path.parent.mkdir(parents=True, exist_ok=True)
1784 path.write_text(raw, encoding="utf-8")
1785 return True
1786 except Exception as exc:
1787 sys.stderr.write(
1788 f"[last30days] WARNING: could not write doctor cache: "
1789 f"{type(exc).__name__}: {exc}\n"
1790 )
1791 sys.stderr.flush()
1792 return False
1793
1794
1795 # ---------------------------------------------------------------------------
1796 # Live probe (U5 / R6)
1797 #
1798 # When there is no fresh run to learn from (or on explicit --probe), doctor
1799 # runs a BOUNDED live test so WORKING is verified, not guessed. Scope is
1800 # deliberate: free HTTP endpoints + keyless CLIs only. Credit-gated /
1801 # session-gated sources (x, tiktok, instagram, threads, ...) are NOT
1802 # live-probed - a health check must never spend ScrapeCreators credits or trip
1803 # auth rate limits; they stay UNVERIFIED with that noted. Every probe is capped
1804 # by a per-source deadline so a single slow source (YouTube's 120s search) can
1805 # never hang doctor.
1806 # ---------------------------------------------------------------------------
1807
1808 # Free, keyless liveness endpoints (reachability check, tiny payload).
1809 _HTTP_PROBE_URLS = {
1810 # The keyless engine's real discovery endpoint, built by the lane itself so
1811 # the probe cannot drift from what the engine requests (hand-copied URLs
1812 # kept certifying endpoints the engine had abandoned).
1813 "reddit": reddit_search.search_url("test"),
1814 "hackernews": "https://hn.algolia.com/api/v1/search?query=test&hitsPerPage=1",
1815 "polymarket": "https://gamma-api.polymarket.com/events?limit=1",
1816 "github": "https://api.github.com/rate_limit",
1817 }
1818
1819 # Per-source exception to "a 4xx still means the endpoint responded". The
1820 # keyless Reddit lanes send no credentials, so a 403/429 there is the host
1821 # refusing this client — the exact failure the engine hits — not reachability.
1822 _PROBE_BLOCKED_STATUSES = {"reddit": frozenset({403, 429})}
1823
1824 # Blocked statuses that mean "refused this probe right now", not "the source is
1825 # down". Reddit answers a burst of keyless probes with 429 while the research
1826 # lane — which retries with backoff across several endpoints — serves the same
1827 # query fine. A single unretried 429 was reporting a healthy Reddit as NOT
1828 # WORKING, so these get one retry and, if still refused, downgrade to unverified
1829 # rather than a false outage. 403 stays hard: that is a real keyless block.
1830 _PROBE_TRANSIENT_STATUSES = {"reddit": frozenset({429})}
1831
1832 # One retry only: the probe budget is the per-source deadline, and a source that
1833 # rate-limits twice in a row is worth surfacing as unverified.
1834 _PROBE_RETRY_DELAY_SECONDS = 2.0
1835
1836 # Probe with the identity the lane sends, or the probe measures the User-Agent
1837 # rather than the endpoint (get_text sends http.BROWSER_USER_AGENT).
1838 _PROBE_HEADERS = {
1839 "reddit": {
1840 "User-Agent": http.BROWSER_USER_AGENT,
1841 "Accept": "text/html",
1842 },
1843 }
1844
1845 # Per-source body validators, run on a 2xx: None means the body is the page the
1846 # lane parses, anything else is the reason it is not. Reddit answers a blocked
1847 # keyless client with a 200 challenge page, so status alone reads it as working.
1848 _PROBE_BODY_CHECKS: Dict[str, Callable[[str], Optional[str]]] = {
1849 "reddit": reddit_search.unrecognized_body,
1850 }
1851
1852 DEFAULT_PROBE_TIMEOUT_SECONDS = 10
1853
1854
1855 def probe_timeout_seconds(config: Dict[str, Any]) -> int:
1856 """Per-source probe deadline; process env > config > default 10s."""
1857 raw: Any = os.environ.get("LAST30DAYS_DOCTOR_PROBE_TIMEOUT")
1858 if raw is None:
1859 raw = (config or {}).get("LAST30DAYS_DOCTOR_PROBE_TIMEOUT")
1860 if raw is None or raw == "":
1861 return DEFAULT_PROBE_TIMEOUT_SECONDS
1862 try:
1863 return max(1, int(raw))
1864 except (TypeError, ValueError):
1865 return DEFAULT_PROBE_TIMEOUT_SECONDS
1866
1867
1868 def _probeable_sources() -> tuple:
1869 """Sources doctor will live-probe: free HTTP endpoints + keyless CLIs.
1870
1871 github is HTTP-probed (its REST tier works without gh), so it is excluded
1872 from the CLI-probe path even though gh is in CLI_DEPENDENCIES.
1873 """
1874 cli_only = [s for s in CLI_DEPENDENCIES if s not in _HTTP_PROBE_URLS]
1875 return tuple(dict.fromkeys(list(_HTTP_PROBE_URLS) + cli_only))
1876
1877
1878 def _http_ok(
1879 url: str,
1880 timeout: float,
1881 *,
1882 blocked_statuses: frozenset = frozenset(),
1883 headers: Optional[Dict[str, str]] = None,
1884 body_check: Optional[Callable[[str], Optional[str]]] = None,
1885 ) -> tuple:
1886 """Reachability check: a 4xx still means the endpoint responded; 5xx or a
1887 connection/timeout error means it did not.
1888
1889 ``blocked_statuses`` names the per-source codes that mean "responded, but
1890 refused us" (Reddit's keyless 403/429) — those are a failure, not
1891 reachability. ``headers`` overrides the probe identity so a source can be
1892 probed with the same User-Agent its lane sends. ``body_check`` validates
1893 a 2xx body; a non-None reason fails the probe as blocked.
1894 """
1895 def _verdict(code: int) -> tuple:
1896 return code < 500 and code not in blocked_statuses, f"HTTP {code}"
1897
1898 try:
1899 req = urllib.request.Request(
1900 url, headers=headers or {"User-Agent": "last30days-doctor"}
1901 )
1902 with urllib.request.urlopen(req, timeout=timeout) as resp:
1903 code = getattr(resp, "status", 200) or 200
1904 if body_check is not None and code < 300:
1905 text = resp.read().decode("utf-8", errors="replace")
1906 reason = body_check(text)
1907 if reason:
1908 return False, f"HTTP {code} but blocked: {reason}"
1909 return _verdict(code)
1910 except urllib.error.HTTPError as exc:
1911 return _verdict(exc.code)
1912 except Exception as exc:
1913 return False, f"{type(exc).__name__}: {exc}"
1914
1915
1916 def _transient_probe_detail(name: str, detail: str) -> bool:
1917 """True when ``detail`` is a status this source may refuse us transiently.
1918
1919 ``_http_ok`` renders its verdict as ``HTTP {code}``; both live in this module,
1920 so matching that shape here keeps the retry rule next to the codes it covers.
1921 """
1922 transient = _PROBE_TRANSIENT_STATUSES.get(name)
1923 if not transient:
1924 return False
1925 return any(detail == f"HTTP {code}" for code in transient)
1926
1927
1928 def _probe_source(name: str, config: Dict[str, Any], timeout: float) -> Optional[Dict[str, Any]]:
1929 url = _HTTP_PROBE_URLS.get(name)
1930 if url:
1931 blocked = _PROBE_BLOCKED_STATUSES.get(name, frozenset())
1932 headers = _PROBE_HEADERS.get(name)
1933 probe_kwargs = {
1934 "blocked_statuses": blocked,
1935 "headers": headers,
1936 "body_check": _PROBE_BODY_CHECKS.get(name),
1937 }
1938 ok, detail = _http_ok(url, timeout, **probe_kwargs)
1939 if not ok and _transient_probe_detail(name, detail):
1940 time.sleep(_PROBE_RETRY_DELAY_SECONDS)
1941 ok, detail = _http_ok(url, timeout, **probe_kwargs)
1942 if not ok and _transient_probe_detail(name, detail):
1943 return {
1944 "ok": False,
1945 "transient": True,
1946 "detail": f"{detail} (rate-limited twice; the lane retries with backoff)",
1947 "probed": True,
1948 }
1949 return {"ok": ok, "detail": detail, "probed": True}
1950 cli = CLI_DEPENDENCIES.get(name)
1951 if cli:
1952 try:
1953 probe = health.probe_dependency(cli)
1954 except Exception as exc:
1955 return {"ok": False, "detail": f"{type(exc).__name__}: {exc}", "probed": True}
1956 return {"ok": bool(probe.ok), "detail": probe.detail, "probed": True}
1957 return None
1958
1959
1960 def _probe_sources(config: Dict[str, Any], timeout: int) -> Dict[str, Dict[str, Any]]:
1961 """Probe the probeable sources concurrently, each capped at ``timeout``.
1962
1963 A source that blows its deadline resolves to a probe-failure for that
1964 source only (never a hung command); other probes are unaffected.
1965 """
1966 names = _probeable_sources()
1967 results: Dict[str, Dict[str, Any]] = {}
1968 with concurrent.futures.ThreadPoolExecutor(
1969 max_workers=min(8, len(names) or 1)
1970 ) as pool:
1971 futures = {
1972 name: pool.submit(_probe_source, name, config, timeout) for name in names
1973 }
1974 for name, fut in futures.items():
1975 try:
1976 res = fut.result(timeout=timeout + 1)
1977 except concurrent.futures.TimeoutError:
1978 res = {"ok": False, "detail": "probe exceeded deadline", "probed": True}
1979 except Exception as exc:
1980 res = {
1981 "ok": False,
1982 "detail": f"{type(exc).__name__}: {exc}",
1983 "probed": True,
1984 }
1985 if res is not None:
1986 results[name] = res
1987 return results
1988
1989
1990 def _apply_probe(report: Dict[str, Any], probe_results: Dict[str, Dict[str, Any]]) -> None:
1991 """Attach probe results and re-derive audit_state for probed sources."""
1992 for name, res in probe_results.items():
1993 record = (report.get("sources") or {}).get(name)
1994 if record is None:
1995 continue
1996 record["probe"] = res
1997 record["audit_state"] = audit_state(
1998 name, record, record.get("run_outcome"), res
1999 )
2000
2001
2002 def run(
2003 config: Dict[str, Any],
2004 *,
2005 emit_json: bool = False,
2006 cached: bool = False,
2007 postmortem: bool = False,
2008 probe: bool = False,
2009 ) -> int:
2010 """Build (or serve the cached) doctor report and print it. Always exits 0
2011 (reporting problems is a successful run).
2012
2013 ``postmortem=True`` reads the last run's per-source outcomes (any age) and
2014 reports what broke, instead of predicting config health.
2015 ``probe=True`` (or no fresh run) runs a bounded live probe (U5) so WORKING
2016 is verified, not guessed.
2017 ``cached=True`` serves the stored report within the TTL; stale, absent,
2018 corrupt, schema-mismatched, or fingerprint-mismatched caches fall
2019 through to a live run that rewrites the cache — as does ANY exception
2020 raised while serving the cache (never-crash contract, KTD 8).
2021 ``cached=False`` (explicit ``doctor``) always runs live and refreshes.
2022 """
2023 if postmortem:
2024 pm = build_postmortem(config)
2025 if emit_json:
2026 print(json.dumps(pm, indent=2, sort_keys=True))
2027 else:
2028 print(render_postmortem_text(pm), end="")
2029 return 0
2030
2031 def _emit(report: Dict[str, Any]) -> None:
2032 if emit_json:
2033 # generated_at/from_cache ride the report dict, so they appear
2034 # at the JSON top level for free.
2035 print(render_json(report))
2036 else:
2037 # The cache-status line is printed here (run() owns this print)
2038 # because render_text's header belongs to the render layer, not
2039 # the cache layer.
2040 origin = "cached" if report.get("from_cache") else "live"
2041 print(render_text(report), end="")
2042 print(f"generated: {report.get('generated_at')} ({origin})")
2043
2044 if cached:
2045 try:
2046 cached_report = read_cached_report(config)
2047 if cached_report is not None:
2048 _emit(cached_report)
2049 return 0
2050 except Exception:
2051 # Belt-and-suspenders for shapes the validator misses: any
2052 # failure serving the cache falls through to a live run.
2053 pass
2054 report = build_report(config)
2055 report["generated_at"] = datetime.datetime.now(datetime.timezone.utc).isoformat()
2056 report["from_cache"] = False
2057
2058 # U5: verify WORKING with a bounded live probe when asked (--probe) or when
2059 # there is no fresh run to learn from ("if no recent runs, run a live
2060 # test"). Scoped to free/CLI sources; credit-gated sources stay UNVERIFIED.
2061 fresh_run = bool((report.get("run_evidence") or {}).get("fresh"))
2062 if probe or not fresh_run:
2063 timeout = probe_timeout_seconds(config)
2064 probeable = _probeable_sources()
2065 sys.stderr.write(
2066 f"[last30days] doctor live probe: checking {len(probeable)} free/CLI "
2067 f"sources ({timeout}s each; no credit-gated sources - x/tiktok/"
2068 f"instagram/threads stay unverified)\n"
2069 )
2070 sys.stderr.flush()
2071 try:
2072 probe_results = _probe_sources(config, timeout)
2073 except Exception:
2074 probe_results = {}
2075 _apply_probe(report, probe_results)
2076 report["mode"] = "probe"
2077 report["probe"] = {"ran": True, "timeout": timeout, "sources": list(probeable)}
2078
2079 _write_cache(report, config)
2080 _emit(report)
2081 return 0
2082
2082 lines PYTHON