返回 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, 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 backups.append({
1125 "name": "ScrapeCreators backfill", "armed": has_sc,
1126 "note": "fills in when the free public path returns nothing",
1127 })
1128 elif source == "youtube":
1129 backups.append({
1130 "name": "ScrapeCreators transcript/search backstop", "armed": has_sc,
1131 "note": "used when yt-dlp is rate-limited or bot-gated",
1132 })
1133 comments = {"enabled": bool(env.is_youtube_comments_available(config))}
1134 elif source == "x":
1135 backups.append(_x_auth_path(config))
1136 elif source == "tiktok":
1137 comments = {"enabled": bool(env.is_tiktok_comments_available(config))}
1138 elif source == "instagram":
1139 comments = {"enabled": bool(env.is_instagram_comments_available(config))}
1140 return backups, comments
1141
1142
1143 # ---------------------------------------------------------------------------
1144 # Aggregation
1145 # ---------------------------------------------------------------------------
1146
1147 def _engine_version() -> str:
1148 try:
1149 from . import render
1150
1151 version = render._skill_version()
1152 except Exception:
1153 version = None
1154 # render's parent-walking helper falls back to "?"; doctor says "unknown".
1155 if not version or version == "?":
1156 return "unknown"
1157 return version
1158
1159
1160 def _setup_block(config: Dict[str, Any]) -> Dict[str, Any]:
1161 keys_present = {var: bool(config.get(var)) for var in KEY_PRESENCE_VARS}
1162 keys_present["x_browser_cookies"] = bool(
1163 config.get("AUTH_TOKEN") and config.get("CT0")
1164 )
1165 keys_present["bluesky_app_password"] = bool(
1166 config.get("BSKY_HANDLE") and config.get("BSKY_APP_PASSWORD")
1167 )
1168 return {
1169 "setup_complete": env.is_setup_complete(config),
1170 "keys_present": keys_present,
1171 # get_config() rejected these before any presence check ran, so
1172 # keys_present already reads them as absent. Carrying the names here is
1173 # what lets the text renderer say *why* they are absent instead of
1174 # leaving the user to read "credentials present: none". Only keys left
1175 # unset are listed: one whose placeholder fell through to a real
1176 # lower-priority credential is configured and does not appear.
1177 "unsubstituted_templates": env.templated_config_keys(config),
1178 }
1179
1180
1181 def _permissions_block(config: Dict[str, Any]) -> Dict[str, Any]:
1182 """Secret-free permission summary via the existing preflight provider."""
1183 from . import pipeline
1184
1185 diag = pipeline.diagnose(config, None, safe=True)
1186 return diag["permission_preflight"]
1187
1188
1189 def build_report(config: Dict[str, Any]) -> Dict[str, Any]:
1190 """Aggregate every health provider into one report dict.
1191
1192 Per-source exceptions are isolated: a failing builder yields an
1193 ``error`` record for that source and the rest of the report survives.
1194 """
1195 def _build_one(name: str) -> Dict[str, Any]:
1196 try:
1197 return _SOURCE_BUILDERS[name](config)
1198 except Exception as exc: # one bad probe must not blank the report
1199 return _record(
1200 status=health.ERROR,
1201 detail=f"probe failed: {type(exc).__name__}: {exc}",
1202 fix=_fix_text(prescriptions.get(name, "probe_error")),
1203 )
1204
1205 # Builders are independent probes (subprocess/filesystem bound), so run
1206 # them concurrently. ``pool.map`` preserves SOURCE_ORDER, keeping the
1207 # sources dict insertion order — and render grouping — deterministic.
1208 with concurrent.futures.ThreadPoolExecutor(
1209 max_workers=min(8, len(SOURCE_ORDER))
1210 ) as pool:
1211 sources: Dict[str, Dict[str, Any]] = dict(
1212 zip(SOURCE_ORDER, pool.map(_build_one, SOURCE_ORDER))
1213 )
1214
1215 # U1: overlay the last research run's per-source outcome onto each record
1216 # so the audit layer can tell "configured" from "actually working".
1217 # U2: derive each record's four-state audit bucket (probe evidence, when a
1218 # live probe runs, is layered on in run() before render).
1219 evidence = load_run_evidence(config)
1220 for source, record in sources.items():
1221 record["run_outcome"] = evidence["outcomes"].get(source) if evidence["fresh"] else None
1222 record["audit_state"] = audit_state(source, record, record["run_outcome"])
1223
1224 # U3: annotate each CLI-dependent source with its binary's health so the
1225 # per-source marker and the dedicated CLI-health block can render (R2).
1226 for source, cli_name in CLI_DEPENDENCIES.items():
1227 record = sources.get(source)
1228 if record is None:
1229 continue
1230 try:
1231 probe = health.probe_dependency(cli_name)
1232 except Exception:
1233 continue
1234 record["cli"] = {
1235 "name": cli_name,
1236 "status": probe.status,
1237 "off_path": bool(getattr(probe, "off_path", False)),
1238 "detail": probe.detail,
1239 "optional": source in _OPTIONAL_CLI_SOURCES,
1240 }
1241
1242 # U7: attach backup + comment sub-lanes to their parent source.
1243 for source, record in sources.items():
1244 backups, comments = _sub_lanes_for(source, config)
1245 if backups:
1246 record["backups"] = backups
1247 if comments is not None:
1248 record["comments"] = comments
1249
1250 # Sequential on purpose: the permission preflight composes pipeline
1251 # diagnostics and must not race the source builders.
1252 try:
1253 permissions = _permissions_block(config)
1254 except Exception as exc:
1255 permissions = {"status": "unavailable", "error": f"{type(exc).__name__}: {exc}"}
1256
1257 return {
1258 "engine_version": _engine_version(),
1259 "config": {
1260 "global_env": str(env.CONFIG_FILE) if env.CONFIG_FILE else None,
1261 "config_source": config.get("_CONFIG_SOURCE"),
1262 # The resolved host value, so a missing export is visible.
1263 "host": env.x_policy(config).host or None,
1264 # A .env line cannot declare the X connector lane.
1265 "host_lane_file_ignored": bool(config.get("_X_HOST_LANE_FILE_IGNORED")),
1266 },
1267 "setup": _setup_block(config),
1268 "permissions": permissions,
1269 "sources": sources,
1270 "mode": "config",
1271 "run_evidence": {
1272 "present": evidence["present"],
1273 "fresh": evidence["fresh"],
1274 "topic": evidence["topic"],
1275 "at": evidence["at"],
1276 },
1277 }
1278
1279
1280 # ---------------------------------------------------------------------------
1281 # Renderers
1282 # ---------------------------------------------------------------------------
1283
1284 def render_json(report: Dict[str, Any]) -> str:
1285 return json.dumps(report, indent=2, sort_keys=True)
1286
1287
1288 def _cli_marker(record: Dict[str, Any]) -> str:
1289 """Inline `[CLI: name ✓]` / `[keyless]` marker (populated by U3)."""
1290 cli = record.get("cli")
1291 if not cli:
1292 return ""
1293 name = cli.get("name")
1294 if cli.get("status") == health.OK:
1295 return f" [CLI: {name} ✓]"
1296 if cli.get("off_path"):
1297 return f" [CLI: {name} ✗ off-PATH]"
1298 return f" [CLI: {name} ✗ {cli.get('status')}]"
1299
1300
1301 def _run_evidence_suffix(record: Dict[str, Any], state: str) -> str:
1302 """Last-run outcome tail for a source line (R4)."""
1303 ro = record.get("run_outcome")
1304 if not ro:
1305 if state == AUDIT_UNVERIFIED:
1306 return " [no recent run]"
1307 return ""
1308 st = ro.get("state")
1309 count = ro.get("items_returned") or 0
1310 detail = ro.get("detail")
1311 if st in _RUN_WORKING_STATES:
1312 if count:
1313 return f" [✓ {count} items last run]"
1314 return " [✓ ran clean, 0 matches last run]"
1315 if st == health.PARTIAL:
1316 tail = f" ({detail})" if detail else ""
1317 return f" [⚠ partial last run{tail}]"
1318 tail = detail or st
1319 return f" [✕ {tail} last run]"
1320
1321
1322 def _audit_source_line(name: str, record: Dict[str, Any], state: str) -> str:
1323 glyph = AUDIT_GLYPHS.get(state, "?")
1324 parts = [f" {glyph} {name}{_cli_marker(record)}"]
1325 descriptors: List[str] = []
1326 if record.get("status") not in (health.OK,):
1327 descriptors.append(record["status"])
1328 if record.get("note"):
1329 descriptors.append(record["note"])
1330 elif record.get("detail") and record.get("tier") != TIER_OK:
1331 descriptors.append(record["detail"])
1332 if descriptors:
1333 parts.append(" — " + "; ".join(descriptors))
1334 evidence = _run_evidence_suffix(record, state)
1335 if evidence:
1336 parts.append(evidence)
1337 # fix is only ever populated when there is something actionable, so
1338 # render it whenever present — an ok-tier record can carry one (the
1339 # youtube transcription-key note) and must not lose it in text mode.
1340 if record.get("fix"):
1341 parts.append(f"; fix: {record['fix']}")
1342 # Backup / comment sub-lanes render on their own indented lines (U7),
1343 # after the primary line (with its fix) is complete.
1344 for sub in _sub_lane_lines(record):
1345 parts.append("\n" + sub)
1346 return "".join(parts)
1347
1348
1349 def _sub_lane_lines(record: Dict[str, Any]) -> List[str]:
1350 """Indented backup/comment sub-lane lines under a source (R8, R9)."""
1351 lines: List[str] = []
1352 for backup in record.get("backups") or []:
1353 state = "armed" if backup.get("armed") else "off"
1354 note = f" - {backup['note']}" if backup.get("note") else ""
1355 lines.append(f" backup: {backup['name']} — {state}{note}")
1356 comments = record.get("comments")
1357 if comments is not None:
1358 state = "on" if comments.get("enabled") else "off"
1359 lines.append(f" comments: {state}")
1360 return lines
1361
1362
1363 def _cli_health_lines(report: Dict[str, Any]) -> List[str]:
1364 """Dedicated CLI-health block (R2): one row per CLI-dependent source,
1365 plus a note naming the keyless sources that need no CLI at all.
1366 """
1367 sources = report.get("sources") or {}
1368 rows: List[str] = []
1369 for source in SOURCE_ORDER:
1370 cli = (sources.get(source) or {}).get("cli")
1371 if not cli:
1372 continue
1373 ok = cli.get("status") == health.OK
1374 glyph = "✓" if ok else "✗"
1375 detail = cli.get("detail") or cli.get("status")
1376 tail = ""
1377 if not ok:
1378 if cli.get("off_path"):
1379 tail = " (installed off-PATH)"
1380 elif cli.get("optional"):
1381 tail = " (optional)"
1382 rows.append(f" {glyph} {cli['name']} — {source}{tail}: {detail}")
1383 if not rows:
1384 return []
1385 return (
1386 ["CLI health (downloaded binaries):"]
1387 + rows
1388 + [" · Reddit, Hacker News, Polymarket need no CLI (keyless)"]
1389 )
1390
1391
1392 def render_text(report: Dict[str, Any]) -> str:
1393 lines: List[str] = [f"last30days doctor — engine v{report['engine_version']}"]
1394 config_block = report.get("config") or {}
1395 if config_block.get("global_env"):
1396 line = f"config: {config_block['global_env']}"
1397 if config_block.get("config_source"):
1398 line += f" (source: {config_block['config_source']})"
1399 lines.append(line)
1400 host = config_block.get("host")
1401 if host:
1402 lines.append(f"host: {host} ({env.X_HOST_VAR})")
1403 else:
1404 lines.append(f"host: not set ({env.X_HOST_VAR} unset; default X policy)")
1405 if config_block.get("host_lane_file_ignored"):
1406 lines.append(
1407 f"note: a {env.X_HOST_LANE_VAR} line in .env cannot declare the X "
1408 "connector lane (ignored); export it in the process environment "
1409 "for the session instead"
1410 )
1411
1412 setup = report.get("setup") or {}
1413 present = sorted(
1414 name for name, is_set in (setup.get("keys_present") or {}).items() if is_set
1415 )
1416 setup_state = "complete" if setup.get("setup_complete") else "not recorded"
1417 lines.append(
1418 f"setup: {setup_state}; credentials present: "
1419 + (", ".join(present) if present else "none")
1420 + " (values never shown)"
1421 )
1422 templated = setup.get("unsubstituted_templates") or []
1423 if templated:
1424 lines.append(
1425 "note: unsubstituted config template(s), counted as unset: "
1426 + ", ".join(templated)
1427 + " (set a real value or remove each)"
1428 )
1429
1430 permissions = report.get("permissions") or {}
1431 if permissions.get("status"):
1432 browser = ((permissions.get("local_reads") or {}).get("browser_cookies") or {})
1433 lines.append(
1434 f"permissions: {permissions['status']}"
1435 + (f"; browser cookies: {browser.get('status')}" if browser else "")
1436 )
1437
1438 run_ev = report.get("run_evidence") or {}
1439 if run_ev.get("present") and run_ev.get("fresh"):
1440 topic = run_ev.get("topic") or "last run"
1441 lines.append(
1442 f"last run: {topic} - overlaying actual source outcomes below"
1443 )
1444 elif run_ev.get("present"):
1445 lines.append(
1446 "last run: found but stale - run `doctor --postmortem` to inspect it"
1447 )
1448
1449 grouped: Dict[str, List[str]] = {state: [] for state, _ in AUDIT_GROUPS}
1450 for name, record in (report.get("sources") or {}).items():
1451 state = record.get("audit_state") or audit_state(
1452 name, record, record.get("run_outcome")
1453 )
1454 grouped.setdefault(state, []).append(_audit_source_line(name, record, state))
1455
1456 for state, header in AUDIT_GROUPS:
1457 entries = grouped.get(state) or []
1458 lines.append("")
1459 lines.append(f"{AUDIT_GLYPHS.get(state, '')} {header}:")
1460 if entries:
1461 lines.extend(entries)
1462 else:
1463 lines.append(" (none)")
1464
1465 cli_block = _cli_health_lines(report)
1466 if cli_block:
1467 lines.append("")
1468 lines.extend(cli_block)
1469
1470 lines.append("")
1471 lines.append(
1472 "doctor reports problems without failing; run the printed fixes, "
1473 "then re-run doctor"
1474 )
1475 return "\n".join(lines) + "\n"
1476
1477
1478 # ---------------------------------------------------------------------------
1479 # Post-mortem (U4 / R5): what actually happened on the last run
1480 #
1481 # Unlike plain doctor (config prediction), --postmortem reads the last run's
1482 # per-source SourceOutcome and reports what broke, at any age (labeled). It is
1483 # a reader of the same last-report.json the overlay uses - no new persistence.
1484 # ---------------------------------------------------------------------------
1485
1486 def _age_label(iso: Any) -> str:
1487 if not iso:
1488 return ""
1489 try:
1490 ts = datetime.datetime.fromisoformat(iso)
1491 except (TypeError, ValueError):
1492 return ""
1493 if ts.tzinfo is None:
1494 ts = ts.replace(tzinfo=datetime.timezone.utc)
1495 secs = int(
1496 (datetime.datetime.now(datetime.timezone.utc) - ts).total_seconds()
1497 )
1498 if secs < 0:
1499 return ""
1500 if secs < 3600:
1501 return f"{secs // 60}m ago"
1502 if secs < 86400:
1503 return f"{secs // 3600}h ago"
1504 return f"{secs // 86400}d ago"
1505
1506
1507 def build_postmortem(config: Dict[str, Any]) -> Dict[str, Any]:
1508 """Assemble the last run's per-source outcomes (any age) for --postmortem."""
1509 evidence = load_run_evidence(config)
1510 return {
1511 "engine_version": _engine_version(),
1512 "mode": "postmortem",
1513 "present": evidence["present"],
1514 "topic": evidence["topic"],
1515 "at": evidence["at"],
1516 "outcomes": evidence["outcomes"],
1517 }
1518
1519
1520 def _postmortem_state_label(source: str, outcome: Dict[str, Any]) -> str:
1521 """State label for a failed post-mortem line.
1522
1523 ``payment-required`` reads as "credits exhausted" ("X API credits
1524 exhausted" for X) so the fix is obvious at a glance: top up, not re-login.
1525 Every other state prints as its raw name.
1526 """
1527 state = str(outcome.get("state") or "")
1528 if state == health.PAYMENT_REQUIRED:
1529 return health.credits_exhausted_label(source)
1530 return state
1531
1532
1533 def render_postmortem_text(pm: Dict[str, Any]) -> str:
1534 lines = [f"last30days post-mortem — engine v{pm['engine_version']}"]
1535 if not pm.get("present"):
1536 lines.append("")
1537 lines.append(
1538 "No saved run found - run `/last30days <topic>` first, or "
1539 "`doctor --probe` for a live check."
1540 )
1541 return "\n".join(lines) + "\n"
1542 topic = pm.get("topic") or "last run"
1543 age = _age_label(pm.get("at"))
1544 lines.append(f"last run: {topic}" + (f" ({age})" if age else ""))
1545
1546 failed, partial, succeeded, skipped = [], [], [], []
1547 for source, outcome in (pm.get("outcomes") or {}).items():
1548 state = outcome.get("state")
1549 if state in _RUN_WORKING_STATES:
1550 succeeded.append((source, outcome))
1551 elif state == health.PARTIAL:
1552 partial.append((source, outcome))
1553 elif state == health.SKIPPED_UNCONFIGURED:
1554 skipped.append((source, outcome))
1555 else:
1556 failed.append((source, outcome))
1557
1558 if failed:
1559 lines.append("")
1560 lines.append("Failed:")
1561 for source, outcome in failed:
1562 detail = outcome.get("detail") or outcome.get("state")
1563 lines.append(f" ✕ {source} — {_postmortem_state_label(source, outcome)}: {detail}")
1564 if outcome.get("fix_hint"):
1565 lines.append(f" fix: {outcome['fix_hint']}")
1566 if partial:
1567 lines.append("")
1568 lines.append("Partial:")
1569 for source, outcome in partial:
1570 count = outcome.get("items_returned") or 0
1571 detail = outcome.get("detail")
1572 tail = f" — {detail}" if detail else ""
1573 lines.append(f" ⚠ {source} ({count} items){tail}")
1574 if outcome.get("fix_hint"):
1575 lines.append(f" fix: {outcome['fix_hint']}")
1576 if succeeded:
1577 lines.append("")
1578 def _succeeded_label(source: str, outcome: Dict[str, Any]) -> str:
1579 count = outcome.get("items_returned") or 0
1580 detail = outcome.get("detail")
1581 if detail:
1582 noun = "item" if count == 1 else "items"
1583 return f"{source} ({count} {noun}; {detail})"
1584 return f"{source} ({count})"
1585
1586 names = ", ".join(_succeeded_label(s, o) for s, o in succeeded)
1587 lines.append(f"Succeeded: {names}")
1588 if skipped:
1589 lines.append("")
1590 lines.append(
1591 "Skipped (not configured): " + ", ".join(s for s, _ in skipped)
1592 )
1593 if not failed and not partial:
1594 lines.append("")
1595 lines.append("No failures on the last run.")
1596 return "\n".join(lines) + "\n"
1597
1598
1599 # ---------------------------------------------------------------------------
1600 # Cross-invocation cache (U5 / R5, KTD 8)
1601 #
1602 # Doctor writes its JSON result beside the existing ``last-run.json``
1603 # convention (env.CONFIG_DIR) so the SKILL.md standing rule's pre-research
1604 # check costs one file read on the healthy path instead of a dozen probe
1605 # subprocesses. ``--cached`` serves the stored report within the TTL and
1606 # falls through to a live run (rewriting the cache) when the file is stale,
1607 # absent, or corrupt — corruption is treated as absence, never a crash.
1608 # An explicit ``doctor`` (no ``--cached``) always runs live and refreshes.
1609 #
1610 # The payload carries a schema stamp (mirrors REPORT_CACHE_VERSION in
1611 # last30days.py) and a config fingerprint — a sha256 over the same
1612 # non-secret signals doctor already reports (key-presence booleans, backend
1613 # pin values, INCLUDE_SOURCES). A schema or fingerprint mismatch is treated
1614 # as stale, so a credential or pin change can never serve yesterday's
1615 # conclusions. Served reports carry ``from_cache`` + ``generated_at`` so
1616 # consumers can see staleness instead of inferring it.
1617 # ---------------------------------------------------------------------------
1618
1619 CACHE_FILENAME = "doctor-cache.json"
1620
1621 # Bump when the cached payload/report shape changes incompatibly; a
1622 # mismatched (or absent) stamp is treated as an absent cache.
1623 DOCTOR_CACHE_SCHEMA_VERSION = "last30days-doctor-cache/v1"
1624
1625 # TTL in SECONDS (env-tunable via LAST30DAYS_DOCTOR_TTL; registered in
1626 # lib/env.py's get_config key list so a .env-set value is not swallowed).
1627 DEFAULT_CACHE_TTL_SECONDS = 900
1628
1629 # Config vars whose values must never land in the cache file. Doctor output
1630 # carries no secrets by design (key presence is booleans only); this belt-and-
1631 # suspenders check refuses to persist the cache if a seeded value ever leaks.
1632 _SECRET_CONFIG_VARS = KEY_PRESENCE_VARS + (
1633 "AUTH_TOKEN", "CT0", "APIFY_API_TOKEN", "GOOGLE_GENAI_API_KEY",
1634 )
1635
1636 # Backend pin vars folded into the config fingerprint. Pin values are
1637 # backend names (e.g. "bird"), never secrets. The host key and the X
1638 # connector lane signal join them: both are non-secret switches that
1639 # change every X conclusion, so a cached report must not outlive them.
1640 _FINGERPRINT_PIN_VARS = (
1641 env.X_BACKEND_PIN_VAR,
1642 env.REDDIT_BACKEND_PIN_VAR,
1643 env.X_HOST_VAR,
1644 env.X_HOST_LANE_VAR,
1645 )
1646
1647 # Top-level report keys the renderers read unguarded; a cached report
1648 # missing any of them is treated as corrupt (absent), never rendered.
1649 _REQUIRED_REPORT_KEYS = ("engine_version", "config", "setup", "permissions", "sources")
1650
1651
1652 def cache_path() -> Optional[Path]:
1653 """The doctor cache file, beside last-run.json (None in clean mode)."""
1654 if env.CONFIG_DIR is None:
1655 return None
1656 return env.CONFIG_DIR / CACHE_FILENAME
1657
1658
1659 def cache_ttl_seconds(config: Dict[str, Any]) -> int:
1660 """LAST30DAYS_DOCTOR_TTL in seconds; process env > config; default 900."""
1661 raw: Any = os.environ.get("LAST30DAYS_DOCTOR_TTL")
1662 if raw is None:
1663 raw = (config or {}).get("LAST30DAYS_DOCTOR_TTL")
1664 if raw is None or raw == "":
1665 return DEFAULT_CACHE_TTL_SECONDS
1666 try:
1667 return max(0, int(raw))
1668 except (TypeError, ValueError):
1669 return DEFAULT_CACHE_TTL_SECONDS
1670
1671
1672 def _is_fresh(timestamp: Any, ttl_seconds: int) -> bool:
1673 return env.is_timestamp_fresh(timestamp, ttl_seconds)
1674
1675
1676 def _config_fingerprint(config: Dict[str, Any]) -> str:
1677 """sha256 over the non-secret config signals doctor already reports.
1678
1679 Inputs are key-presence BOOLEANS (never credential values — the same
1680 ``keys_present`` set the setup block renders), backend pin values
1681 (backend names, not secrets), and INCLUDE_SOURCES (not a secret).
1682 Adding or removing a credential, changing a pin, or toggling an opt-in
1683 source yields a new fingerprint, so ``read_cached_report`` treats the
1684 old cache as stale instead of serving pre-change conclusions.
1685 """
1686 config = config or {}
1687 signals = {
1688 "keys_present": _setup_block(config)["keys_present"],
1689 "pins": {var: str(config.get(var) or "") for var in _FINGERPRINT_PIN_VARS},
1690 "include_sources": str(config.get("INCLUDE_SOURCES") or ""),
1691 }
1692 canonical = json.dumps(signals, sort_keys=True, separators=(",", ":"))
1693 return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
1694
1695
1696 def _report_shape_ok(report: Any) -> bool:
1697 """True when a cached report satisfies the render contract.
1698
1699 Validates everything the renderers read unguarded: the required
1700 top-level keys exist (dict-valued where render calls ``.get`` on them),
1701 and every sources record is a dict carrying a known tier and a str
1702 status. Anything else is corrupt — treated as absent, never rendered.
1703 """
1704 if not isinstance(report, dict):
1705 return False
1706 if any(key not in report for key in _REQUIRED_REPORT_KEYS):
1707 return False
1708 if any(
1709 not isinstance(report[key], dict)
1710 for key in ("config", "setup", "permissions", "sources")
1711 ):
1712 return False
1713 sources = report["sources"]
1714 if not sources:
1715 return False
1716 for record in sources.values():
1717 if not isinstance(record, dict):
1718 return False
1719 if record.get("tier") not in GLYPHS:
1720 return False
1721 if not isinstance(record.get("status"), str):
1722 return False
1723 return True
1724
1725
1726 def read_cached_report(config: Dict[str, Any]) -> Optional[Dict[str, Any]]:
1727 """Return the cached report when present, well-formed, and within TTL.
1728
1729 Any failure mode — unreadable file, invalid JSON, schema mismatch,
1730 config-fingerprint mismatch, wrong shape, bad or stale timestamp —
1731 returns None (cache treated as absent, never a crash).
1732
1733 A served report is stamped with ``from_cache: True`` and
1734 ``generated_at`` (the cache write time) so consumers see staleness.
1735 """
1736 path = cache_path()
1737 if path is None:
1738 return None
1739 try:
1740 payload = json.loads(path.read_text(encoding="utf-8"))
1741 except Exception:
1742 return None
1743 if not isinstance(payload, dict):
1744 return None
1745 if payload.get("schema") != DOCTOR_CACHE_SCHEMA_VERSION:
1746 return None # absent or mismatched schema stamp: treat as absent
1747 if payload.get("fingerprint") != _config_fingerprint(config):
1748 return None # credentials/pins/opt-ins changed: cache is stale
1749 report = payload.get("report")
1750 if not _report_shape_ok(report):
1751 return None
1752 if not _is_fresh(payload.get("timestamp"), cache_ttl_seconds(config)):
1753 return None
1754 report["generated_at"] = payload.get("timestamp")
1755 report["from_cache"] = True
1756 return report
1757
1758
1759 def _write_cache(report: Dict[str, Any], config: Dict[str, Any]) -> bool:
1760 """Best-effort cache write; refuses to persist any secret value.
1761
1762 Never fatal: any failure returns False after a one-line stderr warning
1763 (doctor's exit-0 contract is unaffected; only ``--cached`` reuse is).
1764 """
1765 try:
1766 path = cache_path()
1767 if path is None:
1768 return False
1769 payload = {
1770 "schema": DOCTOR_CACHE_SCHEMA_VERSION,
1771 "fingerprint": _config_fingerprint(config),
1772 "timestamp": report.get("generated_at")
1773 or datetime.datetime.now(datetime.timezone.utc).isoformat(),
1774 "report": report,
1775 }
1776 raw = json.dumps(payload, indent=2, sort_keys=True)
1777 for var in _SECRET_CONFIG_VARS:
1778 value = (config or {}).get(var)
1779 if isinstance(value, str) and value and value in raw:
1780 return False # never write a cache containing a secret
1781 path.parent.mkdir(parents=True, exist_ok=True)
1782 path.write_text(raw, encoding="utf-8")
1783 return True
1784 except Exception as exc:
1785 sys.stderr.write(
1786 f"[last30days] WARNING: could not write doctor cache: "
1787 f"{type(exc).__name__}: {exc}\n"
1788 )
1789 sys.stderr.flush()
1790 return False
1791
1792
1793 # ---------------------------------------------------------------------------
1794 # Live probe (U5 / R6)
1795 #
1796 # When there is no fresh run to learn from (or on explicit --probe), doctor
1797 # runs a BOUNDED live test so WORKING is verified, not guessed. Scope is
1798 # deliberate: free HTTP endpoints + keyless CLIs only. Credit-gated /
1799 # session-gated sources (x, tiktok, instagram, threads, ...) are NOT
1800 # live-probed - a health check must never spend ScrapeCreators credits or trip
1801 # auth rate limits; they stay UNVERIFIED with that noted. Every probe is capped
1802 # by a per-source deadline so a single slow source (YouTube's 120s search) can
1803 # never hang doctor.
1804 # ---------------------------------------------------------------------------
1805
1806 # Free, keyless liveness endpoints (reachability check, tiny payload).
1807 _HTTP_PROBE_URLS = {
1808 # The keyless engine's real discovery endpoint (reddit_rss._build_urls).
1809 # /r/all/hot.json is permanently 403 keyless (see the reddit_keyless module
1810 # docstring) and no lane requests it any more, so probing it measured an
1811 # endpoint the engine had already abandoned.
1812 "reddit": "https://www.reddit.com/search.rss?q=test&sort=relevance&t=month",
1813 "hackernews": "https://hn.algolia.com/api/v1/search?query=test&hitsPerPage=1",
1814 "polymarket": "https://gamma-api.polymarket.com/events?limit=1",
1815 "github": "https://api.github.com/rate_limit",
1816 }
1817
1818 # Per-source exception to "a 4xx still means the endpoint responded". The
1819 # keyless Reddit lanes send no credentials, so a 403/429 there is the host
1820 # refusing this client — the exact failure the engine hits — not reachability.
1821 _PROBE_BLOCKED_STATUSES = {"reddit": frozenset({403, 429})}
1822
1823 # Blocked statuses that mean "refused this probe right now", not "the source is
1824 # down". Reddit answers a burst of keyless probes with 429 while the research
1825 # lane — which retries with backoff across several endpoints — serves the same
1826 # query fine. A single unretried 429 was reporting a healthy Reddit as NOT
1827 # WORKING, so these get one retry and, if still refused, downgrade to unverified
1828 # rather than a false outage. 403 stays hard: that is a real keyless block.
1829 _PROBE_TRANSIENT_STATUSES = {"reddit": frozenset({429})}
1830
1831 # One retry only: the probe budget is the per-source deadline, and a source that
1832 # rate-limits twice in a row is worth surfacing as unverified.
1833 _PROBE_RETRY_DELAY_SECONDS = 2.0
1834
1835 # Probe with the identity the lane sends, or the probe measures the User-Agent
1836 # rather than the endpoint (get_text sends http.BROWSER_USER_AGENT).
1837 _PROBE_HEADERS = {
1838 "reddit": {
1839 "User-Agent": http.BROWSER_USER_AGENT,
1840 "Accept": "application/atom+xml",
1841 },
1842 }
1843
1844 DEFAULT_PROBE_TIMEOUT_SECONDS = 10
1845
1846
1847 def probe_timeout_seconds(config: Dict[str, Any]) -> int:
1848 """Per-source probe deadline; process env > config > default 10s."""
1849 raw: Any = os.environ.get("LAST30DAYS_DOCTOR_PROBE_TIMEOUT")
1850 if raw is None:
1851 raw = (config or {}).get("LAST30DAYS_DOCTOR_PROBE_TIMEOUT")
1852 if raw is None or raw == "":
1853 return DEFAULT_PROBE_TIMEOUT_SECONDS
1854 try:
1855 return max(1, int(raw))
1856 except (TypeError, ValueError):
1857 return DEFAULT_PROBE_TIMEOUT_SECONDS
1858
1859
1860 def _probeable_sources() -> tuple:
1861 """Sources doctor will live-probe: free HTTP endpoints + keyless CLIs.
1862
1863 github is HTTP-probed (its REST tier works without gh), so it is excluded
1864 from the CLI-probe path even though gh is in CLI_DEPENDENCIES.
1865 """
1866 cli_only = [s for s in CLI_DEPENDENCIES if s not in _HTTP_PROBE_URLS]
1867 return tuple(dict.fromkeys(list(_HTTP_PROBE_URLS) + cli_only))
1868
1869
1870 def _http_ok(
1871 url: str,
1872 timeout: float,
1873 *,
1874 blocked_statuses: frozenset = frozenset(),
1875 headers: Optional[Dict[str, str]] = None,
1876 ) -> tuple:
1877 """Reachability check: a 4xx still means the endpoint responded; 5xx or a
1878 connection/timeout error means it did not.
1879
1880 ``blocked_statuses`` names the per-source codes that mean "responded, but
1881 refused us" (Reddit's keyless 403/429) — those are a failure, not
1882 reachability. ``headers`` overrides the probe identity so a source can be
1883 probed with the same User-Agent its lane sends.
1884 """
1885 def _verdict(code: int) -> tuple:
1886 return code < 500 and code not in blocked_statuses, f"HTTP {code}"
1887
1888 try:
1889 req = urllib.request.Request(
1890 url, headers=headers or {"User-Agent": "last30days-doctor"}
1891 )
1892 with urllib.request.urlopen(req, timeout=timeout) as resp:
1893 return _verdict(getattr(resp, "status", 200) or 200)
1894 except urllib.error.HTTPError as exc:
1895 return _verdict(exc.code)
1896 except Exception as exc:
1897 return False, f"{type(exc).__name__}: {exc}"
1898
1899
1900 def _transient_probe_detail(name: str, detail: str) -> bool:
1901 """True when ``detail`` is a status this source may refuse us transiently.
1902
1903 ``_http_ok`` renders its verdict as ``HTTP {code}``; both live in this module,
1904 so matching that shape here keeps the retry rule next to the codes it covers.
1905 """
1906 transient = _PROBE_TRANSIENT_STATUSES.get(name)
1907 if not transient:
1908 return False
1909 return any(detail == f"HTTP {code}" for code in transient)
1910
1911
1912 def _probe_source(name: str, config: Dict[str, Any], timeout: float) -> Optional[Dict[str, Any]]:
1913 url = _HTTP_PROBE_URLS.get(name)
1914 if url:
1915 blocked = _PROBE_BLOCKED_STATUSES.get(name, frozenset())
1916 headers = _PROBE_HEADERS.get(name)
1917 ok, detail = _http_ok(url, timeout, blocked_statuses=blocked, headers=headers)
1918 if not ok and _transient_probe_detail(name, detail):
1919 time.sleep(_PROBE_RETRY_DELAY_SECONDS)
1920 ok, detail = _http_ok(url, timeout, blocked_statuses=blocked, headers=headers)
1921 if not ok and _transient_probe_detail(name, detail):
1922 return {
1923 "ok": False,
1924 "transient": True,
1925 "detail": f"{detail} (rate-limited twice; the lane retries with backoff)",
1926 "probed": True,
1927 }
1928 return {"ok": ok, "detail": detail, "probed": True}
1929 cli = CLI_DEPENDENCIES.get(name)
1930 if cli:
1931 try:
1932 probe = health.probe_dependency(cli)
1933 except Exception as exc:
1934 return {"ok": False, "detail": f"{type(exc).__name__}: {exc}", "probed": True}
1935 return {"ok": bool(probe.ok), "detail": probe.detail, "probed": True}
1936 return None
1937
1938
1939 def _probe_sources(config: Dict[str, Any], timeout: int) -> Dict[str, Dict[str, Any]]:
1940 """Probe the probeable sources concurrently, each capped at ``timeout``.
1941
1942 A source that blows its deadline resolves to a probe-failure for that
1943 source only (never a hung command); other probes are unaffected.
1944 """
1945 names = _probeable_sources()
1946 results: Dict[str, Dict[str, Any]] = {}
1947 with concurrent.futures.ThreadPoolExecutor(
1948 max_workers=min(8, len(names) or 1)
1949 ) as pool:
1950 futures = {
1951 name: pool.submit(_probe_source, name, config, timeout) for name in names
1952 }
1953 for name, fut in futures.items():
1954 try:
1955 res = fut.result(timeout=timeout + 1)
1956 except concurrent.futures.TimeoutError:
1957 res = {"ok": False, "detail": "probe exceeded deadline", "probed": True}
1958 except Exception as exc:
1959 res = {
1960 "ok": False,
1961 "detail": f"{type(exc).__name__}: {exc}",
1962 "probed": True,
1963 }
1964 if res is not None:
1965 results[name] = res
1966 return results
1967
1968
1969 def _apply_probe(report: Dict[str, Any], probe_results: Dict[str, Dict[str, Any]]) -> None:
1970 """Attach probe results and re-derive audit_state for probed sources."""
1971 for name, res in probe_results.items():
1972 record = (report.get("sources") or {}).get(name)
1973 if record is None:
1974 continue
1975 record["probe"] = res
1976 record["audit_state"] = audit_state(
1977 name, record, record.get("run_outcome"), res
1978 )
1979
1980
1981 def run(
1982 config: Dict[str, Any],
1983 *,
1984 emit_json: bool = False,
1985 cached: bool = False,
1986 postmortem: bool = False,
1987 probe: bool = False,
1988 ) -> int:
1989 """Build (or serve the cached) doctor report and print it. Always exits 0
1990 (reporting problems is a successful run).
1991
1992 ``postmortem=True`` reads the last run's per-source outcomes (any age) and
1993 reports what broke, instead of predicting config health.
1994 ``probe=True`` (or no fresh run) runs a bounded live probe (U5) so WORKING
1995 is verified, not guessed.
1996 ``cached=True`` serves the stored report within the TTL; stale, absent,
1997 corrupt, schema-mismatched, or fingerprint-mismatched caches fall
1998 through to a live run that rewrites the cache — as does ANY exception
1999 raised while serving the cache (never-crash contract, KTD 8).
2000 ``cached=False`` (explicit ``doctor``) always runs live and refreshes.
2001 """
2002 if postmortem:
2003 pm = build_postmortem(config)
2004 if emit_json:
2005 print(json.dumps(pm, indent=2, sort_keys=True))
2006 else:
2007 print(render_postmortem_text(pm), end="")
2008 return 0
2009
2010 def _emit(report: Dict[str, Any]) -> None:
2011 if emit_json:
2012 # generated_at/from_cache ride the report dict, so they appear
2013 # at the JSON top level for free.
2014 print(render_json(report))
2015 else:
2016 # The cache-status line is printed here (run() owns this print)
2017 # because render_text's header belongs to the render layer, not
2018 # the cache layer.
2019 origin = "cached" if report.get("from_cache") else "live"
2020 print(render_text(report), end="")
2021 print(f"generated: {report.get('generated_at')} ({origin})")
2022
2023 if cached:
2024 try:
2025 cached_report = read_cached_report(config)
2026 if cached_report is not None:
2027 _emit(cached_report)
2028 return 0
2029 except Exception:
2030 # Belt-and-suspenders for shapes the validator misses: any
2031 # failure serving the cache falls through to a live run.
2032 pass
2033 report = build_report(config)
2034 report["generated_at"] = datetime.datetime.now(datetime.timezone.utc).isoformat()
2035 report["from_cache"] = False
2036
2037 # U5: verify WORKING with a bounded live probe when asked (--probe) or when
2038 # there is no fresh run to learn from ("if no recent runs, run a live
2039 # test"). Scoped to free/CLI sources; credit-gated sources stay UNVERIFIED.
2040 fresh_run = bool((report.get("run_evidence") or {}).get("fresh"))
2041 if probe or not fresh_run:
2042 timeout = probe_timeout_seconds(config)
2043 probeable = _probeable_sources()
2044 sys.stderr.write(
2045 f"[last30days] doctor live probe: checking {len(probeable)} free/CLI "
2046 f"sources ({timeout}s each; no credit-gated sources - x/tiktok/"
2047 f"instagram/threads stay unverified)\n"
2048 )
2049 sys.stderr.flush()
2050 try:
2051 probe_results = _probe_sources(config, timeout)
2052 except Exception:
2053 probe_results = {}
2054 _apply_probe(report, probe_results)
2055 report["mode"] = "probe"
2056 report["probe"] = {"ran": True, "timeout": timeout, "sources": list(probeable)}
2057
2058 _write_cache(report, config)
2059 _emit(report)
2060 return 0
2061
2061 lines PYTHON