boardwatch 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. boardwatch/__init__.py +0 -0
  2. boardwatch/cli/__init__.py +0 -0
  3. boardwatch/cli/app.py +56 -0
  4. boardwatch/cli/companies_cmd.py +104 -0
  5. boardwatch/cli/config_cmd.py +138 -0
  6. boardwatch/cli/context.py +25 -0
  7. boardwatch/cli/digest_cmd.py +76 -0
  8. boardwatch/cli/doctor_cmd.py +106 -0
  9. boardwatch/cli/eligibility_cmd.py +395 -0
  10. boardwatch/cli/export_cmd.py +75 -0
  11. boardwatch/cli/init_cmd.py +126 -0
  12. boardwatch/cli/profile_cmd.py +185 -0
  13. boardwatch/cli/scan_cmd.py +39 -0
  14. boardwatch/cli/show_cmd.py +148 -0
  15. boardwatch/cli/top_cmd.py +289 -0
  16. boardwatch/cli/track_cmd.py +163 -0
  17. boardwatch/core/__init__.py +0 -0
  18. boardwatch/core/board_urls.py +46 -0
  19. boardwatch/core/clock.py +17 -0
  20. boardwatch/core/html_text.py +48 -0
  21. boardwatch/core/identity.py +18 -0
  22. boardwatch/core/models.py +67 -0
  23. boardwatch/core/normalize.py +39 -0
  24. boardwatch/core/politeness.py +158 -0
  25. boardwatch/core/secrets.py +33 -0
  26. boardwatch/core/settings.py +91 -0
  27. boardwatch/eligibility/__init__.py +2 -0
  28. boardwatch/eligibility/audit.py +286 -0
  29. boardwatch/eligibility/catalog.py +526 -0
  30. boardwatch/eligibility/detect.py +289 -0
  31. boardwatch/eligibility/engine.py +311 -0
  32. boardwatch/eligibility/extract_llm.py +162 -0
  33. boardwatch/eligibility/facts.py +98 -0
  34. boardwatch/eligibility/ground.py +73 -0
  35. boardwatch/eligibility/hashing.py +138 -0
  36. boardwatch/eligibility/preflight.py +177 -0
  37. boardwatch/eligibility/read.py +48 -0
  38. boardwatch/eligibility/resolve.py +346 -0
  39. boardwatch/eligibility/rules.yaml +828 -0
  40. boardwatch/extract/__init__.py +0 -0
  41. boardwatch/extract/preflight.py +82 -0
  42. boardwatch/extract/role_family.py +47 -0
  43. boardwatch/extract/taxonomy.py +144 -0
  44. boardwatch/extract/taxonomy.yaml +138 -0
  45. boardwatch/llm/__init__.py +0 -0
  46. boardwatch/llm/anthropic.py +104 -0
  47. boardwatch/llm/cache.py +75 -0
  48. boardwatch/llm/client.py +31 -0
  49. boardwatch/llm/factory.py +46 -0
  50. boardwatch/llm/openai_compat.py +101 -0
  51. boardwatch/llm/payload.py +75 -0
  52. boardwatch/llm/prompt.py +49 -0
  53. boardwatch/providers/__init__.py +0 -0
  54. boardwatch/providers/ashby.py +181 -0
  55. boardwatch/providers/base.py +42 -0
  56. boardwatch/providers/greenhouse.py +142 -0
  57. boardwatch/providers/lever.py +130 -0
  58. boardwatch/providers/registry.py +70 -0
  59. boardwatch/py.typed +0 -0
  60. boardwatch/rank/__init__.py +0 -0
  61. boardwatch/rank/explain.py +44 -0
  62. boardwatch/rank/heuristic.py +163 -0
  63. boardwatch/registry/README.md +71 -0
  64. boardwatch/registry/__init__.py +4 -0
  65. boardwatch/registry/companies.yaml +153 -0
  66. boardwatch/registry/health_report.py +65 -0
  67. boardwatch/registry/loader.py +37 -0
  68. boardwatch/registry/validate.py +43 -0
  69. boardwatch/reports/__init__.py +0 -0
  70. boardwatch/reports/digest.py +78 -0
  71. boardwatch/reports/export.py +135 -0
  72. boardwatch/scan/__init__.py +0 -0
  73. boardwatch/scan/apply.py +243 -0
  74. boardwatch/scan/coordinator.py +171 -0
  75. boardwatch/scan/health.py +101 -0
  76. boardwatch/scan/workers.py +16 -0
  77. boardwatch/store/__init__.py +0 -0
  78. boardwatch/store/app_state.py +44 -0
  79. boardwatch/store/applications.py +131 -0
  80. boardwatch/store/artifacts.py +69 -0
  81. boardwatch/store/db.py +51 -0
  82. boardwatch/store/eligibility.py +253 -0
  83. boardwatch/store/events.py +26 -0
  84. boardwatch/store/funnel_queries.py +95 -0
  85. boardwatch/store/migrations/env.py +35 -0
  86. boardwatch/store/migrations/script.py.mako +22 -0
  87. boardwatch/store/migrations/versions/8df3b3809bba_schema_v1.py +159 -0
  88. boardwatch/store/migrations/versions/b7e41c0a9f23_widen_last_health_enum.py +75 -0
  89. boardwatch/store/migrations/versions/p0_applications.py +66 -0
  90. boardwatch/store/migrations/versions/p0_artifacts.py +65 -0
  91. boardwatch/store/migrations/versions/p0_eligibility.py +123 -0
  92. boardwatch/store/migrations/versions/p0_job_grouping.py +48 -0
  93. boardwatch/store/migrations/versions/p0_jobs_anchor.py +62 -0
  94. boardwatch/store/migrations/versions/p0_posting_versions.py +65 -0
  95. boardwatch/store/migrations/versions/p0_version_sources.py +41 -0
  96. boardwatch/store/migrations/versions/p2_profile_eligibility.py +23 -0
  97. boardwatch/store/queries.py +266 -0
  98. boardwatch/store/sources.py +46 -0
  99. boardwatch/store/tables.py +372 -0
  100. boardwatch-0.1.0.dist-info/METADATA +455 -0
  101. boardwatch-0.1.0.dist-info/RECORD +104 -0
  102. boardwatch-0.1.0.dist-info/WHEEL +4 -0
  103. boardwatch-0.1.0.dist-info/entry_points.txt +2 -0
  104. boardwatch-0.1.0.dist-info/licenses/LICENSE +21 -0
boardwatch/__init__.py ADDED
File without changes
File without changes
boardwatch/cli/app.py ADDED
@@ -0,0 +1,56 @@
1
+ """boardwatch CLI entry point."""
2
+
3
+ from importlib.metadata import version as package_version
4
+ from pathlib import Path
5
+
6
+ import typer
7
+ from rich.console import Console
8
+
9
+ from boardwatch.cli.companies_cmd import companies_app
10
+ from boardwatch.cli.config_cmd import config_app
11
+ from boardwatch.cli.digest_cmd import digest as _digest
12
+ from boardwatch.cli.doctor_cmd import doctor as _doctor
13
+ from boardwatch.cli.eligibility_cmd import eligibility_app
14
+ from boardwatch.cli.export_cmd import export as _export
15
+ from boardwatch.cli.init_cmd import init as _init
16
+ from boardwatch.cli.profile_cmd import profile_app
17
+ from boardwatch.cli.scan_cmd import scan as _scan
18
+ from boardwatch.cli.show_cmd import show as _show
19
+ from boardwatch.cli.top_cmd import top as _top
20
+ from boardwatch.cli.track_cmd import track_app
21
+
22
+ app = typer.Typer(no_args_is_help=True, add_completion=False)
23
+ console = Console()
24
+
25
+
26
+ @app.callback()
27
+ def main(
28
+ ctx: typer.Context,
29
+ data_dir: Path | None = typer.Option( # noqa: B008
30
+ None, "--data-dir", help="Override the data directory (default: platform data dir)."
31
+ ),
32
+ ) -> None:
33
+ """boardwatch — self-hosted job radar over official ATS APIs."""
34
+ ctx.obj = data_dir
35
+
36
+
37
+ @app.command()
38
+ def version() -> None:
39
+ """Print the boardwatch version and schema revision."""
40
+ from boardwatch.store.db import schema_revision
41
+
42
+ console.print(f"boardwatch {package_version('boardwatch')} · schema {schema_revision()}")
43
+
44
+
45
+ app.add_typer(companies_app, name="companies")
46
+ app.command("scan")(_scan)
47
+ app.command("init")(_init)
48
+ app.add_typer(profile_app, name="profile")
49
+ app.command("top")(_top)
50
+ app.command("show")(_show)
51
+ app.add_typer(config_app, name="config")
52
+ app.command("doctor")(_doctor)
53
+ app.command("digest")(_digest)
54
+ app.add_typer(eligibility_app, name="eligibility")
55
+ app.add_typer(track_app, name="track")
56
+ app.command("export")(_export)
@@ -0,0 +1,104 @@
1
+ """boardwatch companies — search / add / remove / list / import / export (§2.3).
2
+ `export` is the registry-format contribution funnel; data-portability export
3
+ (--format jsonl|csv) is P2 and intentionally absent."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import typer
8
+ import yaml
9
+ from rich.console import Console
10
+ from rich.table import Table
11
+
12
+ from boardwatch.cli.context import build_context
13
+ from boardwatch.core.board_urls import UnknownBoardURL, parse_board_target
14
+ from boardwatch.registry.loader import load_catalog
15
+ from boardwatch.registry.validate import CatalogError, CompanyEntry
16
+ from boardwatch.store.queries import list_watches, unwatch, upsert_watch
17
+
18
+ companies_app = typer.Typer(no_args_is_help=True, help="Manage watched company boards.")
19
+ console = Console()
20
+
21
+
22
+ def _catalog_index() -> dict[tuple[str, str], CompanyEntry]:
23
+ return {(e.provider, e.slug): e for e in load_catalog()}
24
+
25
+
26
+ @companies_app.command("add")
27
+ def add(ctx: typer.Context, target: str) -> None:
28
+ """Watch a board by provider:slug or board URL."""
29
+ try:
30
+ provider, slug = parse_board_target(target)
31
+ except UnknownBoardURL as exc:
32
+ console.print(f"[red]{exc}[/red]")
33
+ raise typer.Exit(code=1) from exc # no DB write on the failed-validation path
34
+ entry = _catalog_index().get((provider, slug))
35
+ source = "registry" if entry else "user"
36
+ name = entry.name if entry else slug
37
+ app_ctx = build_context(ctx.obj)
38
+ with app_ctx.engine.begin() as conn:
39
+ upsert_watch(conn, provider=provider, slug=slug, name=name, source=source)
40
+ console.print(f"Watching {provider}:{slug} (source={source}).")
41
+
42
+
43
+ @companies_app.command("remove")
44
+ def remove(ctx: typer.Context, target: str) -> None:
45
+ provider, slug = parse_board_target(target)
46
+ app_ctx = build_context(ctx.obj)
47
+ with app_ctx.engine.begin() as conn:
48
+ changed = unwatch(conn, provider=provider, slug=slug)
49
+ console.print(f"Unwatched {provider}:{slug}." if changed else "No such watch.")
50
+
51
+
52
+ @companies_app.command("search")
53
+ def search(ctx: typer.Context, query: str) -> None:
54
+ """Case-insensitive substring search over the bundled catalog (offline)."""
55
+ q = query.casefold()
56
+ hits = [e for e in load_catalog() if q in e.name.casefold() or q in e.slug.casefold()]
57
+ table = Table("name", "provider", "slug", "starter")
58
+ for e in hits:
59
+ table.add_row(e.name, e.provider, e.slug, "★" if "starter" in e.tags else "")
60
+ console.print(table)
61
+
62
+
63
+ @companies_app.command("list")
64
+ def list_(ctx: typer.Context) -> None:
65
+ app_ctx = build_context(ctx.obj)
66
+ with app_ctx.engine.connect() as conn:
67
+ rows = list_watches(conn)
68
+ table = Table("provider", "slug", "source", "watched", "last_health", "last_ok_at")
69
+ for r in rows:
70
+ table.add_row(
71
+ r.provider, r.slug, r.source, "yes" if r.watched else "no",
72
+ r.last_health or "—", str(r.last_ok_at or "—"),
73
+ )
74
+ console.print(table)
75
+
76
+
77
+ @companies_app.command("export")
78
+ def export(ctx: typer.Context) -> None:
79
+ """Emit the user's watches as registry-format YAML (the §3.2 contribution funnel)."""
80
+ app_ctx = build_context(ctx.obj)
81
+ with app_ctx.engine.connect() as conn:
82
+ rows = list_watches(conn)
83
+ payload = {"companies": [
84
+ {"name": r.slug, "provider": r.provider, "slug": r.slug, "tags": []} for r in rows
85
+ ]}
86
+ console.print(yaml.safe_dump(payload, sort_keys=False))
87
+
88
+
89
+ @companies_app.command("import")
90
+ def import_(ctx: typer.Context, path: typer.FileText) -> None:
91
+ """Validate registry-format YAML, then watch each entry."""
92
+ try:
93
+ raw = yaml.safe_load(path.read()) or {}
94
+ entries = [CompanyEntry.model_validate(row) for row in (raw.get("companies") or [])]
95
+ except (CatalogError, ValueError) as exc:
96
+ console.print(f"[red]invalid import file: {exc}[/red]")
97
+ raise typer.Exit(code=1) from exc
98
+ app_ctx = build_context(ctx.obj)
99
+ with app_ctx.engine.begin() as conn:
100
+ for e in entries:
101
+ in_catalog = (e.provider, e.slug) in _catalog_index()
102
+ upsert_watch(conn, provider=e.provider, slug=e.slug, name=e.name,
103
+ source="registry" if in_catalog else "user")
104
+ console.print(f"Imported {len(entries)} watches.")
@@ -0,0 +1,138 @@
1
+ """boardwatch config show|set (§2.3, D17). Validates against the supported-key table;
2
+ writes config.toml via tomli-w (stdlib has no writer), round-tripping the raw dict so a
3
+ user's unknown-but-harmless keys survive a set."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import tomllib
8
+ from typing import Any
9
+
10
+ import tomli_w
11
+ import typer
12
+ from pydantic import ValidationError
13
+ from rich.console import Console
14
+
15
+ from boardwatch.cli.context import build_context
16
+ from boardwatch.core.secrets import LLM_API_KEY_ENV, resolve_secret
17
+ from boardwatch.core.settings import Settings, load_settings
18
+
19
+ config_app = typer.Typer(no_args_is_help=True, help="Show or change settings.")
20
+ console = Console()
21
+
22
+ # key → (caster, "takes effect", units note). weights.* are nested under [weights].
23
+ _SCALAR_KEYS = {
24
+ "per_host_delay_seconds": (float, "next scan", "seconds, floor 0.25"),
25
+ "retry_attempts": (int, "next scan", "total attempts 1–10 (1 = no retry)"),
26
+ "scan_workers": (int, "next scan", "1–8"),
27
+ }
28
+ _WEIGHT_KEYS = {"skill_coverage", "title_match", "recency", "location_fit"}
29
+
30
+ _SECRET_LEAF_NAMES = frozenset({"api_key", "token", "secret", "password"})
31
+
32
+
33
+ def _is_secret_key(name: str) -> bool:
34
+ return name in _SECRET_LEAF_NAMES or name.endswith("_api_key")
35
+
36
+
37
+ def _find_secret_key(raw: dict[str, Any], prefix: str = "") -> str | None:
38
+ """Dotted path of the first reserved secret key at any depth in raw, or None.
39
+ Recurses through nested tables (dict) and arrays-of-tables (list). Returns a PATH
40
+ only, never a value (secrets contract, P0-3 D-P0-3-5)."""
41
+ for key, value in raw.items():
42
+ path = f"{prefix}{key}"
43
+ if _is_secret_key(key):
44
+ return path
45
+ found = _find_secret_in_value(value, path)
46
+ if found is not None:
47
+ return found
48
+ return None
49
+
50
+
51
+ def _find_secret_in_value(value: Any, path: str) -> str | None:
52
+ """Walk a config value for a reserved secret key; returns a PATH or None, never a value."""
53
+ if isinstance(value, dict):
54
+ return _find_secret_key(value, f"{path}.")
55
+ if isinstance(value, list):
56
+ for index, item in enumerate(value):
57
+ found = _find_secret_in_value(item, f"{path}[{index}]")
58
+ if found is not None:
59
+ return found
60
+ return None
61
+
62
+
63
+ @config_app.command("show")
64
+ def show(ctx: typer.Context) -> None:
65
+ settings = load_settings(data_dir=ctx.obj)
66
+ defaults = Settings(data_dir=settings.data_dir, config_dir=settings.config_dir)
67
+ for key, (_caster, effect, units) in _SCALAR_KEYS.items():
68
+ cur, dflt = getattr(settings, key), getattr(defaults, key)
69
+ console.print(f"{key} = {cur} (default {dflt}; {units}; {effect})")
70
+ for key in sorted(_WEIGHT_KEYS):
71
+ cur, dflt = getattr(settings.weights, key), getattr(defaults.weights, key)
72
+ console.print(f"weights.{key} = {cur} (default {dflt}; [0,1]; next top)")
73
+ llm = settings.llm
74
+ console.print(
75
+ f"llm: reserved (opt-in; ships v1.1); "
76
+ f"enabled={llm.enabled}, provider={llm.provider}, model={llm.model}"
77
+ )
78
+ present = "set" if resolve_secret(LLM_API_KEY_ENV) is not None else "unset"
79
+ console.print(f"llm.api_key: {present} (via {LLM_API_KEY_ENV})")
80
+
81
+
82
+ @config_app.command("set")
83
+ def set_(ctx: typer.Context, key: str, value: str) -> None:
84
+ settings = load_settings(data_dir=ctx.obj)
85
+ config_file = settings.config_dir / "config.toml"
86
+ raw = tomllib.loads(config_file.read_text(encoding="utf-8")) if config_file.is_file() else {}
87
+
88
+ existing_secret = _find_secret_key(raw)
89
+ if existing_secret is not None:
90
+ console.print(
91
+ f"[red]refusing to write: config.toml must not contain secrets; found "
92
+ f"reserved key {existing_secret!r}. Remove it and put credentials in the "
93
+ f"environment ({LLM_API_KEY_ENV}).[/red]"
94
+ )
95
+ raise typer.Exit(code=1)
96
+ if _is_secret_key(key.rsplit(".", 1)[-1]):
97
+ console.print(
98
+ f"[red]secrets do not belong in config.toml; set {LLM_API_KEY_ENV} in the "
99
+ f"environment instead of {key!r}.[/red]"
100
+ )
101
+ raise typer.Exit(code=1)
102
+ if key == "llm" or key.startswith("llm."):
103
+ console.print(
104
+ f"[red]{key!r} is reserved for the v1.1 LLM tier and is not settable yet.[/red]"
105
+ )
106
+ raise typer.Exit(code=1)
107
+
108
+ if key in _SCALAR_KEYS:
109
+ caster, _e, _u = _SCALAR_KEYS[key]
110
+ old = getattr(settings, key)
111
+ try:
112
+ new = caster(value)
113
+ # construct Settings with the new value → the Field(ge/le) range check fires
114
+ Settings(data_dir=settings.data_dir, config_dir=settings.config_dir, **{key: new})
115
+ except (ValueError, ValidationError) as exc:
116
+ console.print(f"[red]invalid value for {key}: {exc}[/red]")
117
+ raise typer.Exit(code=1) from exc
118
+ raw[key] = new
119
+ elif key.startswith("weights.") and key.split(".", 1)[1] in _WEIGHT_KEYS:
120
+ from boardwatch.core.settings import RankWeights
121
+
122
+ leaf = key.split(".", 1)[1]
123
+ old = getattr(settings.weights, leaf)
124
+ try:
125
+ new = float(value)
126
+ RankWeights(**{**settings.weights.model_dump(), leaf: new}) # [0,1] range check
127
+ except (ValueError, ValidationError) as exc:
128
+ console.print(f"[red]invalid value for {key}: {exc}[/red]")
129
+ raise typer.Exit(code=1) from exc
130
+ raw.setdefault("weights", {})[leaf] = new
131
+ else:
132
+ console.print(f"[red]unknown key {key!r}[/red]")
133
+ raise typer.Exit(code=1)
134
+
135
+ config_file.parent.mkdir(parents=True, exist_ok=True)
136
+ config_file.write_bytes(tomli_w.dumps(raw).encode("utf-8")) # round-trips unknown keys
137
+ console.print(f"{key}: {old} → {new}")
138
+ build_context(ctx.obj) # ensure the data dir/schema exist (parity with other commands)
@@ -0,0 +1,25 @@
1
+ """Per-invocation app context: settings + engine, built lazily by DB-touching commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ from sqlalchemy import Engine
9
+
10
+ from boardwatch.core.settings import Settings, load_settings
11
+ from boardwatch.store.db import ensure_schema, get_engine
12
+
13
+
14
+ @dataclass
15
+ class AppContext:
16
+ settings: Settings
17
+ engine: Engine
18
+
19
+
20
+ def build_context(data_dir: Path | None, *, ensure: bool = True) -> AppContext:
21
+ settings = load_settings(data_dir=data_dir)
22
+ engine = get_engine(settings.data_dir, busy_timeout_ms=settings.busy_timeout_ms)
23
+ if ensure:
24
+ ensure_schema(engine)
25
+ return AppContext(settings=settings, engine=engine)
@@ -0,0 +1,76 @@
1
+ """digest: what changed since you last looked (D18).
2
+
3
+ Reads the event window past the stored cursor, renders it, and advances the cursor.
4
+ Issues BEGIN IMMEDIATE before the first cursor read so two concurrent digests cannot
5
+ both read the same window (A1). --peek renders without advancing.
6
+
7
+ A crash after terminal output can still re-render a window. Exactly-once terminal
8
+ rendering is not achievable transactionally, and claiming it would be false.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from collections.abc import Callable
14
+
15
+ import typer
16
+ from rich.console import Console
17
+ from rich.table import Table
18
+ from sqlalchemy import text
19
+
20
+ from boardwatch.cli.context import build_context
21
+ from boardwatch.reports.digest import DigestEntry, DigestSummary, summarize_events
22
+ from boardwatch.store.app_state import get_digest_cursor, set_digest_cursor
23
+
24
+ console = Console()
25
+
26
+ # Explicit accessors rather than getattr: mypy --strict cannot type a getattr lookup, and
27
+ # a typo in a section name would then only surface at runtime.
28
+ _SECTIONS: tuple[tuple[str, Callable[[DigestSummary], tuple[DigestEntry, ...]]], ...] = (
29
+ ("New", lambda s: s.new),
30
+ ("Reopened", lambda s: s.reopened),
31
+ ("Updated", lambda s: s.revised),
32
+ )
33
+
34
+
35
+ def render(summary: DigestSummary) -> None:
36
+ for heading, accessor in _SECTIONS:
37
+ entries = accessor(summary)
38
+ if not entries:
39
+ continue
40
+ table = Table(title=heading, show_header=True, header_style="bold", title_justify="left")
41
+ table.add_column("#", style="dim")
42
+ table.add_column("Title")
43
+ table.add_column("Company")
44
+ for entry in entries:
45
+ table.add_row(str(entry.posting_id), entry.title, entry.company)
46
+ console.print(table)
47
+ if summary.closed_count:
48
+ console.print(f"{summary.closed_count} closed since your last digest.")
49
+
50
+
51
+ def digest(
52
+ ctx: typer.Context,
53
+ peek: bool = typer.Option(
54
+ False, "--peek", help="Show the same digest without advancing the cursor."
55
+ ),
56
+ ) -> None:
57
+ """New, reopened, updated and closed postings since your last digest."""
58
+ app_ctx = build_context(ctx.obj)
59
+ with app_ctx.engine.connect() as conn:
60
+ # A1: BEGIN IMMEDIATE serializes before the first cursor read so two
61
+ # overlapping digests cannot read the same window.
62
+ conn.execute(text("BEGIN IMMEDIATE"))
63
+ try:
64
+ summary = summarize_events(conn, get_digest_cursor(conn))
65
+ if summary.is_empty:
66
+ console.print("nothing new since your last digest")
67
+ return
68
+ render(summary)
69
+ if not peek:
70
+ set_digest_cursor(conn, summary.max_event_id)
71
+ conn.commit()
72
+ except Exception:
73
+ conn.rollback()
74
+ raise
75
+ if peek:
76
+ console.print("peeked, so the cursor did not move")
@@ -0,0 +1,106 @@
1
+ """boardwatch doctor — connectivity, per-board health + freshness, DB integrity (§2.3).
2
+
3
+ Runtime is healthy-path-only: ~15 boards ≈ seconds when healthy; DEAD/ERROR/
4
+ UNREACHABLE paths can take minutes (tenacity retries + timeouts)."""
5
+
6
+ from __future__ import annotations
7
+
8
+ from importlib.metadata import version as package_version
9
+
10
+ import typer
11
+ from rich.console import Console
12
+ from rich.table import Table
13
+ from sqlalchemy import Connection, select, text
14
+ from sqlalchemy.exc import OperationalError
15
+
16
+ from boardwatch.cli.context import build_context
17
+ from boardwatch.core.clock import utcnow
18
+ from boardwatch.scan.coordinator import default_providers
19
+ from boardwatch.scan.health import probe_health
20
+ from boardwatch.store import tables
21
+ from boardwatch.store.db import schema_revision
22
+ from boardwatch.store.queries import last_complete_scan_ages
23
+
24
+ console = Console()
25
+
26
+
27
+ def _db_revision(conn: Connection) -> str | None:
28
+ """The DB's applied Alembic revision, or None if the DB is unversioned/uninitialized."""
29
+ try:
30
+ result = conn.execute(text("SELECT version_num FROM alembic_version")).scalar_one_or_none()
31
+ except OperationalError: # alembic_version table absent → schema never applied
32
+ return None
33
+ return str(result) if result is not None else None
34
+
35
+
36
+ def _integrity_check(conn: Connection) -> str:
37
+ """PRAGMA integrity_check result ('ok' on a healthy DB). A module-level seam so tests
38
+ can force a corruption result without writing bad SQLite pages."""
39
+ return str(conn.execute(text("PRAGMA integrity_check")).scalar_one())
40
+
41
+
42
+ def doctor(ctx: typer.Context, offline: bool = typer.Option(False, "--offline")) -> None:
43
+ """Connectivity, board health and freshness, and database integrity checks."""
44
+ # ensure=False (context.py supports it): doctor must INSPECT the schema, never migrate it —
45
+ # otherwise a corrupted/absent revision would be silently upgraded before we could report it
46
+ app_ctx = build_context(ctx.obj, ensure=False)
47
+
48
+ with app_ctx.engine.connect() as conn:
49
+ db_revision = _db_revision(conn)
50
+ schema_ok = db_revision == schema_revision()
51
+ if db_revision is None: # absent/unversioned schema — report and stop before probing
52
+ console.print(f"boardwatch {package_version('boardwatch')}")
53
+ console.print("schema: ABSENT (run a boardwatch command that initializes the database)")
54
+ raise typer.Exit(code=1)
55
+
56
+ report = probe_health(app_ctx.engine, app_ctx.settings, offline=offline)
57
+
58
+ with app_ctx.engine.connect() as conn:
59
+ ages = last_complete_scan_ages(conn)
60
+ watches = conn.execute(
61
+ select(tables.companies).where(tables.companies.c.watched.is_(True))
62
+ ).all()
63
+ running = conn.execute(
64
+ select(tables.runs.c.id).where(tables.runs.c.finished_at.is_(None))
65
+ ).first()
66
+ integrity = _integrity_check(conn)
67
+
68
+ # connectivity: offline renders "not checked" for EVERY registered provider (not just those
69
+ # with watches — the offline contract); online renders the probed result
70
+ conn_table = Table("provider", "reachable")
71
+ if offline:
72
+ for provider in sorted(default_providers()):
73
+ conn_table.add_row(provider, "not checked")
74
+ else:
75
+ for c in report.connectivity:
76
+ label = "yes" if c.reachable else "NO"
77
+ conn_table.add_row(c.provider, label + (" (fallback)" if c.from_fallback else ""))
78
+ console.print(conn_table)
79
+
80
+ # per-board health + freshness; freshness renders an AGE (duration), not a raw timestamp;
81
+ # offline renders the STORED columns (last_health + last_ok_at)
82
+ now = utcnow()
83
+ health_table = Table("board", "last_health", "last_ok_at", "last_complete_scan_age")
84
+ for w in watches:
85
+ stored = " (stored)" if offline else ""
86
+ ts = ages.get(w.id)
87
+ age = "never" if ts is None else f"{(now - ts).days}d ago"
88
+ health_table.add_row(
89
+ f"{w.provider}:{w.slug}", (w.last_health or "—") + stored,
90
+ str(w.last_ok_at or "—"), age,
91
+ )
92
+ console.print(health_table)
93
+ if running:
94
+ console.print(f"[yellow]a scan is in progress (run {running.id})[/yellow]")
95
+
96
+ # schema check compares the DB's applied revision against the code's expected script head
97
+ schema_ok = db_revision == schema_revision()
98
+ integrity_ok = integrity == "ok"
99
+ console.print(f"boardwatch {package_version('boardwatch')}")
100
+ console.print(
101
+ f"integrity: {integrity} · schema: "
102
+ f"{'ok' if schema_ok else f'MISMATCH (db={db_revision}, code={schema_revision()})'}"
103
+ )
104
+
105
+ failed = report.actionable or not integrity_ok or not schema_ok
106
+ raise typer.Exit(code=1 if failed else 0)