loadcoach 1.0.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 (125) hide show
  1. loadcoach/__about__.py +1 -0
  2. loadcoach/__main__.py +12 -0
  3. loadcoach/bootstrap.py +157 -0
  4. loadcoach/cli/commands/config.py +169 -0
  5. loadcoach/cli/commands/db.py +249 -0
  6. loadcoach/cli/commands/evidence.py +347 -0
  7. loadcoach/cli/commands/generate.py +134 -0
  8. loadcoach/cli/commands/job.py +394 -0
  9. loadcoach/cli/commands/models.py +215 -0
  10. loadcoach/cli/commands/queue.py +162 -0
  11. loadcoach/cli/commands/reliability.py +92 -0
  12. loadcoach/cli/commands/route.py +191 -0
  13. loadcoach/cli/commands/system.py +137 -0
  14. loadcoach/cli/commands/tasks.py +158 -0
  15. loadcoach/cli/commands/token.py +146 -0
  16. loadcoach/cli/main.py +84 -0
  17. loadcoach/config/manual_capability_scores.toml +15 -0
  18. loadcoach/config/schemas/code_debug_findings.json +13 -0
  19. loadcoach/config/schemas/code_review_findings.json +25 -0
  20. loadcoach/config/schemas/content_review_findings.json +24 -0
  21. loadcoach/config/schemas/fact_check_findings.json +26 -0
  22. loadcoach/config/schemas/structured_extract.json +11 -0
  23. loadcoach/config/task_profiles.toml +417 -0
  24. loadcoach/config.py +894 -0
  25. loadcoach/domain/admission.py +230 -0
  26. loadcoach/domain/authorization.py +105 -0
  27. loadcoach/domain/circuit_breaker.py +357 -0
  28. loadcoach/domain/evidence_policy.py +848 -0
  29. loadcoach/domain/priority.py +158 -0
  30. loadcoach/domain/queue_state.py +214 -0
  31. loadcoach/domain/registry.py +194 -0
  32. loadcoach/domain/reliability.py +812 -0
  33. loadcoach/domain/retry_policy.py +192 -0
  34. loadcoach/domain/routing/constraints.py +567 -0
  35. loadcoach/domain/routing/context_budget.py +188 -0
  36. loadcoach/domain/routing/explanation.py +288 -0
  37. loadcoach/domain/routing/narrative.py +360 -0
  38. loadcoach/domain/routing/ranking.py +118 -0
  39. loadcoach/domain/routing/scoring.py +549 -0
  40. loadcoach/domain/routing/subject.py +343 -0
  41. loadcoach/domain/task_profile.py +221 -0
  42. loadcoach/domain/validation.py +399 -0
  43. loadcoach/infrastructure/db/migrations/.gitkeep +0 -0
  44. loadcoach/infrastructure/db/migrations/env.py +34 -0
  45. loadcoach/infrastructure/db/migrations/script.py.mako +26 -0
  46. loadcoach/infrastructure/db/migrations/versions/0001_initial_schema.py +161 -0
  47. loadcoach/infrastructure/db/migrations/versions/0002_routing_decisions.py +118 -0
  48. loadcoach/infrastructure/db/migrations/versions/0003_jobs_and_attempts.py +218 -0
  49. loadcoach/infrastructure/db/migrations/versions/0004_residency.py +84 -0
  50. loadcoach/infrastructure/db/migrations/versions/0005_capability_evidence.py +143 -0
  51. loadcoach/infrastructure/db/migrations/versions/0006_feedback_and_reliability.py +111 -0
  52. loadcoach/infrastructure/db/models.py +727 -0
  53. loadcoach/infrastructure/db/repositories/.gitkeep +0 -0
  54. loadcoach/infrastructure/db/repositories/settings.py +51 -0
  55. loadcoach/infrastructure/freeweight_client.py +470 -0
  56. loadcoach/infrastructure/providers/factory.py +63 -0
  57. loadcoach/observability/logging.py +144 -0
  58. loadcoach/prompts/.gitkeep +0 -0
  59. loadcoach/prompts/execution/structured_output.retry.v1.json +38 -0
  60. loadcoach/prompts/manifest.json +14 -0
  61. loadcoach/services/config_reference.py +154 -0
  62. loadcoach/services/dashboard.py +191 -0
  63. loadcoach/services/database.py +359 -0
  64. loadcoach/services/doctor.py +535 -0
  65. loadcoach/services/evidence.py +1563 -0
  66. loadcoach/services/execution.py +1589 -0
  67. loadcoach/services/feedback.py +245 -0
  68. loadcoach/services/health.py +379 -0
  69. loadcoach/services/job_events.py +287 -0
  70. loadcoach/services/machine.py +73 -0
  71. loadcoach/services/models.py +485 -0
  72. loadcoach/services/prompts.py +63 -0
  73. loadcoach/services/queue.py +1501 -0
  74. loadcoach/services/queue_stream.py +151 -0
  75. loadcoach/services/recovery.py +165 -0
  76. loadcoach/services/reliability.py +450 -0
  77. loadcoach/services/residency.py +367 -0
  78. loadcoach/services/retention.py +126 -0
  79. loadcoach/services/routing.py +903 -0
  80. loadcoach/services/settings.py +271 -0
  81. loadcoach/services/status.py +125 -0
  82. loadcoach/services/task_profiles.py +140 -0
  83. loadcoach/services/telemetry_stream.py +170 -0
  84. loadcoach/services/tokens.py +164 -0
  85. loadcoach/services/worker.py +1920 -0
  86. loadcoach/web/app.py +404 -0
  87. loadcoach/web/auth.py +189 -0
  88. loadcoach/web/csrf.py +55 -0
  89. loadcoach/web/limits.py +149 -0
  90. loadcoach/web/rate_limit.py +282 -0
  91. loadcoach/web/rendering.py +79 -0
  92. loadcoach/web/routes/access.py +44 -0
  93. loadcoach/web/routes/dashboard.py +36 -0
  94. loadcoach/web/routes/evidence.py +263 -0
  95. loadcoach/web/routes/generate.py +372 -0
  96. loadcoach/web/routes/jobs.py +411 -0
  97. loadcoach/web/routes/models.py +153 -0
  98. loadcoach/web/routes/queue.py +181 -0
  99. loadcoach/web/routes/reliability.py +103 -0
  100. loadcoach/web/routes/routing.py +203 -0
  101. loadcoach/web/routes/settings.py +106 -0
  102. loadcoach/web/routes/system.py +110 -0
  103. loadcoach/web/routes/task_profiles.py +59 -0
  104. loadcoach/web/routing_support.py +87 -0
  105. loadcoach/web/templates/.gitkeep +0 -0
  106. loadcoach/web/templates/dashboard/index.html +89 -0
  107. loadcoach/web/templates/error.html +34 -0
  108. loadcoach/web/templates/evidence/index.html +105 -0
  109. loadcoach/web/templates/jobs/detail.html +100 -0
  110. loadcoach/web/templates/jobs/index.html +46 -0
  111. loadcoach/web/templates/models/index.html +42 -0
  112. loadcoach/web/templates/queue/_live.html +63 -0
  113. loadcoach/web/templates/queue/index.html +47 -0
  114. loadcoach/web/templates/reliability/index.html +106 -0
  115. loadcoach/web/templates/routing/_narrative.html +67 -0
  116. loadcoach/web/templates/routing/detail.html +85 -0
  117. loadcoach/web/templates/routing/index.html +30 -0
  118. loadcoach/web/templates/settings/index.html +39 -0
  119. loadcoach/web/templates/system/index.html +84 -0
  120. loadcoach/web/templates/task_profiles/index.html +24 -0
  121. loadcoach-1.0.0.dist-info/METADATA +120 -0
  122. loadcoach-1.0.0.dist-info/RECORD +125 -0
  123. loadcoach-1.0.0.dist-info/WHEEL +4 -0
  124. loadcoach-1.0.0.dist-info/entry_points.txt +2 -0
  125. loadcoach-1.0.0.dist-info/licenses/LICENSE +201 -0
loadcoach/__about__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "1.0.0"
loadcoach/__main__.py ADDED
@@ -0,0 +1,12 @@
1
+ """loadcoach.__main__ — ``python -m loadcoach``.
2
+
3
+ Delegates to the Typer app, whose root callback starts ``serve`` when invoked with no subcommand
4
+ (CLI Standards §1).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from loadcoach.cli.main import app
10
+
11
+ if __name__ == "__main__":
12
+ app()
loadcoach/bootstrap.py ADDED
@@ -0,0 +1,157 @@
1
+ """loadcoach.bootstrap — the composition root: settings, logging and the ASGI app, wired once.
2
+
3
+ This module sits outside the ``web``/``cli``/``services``/``domain`` layer ordering that
4
+ ``.importlinter`` enforces, precisely so it can import both configuration and the web layer.
5
+ ``loadcoach.cli`` never imports it directly — the ``web-cli-independence`` contract forbids any
6
+ import chain from ``cli`` into ``web``, and this module imports ``web``. Instead, the CLI's
7
+ ``serve`` command hands uvicorn the dotted string
8
+ ``"loadcoach.bootstrap:create_app_from_environment"`` and lets uvicorn perform that import itself;
9
+ a string literal is invisible to import-linter's static analysis, so the two surfaces stay decoupled
10
+ at the source level while still running the same application in one process.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ from dataclasses import dataclass
17
+ from datetime import UTC, datetime
18
+
19
+ from fastapi import FastAPI
20
+ from sqlalchemy import func, select
21
+
22
+ from loadcoach.config import LOOPBACK_HOSTS, InsecureBindingError, LoadedSettings, load_settings
23
+ from loadcoach.infrastructure.db.models import ApiToken
24
+ from loadcoach.infrastructure.providers.factory import build_provider
25
+ from loadcoach.observability.logging import configure_logging
26
+ from loadcoach.services.database import Database, ensure_ready
27
+ from loadcoach.services.models import import_manual_capability_scores, try_discover_models
28
+ from loadcoach.services.task_profiles import import_task_profiles, read_task_profiles_file
29
+ from loadcoach.web.app import create_app
30
+
31
+ __all__ = ["Application", "bootstrap", "create_app_from_environment"]
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+
36
+ @dataclass(frozen=True, slots=True)
37
+ class Application:
38
+ """A fully wired application: the settings it was built from and its ASGI app."""
39
+
40
+ loaded_settings: LoadedSettings
41
+ app: FastAPI
42
+
43
+
44
+ def _has_active_token(database: Database) -> bool:
45
+ """Return whether at least one non-revoked, unexpired API token exists.
46
+
47
+ The third member of ADR-0026's non-loopback refusal set — bind acknowledgement and
48
+ ``server.allowed_hosts`` are checked in :func:`loadcoach.config.load_settings`, which reads no
49
+ database; this one needs the database, because LoadCoach stores tokens in the ``api_tokens``
50
+ table (created by ``loadcoach token create``) rather than in ``config.toml`` — a token an
51
+ operator can issue and revoke without editing a file.
52
+ """
53
+ now = datetime.now(UTC)
54
+ with database.read() as session:
55
+ count = session.execute(
56
+ select(func.count())
57
+ .select_from(ApiToken)
58
+ .where(
59
+ ApiToken.revoked_at.is_(None),
60
+ (ApiToken.expires_at.is_(None)) | (ApiToken.expires_at > now),
61
+ )
62
+ ).scalar_one()
63
+ return count > 0
64
+
65
+
66
+ def bootstrap() -> Application:
67
+ """Load configuration, configure logging, ready the database, build the app.
68
+
69
+ Reads configuration through the standard precedence chain (defaults, file, environment) with
70
+ no CLI-argument layer of its own: a caller that needs CLI overrides applies them as environment
71
+ variables before calling this function, which is what ``loadcoach.cli.commands.system.serve``
72
+ does.
73
+
74
+ The startup revision check (database standards §5.1) and the non-loopback token requirement
75
+ (ADR-0026) both run here, in the composition root, and deliberately not inside
76
+ :func:`~loadcoach.web.app.create_app` — that function is documented as a pure function of
77
+ :class:`~loadcoach.config.Settings` precisely so tests can build an app without touching the
78
+ filesystem, and opening a database is neither pure nor free.
79
+
80
+ The shipped task profiles are validated and imported here too, before anything can route: a
81
+ malformed profile is a startup failure (dev-plan P2 acceptance criterion 3), not a
82
+ routing-time surprise. Model discovery is best-effort
83
+ (:func:`~loadcoach.services.models.try_discover_models`) — spec §5 promises LoadCoach starts
84
+ and serves with no provider, so an unreachable one at startup degrades health rather than
85
+ blocking the server.
86
+
87
+ Returns:
88
+ The wired :class:`Application`.
89
+
90
+ Raises:
91
+ ConfigurationError: Configuration is invalid, or an unsafe bind combination is configured.
92
+ InsecureBindingError: ``server.host`` is not loopback and no active API token exists.
93
+ MigrationRequired: The database is behind head and ``storage.auto_migrate`` is false.
94
+ SchemaAhead: The database was written by a newer application version.
95
+ DatabaseUnavailable: The configured database could not be reached at all.
96
+ TaskProfileInvalid: A shipped task profile fails validation.
97
+ """
98
+ loaded = load_settings()
99
+ configure_logging(
100
+ level=loaded.settings.logging.level, log_format=loaded.settings.logging.format
101
+ )
102
+ database_url = loaded.settings.storage.database_url
103
+ if database_url is None: # pragma: no cover — StorageSettings always fills this in
104
+ message = "no database_url configured"
105
+ raise RuntimeError(message)
106
+ with Database.from_url(
107
+ database_url, statement_timeout_ms=loaded.settings.storage.statement_timeout_ms
108
+ ) as database:
109
+ ensure_ready(
110
+ database,
111
+ auto_migrate=loaded.settings.storage.auto_migrate,
112
+ backup_retention=loaded.settings.storage.backup_retention,
113
+ )
114
+ if loaded.settings.server.host not in LOOPBACK_HOSTS and not _has_active_token(database):
115
+ raise InsecureBindingError(
116
+ "server.host is not loopback but no active API token exists. A non-loopback bind "
117
+ "must have at least one token created first: `loadcoach token create`.",
118
+ details={"field": "server.host", "host": loaded.settings.server.host},
119
+ )
120
+ if (
121
+ loaded.settings.server.host not in LOOPBACK_HOSTS
122
+ and not loaded.settings.server.trusted_proxies
123
+ ):
124
+ # ADR-0014 §7's startup warning (F5/M5C-5): LoadCoach speaks HTTP and expects a TLS
125
+ # reverse proxy on any non-loopback bind; `trusted_proxies` being set is the one
126
+ # piece of configuration that evidences one. Without it, warn — and say what breaks:
127
+ # the UI's cookies are `Secure`, so over plain HTTP beyond loopback the browser
128
+ # stores neither the CSRF nor the token cookie and the 401 page's flow cannot work.
129
+ logger.warning(
130
+ "server.plain_http_exposure",
131
+ extra={
132
+ "host": loaded.settings.server.host,
133
+ "detail": (
134
+ "non-loopback bind with no [server] trusted_proxies configured: "
135
+ "ADR-0014 §7 expects a TLS reverse proxy here. Over plain HTTP the "
136
+ "browser refuses the UI's Secure cookies, so the tokened-bind page "
137
+ "flow needs HTTPS or loopback; the bearer-token API is unaffected."
138
+ ),
139
+ },
140
+ )
141
+ profiles = read_task_profiles_file()
142
+ import_task_profiles(database, profiles, now=datetime.now(UTC))
143
+ provider = build_provider(loaded.settings.provider)
144
+ try_discover_models(database, provider, now=datetime.now(UTC))
145
+ # After discovery: a manual score names a model by canonical_id and is skipped, not an
146
+ # error, if that model has not been discovered yet.
147
+ import_manual_capability_scores(database, now=datetime.now(UTC))
148
+ return Application(loaded_settings=loaded, app=create_app(loaded.settings))
149
+
150
+
151
+ def create_app_from_environment() -> FastAPI:
152
+ """Zero-argument ASGI factory: the target uvicorn imports by dotted name.
153
+
154
+ See the module docstring for why this is referenced by string rather than imported directly by
155
+ ``loadcoach.cli``.
156
+ """
157
+ return bootstrap().app
@@ -0,0 +1,169 @@
1
+ """loadcoach.cli.commands.config — show, validate, init, path.
2
+
3
+ Only ``typer`` and ``json`` load at module level; ``loadcoach.config`` (which imports pydantic) is
4
+ imported lazily inside each command body, per the same startup-performance discipline as
5
+ :mod:`loadcoach.cli.commands.system`.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from typing import Annotated
12
+
13
+ import typer
14
+
15
+ __all__ = ["app"]
16
+
17
+ app = typer.Typer(help="Configuration inspection and management.")
18
+
19
+
20
+ def _looks_secret(field_name: str) -> bool:
21
+ lowered = field_name.lower()
22
+ return any(marker in lowered for marker in ("token", "key", "secret", "password"))
23
+
24
+
25
+ @app.command("show")
26
+ def show(
27
+ config: Annotated[
28
+ str | None, typer.Option("--config", help="Path to a config.toml file.")
29
+ ] = None,
30
+ json_output: Annotated[
31
+ bool, typer.Option("--json", help="Print JSON instead of a table.")
32
+ ] = False,
33
+ ) -> None:
34
+ """Print the effective configuration, with the source of every value.
35
+
36
+ Example:
37
+ loadcoach config show --json
38
+ """
39
+ from loadcoach.config import ConfigurationError, load_settings
40
+
41
+ try:
42
+ loaded = load_settings(config_path=config)
43
+ except ConfigurationError as exc:
44
+ typer.echo(f"Error: {exc.message} ({exc.code})", err=True)
45
+ raise typer.Exit(3) from exc
46
+
47
+ dumped = loaded.settings.model_dump(mode="json")
48
+ if json_output:
49
+ typer.echo(
50
+ json.dumps(
51
+ {
52
+ "values": dumped,
53
+ "sources": loaded.sources,
54
+ "config_path": str(loaded.config_path),
55
+ }
56
+ )
57
+ )
58
+ return
59
+
60
+ typer.echo(
61
+ f"# {loaded.config_path}{'' if loaded.config_file_used else ' (not found; defaults apply)'}"
62
+ )
63
+ for section, fields in dumped.items():
64
+ for field_name, value in fields.items():
65
+ path = f"{section}.{field_name}"
66
+ source = loaded.sources.get(path, "default")
67
+ rendered = "********" if _looks_secret(field_name) else value
68
+ typer.echo(f"{path:<40} {rendered!s:<24} ({source})")
69
+
70
+
71
+ @app.command("validate")
72
+ def validate(
73
+ config: Annotated[
74
+ str | None, typer.Option("--config", help="Path to a config.toml file.")
75
+ ] = None,
76
+ ) -> None:
77
+ """Validate configuration without starting the service. Exit 0 or 3.
78
+
79
+ Example:
80
+ loadcoach config validate --config ./config.toml
81
+ """
82
+ from loadcoach.config import ConfigurationError, load_settings
83
+
84
+ try:
85
+ load_settings(config_path=config)
86
+ except ConfigurationError as exc:
87
+ typer.echo(f"Error: {exc.message} ({exc.code})", err=True)
88
+ raise typer.Exit(3) from exc
89
+ typer.echo("Configuration is valid.")
90
+
91
+
92
+ @app.command("path")
93
+ def path(
94
+ config: Annotated[
95
+ str | None, typer.Option("--config", help="Path to a config.toml file.")
96
+ ] = None,
97
+ ) -> None:
98
+ """Print the resolved configuration file location.
99
+
100
+ Example:
101
+ loadcoach config path
102
+ """
103
+ from loadcoach.config import resolve_config_path
104
+
105
+ typer.echo(str(resolve_config_path(config)))
106
+
107
+
108
+ @app.command("init")
109
+ def init(
110
+ config: Annotated[
111
+ str | None, typer.Option("--config", help="Path to write the config file to.")
112
+ ] = None,
113
+ force: Annotated[bool, typer.Option("--force", help="Overwrite an existing file.")] = False,
114
+ ) -> None:
115
+ """Write a fully commented example configuration file.
116
+
117
+ Example:
118
+ loadcoach config init --force
119
+ """
120
+ from loadcoach.config import EXAMPLE_CONFIG_TOML, resolve_config_path
121
+
122
+ target = resolve_config_path(config)
123
+ if target.exists() and not force:
124
+ typer.echo(f"Error: {target} already exists (use --force to overwrite).", err=True)
125
+ raise typer.Exit(3)
126
+ target.parent.mkdir(parents=True, exist_ok=True)
127
+ target.write_text(EXAMPLE_CONFIG_TOML, encoding="utf-8")
128
+ typer.echo(str(target))
129
+
130
+
131
+ @app.command("reference")
132
+ def reference(
133
+ check: Annotated[
134
+ bool,
135
+ typer.Option(
136
+ "--check", help="Exit 1 if docs/configuration.md differs from the generated text."
137
+ ),
138
+ ] = False,
139
+ output: Annotated[
140
+ str | None, typer.Option("--output", help="Write to this file instead of stdout.")
141
+ ] = None,
142
+ ) -> None:
143
+ """Generate the configuration reference from the settings model (configuration standards §8).
144
+
145
+ Mode: local. With ``--check``, compares against ``--output`` (or ``docs/configuration.md``)
146
+ and exits 1 on drift, which is what CI runs.
147
+ """
148
+ from pathlib import Path
149
+
150
+ from loadcoach.services.config_reference import render_configuration_reference
151
+
152
+ rendered = render_configuration_reference()
153
+ target = Path(output) if output else Path("docs/configuration.md")
154
+ if check:
155
+ committed = target.read_text(encoding="utf-8") if target.is_file() else ""
156
+ if committed != rendered:
157
+ typer.echo(
158
+ f"{target} differs from the generated reference; run "
159
+ "`loadcoach config reference --output docs/configuration.md`",
160
+ err=True,
161
+ )
162
+ raise typer.Exit(1)
163
+ typer.echo(f"{target} matches the settings model")
164
+ return
165
+ if output:
166
+ target.write_text(rendered, encoding="utf-8")
167
+ typer.echo(f"wrote {target}")
168
+ else:
169
+ typer.echo(rendered)
@@ -0,0 +1,249 @@
1
+ """loadcoach.cli.commands.db — upgrade, status, backup, restore.
2
+
3
+ Every command here is **local** mode (CLI standards §6): it runs the service layer in-process
4
+ against the configured database and needs no server running. Only ``typer`` and ``json`` load at
5
+ module level, so registering this subgroup never pulls in SQLAlchemy or Alembic
6
+ (CLI standards §12).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from collections.abc import Iterator
13
+ from contextlib import contextmanager
14
+ from pathlib import Path
15
+ from typing import TYPE_CHECKING, Annotated
16
+
17
+ import typer
18
+
19
+ if TYPE_CHECKING:
20
+ from loadcoach.config import StorageSettings
21
+ from loadcoach.services.database import Database
22
+
23
+ __all__ = ["app"]
24
+
25
+ app = typer.Typer(help="Database migration and maintenance.")
26
+
27
+
28
+ @contextmanager
29
+ def _open_database(config: str | None) -> Iterator[tuple[Database, StorageSettings]]:
30
+ """Resolve configuration and open one database handle for this command, or exit 3.
31
+
32
+ One handle per command, closed on the way out — the CLI is one-shot, so it neither needs nor
33
+ wants the server's application-lifetime engine.
34
+ """
35
+ from loadcoach.config import ConfigurationError, load_settings
36
+ from loadcoach.services.database import Database
37
+
38
+ try:
39
+ loaded = load_settings(config_path=config)
40
+ except ConfigurationError as exc:
41
+ typer.echo(f"Error: {exc.message} ({exc.code})", err=True)
42
+ raise typer.Exit(3) from exc
43
+ storage = loaded.settings.storage
44
+ if storage.database_url is None: # pragma: no cover — StorageSettings always fills this in
45
+ typer.echo("Error: no database_url configured (CONFIGURATION_ERROR)", err=True)
46
+ raise typer.Exit(3)
47
+ with Database.from_url(
48
+ storage.database_url, statement_timeout_ms=storage.statement_timeout_ms
49
+ ) as database:
50
+ yield database, storage
51
+
52
+
53
+ def _human_bytes(value: int) -> str:
54
+ """Render a byte count at human scale, exact below 1 KiB."""
55
+ if value < 1024:
56
+ return f"{value} B"
57
+ scaled = float(value)
58
+ for unit in ("KiB", "MiB", "GiB"):
59
+ scaled /= 1024
60
+ if scaled < 1024 or unit == "GiB":
61
+ return f"{scaled:.1f} {unit}"
62
+ raise AssertionError("unreachable: the GiB branch always returns") # pragma: no cover
63
+
64
+
65
+ def _fail(exc: Exception) -> typer.Exit:
66
+ """Print the standard CLI error line and return the exit-4 (dependency unavailable) signal."""
67
+ code = getattr(exc, "code", "DATABASE_ERROR")
68
+ typer.echo(f"Error: {exc} ({code})", err=True)
69
+ return typer.Exit(4)
70
+
71
+
72
+ @app.command("upgrade")
73
+ def upgrade(
74
+ revision: Annotated[str, typer.Argument(help="Target revision.")] = "head",
75
+ config: Annotated[
76
+ str | None, typer.Option("--config", help="Path to a config.toml file.")
77
+ ] = None,
78
+ json_output: Annotated[
79
+ bool, typer.Option("--json", help="Print JSON instead of text.")
80
+ ] = False,
81
+ ) -> None:
82
+ """Migrate the database to REVISION. Mode: local. A no-op at the target revision (exit 0).
83
+
84
+ Example:
85
+ loadcoach db upgrade
86
+ """
87
+ from weightsdb import DatabaseError
88
+
89
+ from loadcoach.services.database import upgrade as upgrade_database
90
+
91
+ with _open_database(config) as (database, storage):
92
+ try:
93
+ outcome = upgrade_database(
94
+ database, revision=revision, backup_retention=storage.backup_retention
95
+ )
96
+ except DatabaseError as exc:
97
+ raise _fail(exc) from exc
98
+
99
+ if json_output:
100
+ typer.echo(
101
+ json.dumps(
102
+ {
103
+ "from_revision": outcome.from_revision,
104
+ "to_revision": outcome.to_revision,
105
+ "backed_up": outcome.backed_up,
106
+ "backup_path": str(outcome.backup_path) if outcome.backup_path else None,
107
+ "pruned_backups": [str(path) for path in outcome.pruned_backups],
108
+ "restore_on_failure_available": outcome.restore_on_failure_available,
109
+ }
110
+ )
111
+ )
112
+ else:
113
+ typer.echo(f"{outcome.from_revision or '(empty)'} -> {outcome.to_revision}")
114
+ if outcome.backed_up:
115
+ typer.echo(f"Backup written to {outcome.backup_path}")
116
+ for path in outcome.pruned_backups:
117
+ typer.echo(f"Rotated out old backup {path}")
118
+
119
+
120
+ @app.command("status")
121
+ def status(
122
+ config: Annotated[
123
+ str | None, typer.Option("--config", help="Path to a config.toml file.")
124
+ ] = None,
125
+ json_output: Annotated[
126
+ bool, typer.Option("--json", help="Print JSON instead of a table.")
127
+ ] = False,
128
+ ) -> None:
129
+ """Report revision, table row counts and integrity status. Mode: local.
130
+
131
+ Example:
132
+ loadcoach db status --json
133
+ """
134
+ from weightsdb import DatabaseError
135
+
136
+ from loadcoach.services.database import get_status
137
+
138
+ with _open_database(config) as (database, _):
139
+ try:
140
+ report = get_status(database)
141
+ except DatabaseError as exc:
142
+ raise _fail(exc) from exc
143
+
144
+ if json_output:
145
+ typer.echo(
146
+ json.dumps(
147
+ {
148
+ "dialect": report.dialect,
149
+ "current_revision": report.current_revision,
150
+ "head_revision": report.head_revision,
151
+ "is_at_head": report.is_at_head,
152
+ "table_row_counts": report.table_row_counts,
153
+ "size_bytes": report.size_bytes,
154
+ "integrity_ok": report.integrity_ok,
155
+ "integrity_detail": report.integrity_detail,
156
+ }
157
+ )
158
+ )
159
+ return
160
+ typer.echo(f"dialect: {report.dialect}")
161
+ typer.echo(
162
+ f"revision: {report.current_revision or '(none)'} (head: {report.head_revision})"
163
+ )
164
+ typer.echo(f"at head: {report.is_at_head}")
165
+ typer.echo(f"size: {_human_bytes(report.size_bytes)}")
166
+ typer.echo(f"integrity: {'ok' if report.integrity_ok else report.integrity_detail}")
167
+ for table, count in sorted(report.table_row_counts.items()):
168
+ typer.echo(f" {table:<24} {count}")
169
+
170
+
171
+ @app.command("backup")
172
+ def backup(
173
+ output: Annotated[
174
+ Path | None, typer.Option("--output", "-o", help="Backup destination.")
175
+ ] = None,
176
+ config: Annotated[
177
+ str | None, typer.Option("--config", help="Path to a config.toml file.")
178
+ ] = None,
179
+ json_output: Annotated[
180
+ bool, typer.Option("--json", help="Print JSON instead of text.")
181
+ ] = False,
182
+ ) -> None:
183
+ """Take a consistent backup of the database. Mode: local.
184
+
185
+ Example:
186
+ loadcoach db backup --output ./loadcoach-before-upgrade.sqlite3
187
+ """
188
+ from weightsdb import DatabaseError
189
+
190
+ from loadcoach.services.database import backup_database
191
+
192
+ with _open_database(config) as (database, storage):
193
+ try:
194
+ result = backup_database(database, output=output, keep=storage.backup_retention)
195
+ except DatabaseError as exc:
196
+ raise _fail(exc) from exc
197
+
198
+ if json_output:
199
+ typer.echo(
200
+ json.dumps(
201
+ {
202
+ "path": str(result.path),
203
+ "size_bytes": result.size_bytes,
204
+ "created_at": result.created_at.isoformat(),
205
+ "dialect": result.dialect,
206
+ "pruned": [str(path) for path in result.pruned],
207
+ }
208
+ )
209
+ )
210
+ else:
211
+ typer.echo(str(result.path))
212
+ for path in result.pruned:
213
+ typer.echo(f"Rotated out old backup {path}")
214
+
215
+
216
+ @app.command("restore")
217
+ def restore(
218
+ source: Annotated[Path, typer.Argument(help="Backup file to restore from.")],
219
+ yes: Annotated[
220
+ bool, typer.Option("--yes", "-y", help="Confirm the restore; required, non-interactive.")
221
+ ] = False,
222
+ config: Annotated[
223
+ str | None, typer.Option("--config", help="Path to a config.toml file.")
224
+ ] = None,
225
+ ) -> None:
226
+ """Restore the database from SOURCE, overwriting the current one. Mode: local.
227
+
228
+ Requires ``--yes``: there is no interactive prompt (CLI standards §5), and refusing without it
229
+ is exit 2 naming the flag that would have answered it.
230
+
231
+ Example:
232
+ loadcoach db restore ./backups/loadcoach-0001-20260829T090000Z.sqlite3 --yes
233
+ """
234
+ from weightsdb import DatabaseError
235
+
236
+ from loadcoach.services.database import restore_database
237
+
238
+ if not yes:
239
+ typer.echo("Error: --yes is required to confirm this destructive operation.", err=True)
240
+ raise typer.Exit(2)
241
+
242
+ with _open_database(config) as (database, _):
243
+ try:
244
+ result = restore_database(database, source=source, confirm=True)
245
+ except DatabaseError as exc:
246
+ raise _fail(exc) from exc
247
+ typer.echo(
248
+ f"Restored {result.path} from {result.source} (revision {result.revision or '(none)'})"
249
+ )