hawser-server 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 (40) hide show
  1. hawser_server/__init__.py +11 -0
  2. hawser_server/app.py +115 -0
  3. hawser_server/auth.py +187 -0
  4. hawser_server/cli.py +255 -0
  5. hawser_server/config.py +90 -0
  6. hawser_server/db.py +143 -0
  7. hawser_server/deps.py +68 -0
  8. hawser_server/errors.py +125 -0
  9. hawser_server/jobs.py +140 -0
  10. hawser_server/migrations.py +66 -0
  11. hawser_server/py.typed +0 -0
  12. hawser_server/routes/__init__.py +1 -0
  13. hawser_server/routes/approvals.py +50 -0
  14. hawser_server/routes/artifacts.py +41 -0
  15. hawser_server/routes/assets.py +90 -0
  16. hawser_server/routes/catalog.py +23 -0
  17. hawser_server/routes/evals.py +143 -0
  18. hawser_server/routes/examples.py +50 -0
  19. hawser_server/routes/reviews.py +144 -0
  20. hawser_server/routes/runs.py +286 -0
  21. hawser_server/routes/settings.py +58 -0
  22. hawser_server/routes/system.py +27 -0
  23. hawser_server/routes/webhooks.py +69 -0
  24. hawser_server/schemas.py +167 -0
  25. hawser_server/services.py +118 -0
  26. hawser_server/sse.py +58 -0
  27. hawser_server/static/.gitkeep +0 -0
  28. hawser_server/static/assets/index-BBvdMNrB.css +1 -0
  29. hawser_server/static/assets/index-C3C5r10w.js +264 -0
  30. hawser_server/static/index.html +13 -0
  31. hawser_server/storage.py +92 -0
  32. hawser_server/testing.py +62 -0
  33. hawser_server/util.py +41 -0
  34. hawser_server/webhooks.py +326 -0
  35. hawser_server/worker.py +190 -0
  36. hawser_server-0.1.0.dist-info/METADATA +70 -0
  37. hawser_server-0.1.0.dist-info/RECORD +40 -0
  38. hawser_server-0.1.0.dist-info/WHEEL +4 -0
  39. hawser_server-0.1.0.dist-info/entry_points.txt +2 -0
  40. hawser_server-0.1.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,11 @@
1
+ """Hawser control plane: ``hawserd`` (ADR-0010)."""
2
+
3
+ from importlib.metadata import PackageNotFoundError
4
+ from importlib.metadata import version as _pkg_version
5
+
6
+ try:
7
+ __version__ = _pkg_version("hawser-server")
8
+ except PackageNotFoundError: # running from a source tree without installed metadata
9
+ __version__ = "0.1.0"
10
+
11
+ __all__ = ["__version__"]
hawser_server/app.py ADDED
@@ -0,0 +1,115 @@
1
+ """The FastAPI application. ``create_app(services)`` wires routes, errors and request ids."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from collections.abc import Awaitable, Callable
7
+ from importlib import resources
8
+ from pathlib import Path
9
+
10
+ from fastapi import FastAPI, Request, Response
11
+ from fastapi.middleware.cors import CORSMiddleware
12
+ from fastapi.responses import FileResponse, RedirectResponse
13
+
14
+ from hawser.models import CONTRACT_VERSION
15
+ from hawser_server import __version__
16
+ from hawser_server.errors import install_error_handlers
17
+ from hawser_server.routes import (
18
+ approvals,
19
+ artifacts,
20
+ assets,
21
+ catalog,
22
+ evals,
23
+ examples,
24
+ reviews,
25
+ runs,
26
+ settings,
27
+ system,
28
+ webhooks,
29
+ )
30
+ from hawser_server.services import Services
31
+
32
+ DESCRIPTION = (
33
+ "Hawser control plane. Bodies and responses are the contract models published under "
34
+ "`api/schemas/`; errors are `application/problem+json` with the error-taxonomy `code`."
35
+ )
36
+
37
+
38
+ def create_app(services: Services) -> FastAPI:
39
+ app = FastAPI(
40
+ title="Hawser API",
41
+ version=f"{CONTRACT_VERSION}+hawserd.{__version__}",
42
+ description=DESCRIPTION,
43
+ docs_url="/docs",
44
+ openapi_url="/openapi.json",
45
+ )
46
+ app.state.services = services
47
+ install_error_handlers(app)
48
+
49
+ @app.middleware("http")
50
+ async def _request_id(
51
+ request: Request, call_next: Callable[[Request], Awaitable[Response]]
52
+ ) -> Response:
53
+ rid = request.headers.get("X-Hawser-Request-Id") or f"req_{uuid.uuid4().hex[:20]}"
54
+ request.state.request_id = rid
55
+ response = await call_next(request)
56
+ response.headers["X-Hawser-Request-Id"] = rid
57
+ return response
58
+
59
+ if services.config.cors_origins:
60
+ app.add_middleware(
61
+ CORSMiddleware,
62
+ allow_origins=services.config.cors_origins,
63
+ allow_methods=["*"],
64
+ allow_headers=["*"],
65
+ expose_headers=["ETag", "Location", "X-Hawser-Request-Id", "X-Hawser-Dpi"],
66
+ )
67
+
68
+ for r in (
69
+ system,
70
+ assets,
71
+ runs,
72
+ reviews,
73
+ approvals,
74
+ catalog,
75
+ artifacts,
76
+ webhooks,
77
+ evals,
78
+ settings,
79
+ examples,
80
+ ):
81
+ app.include_router(r.router)
82
+ _mount_web(app, services.config.web_dir)
83
+ return app
84
+
85
+
86
+ def web_dir(configured: Path | None) -> Path | None:
87
+ """The built web app: an explicit directory, else the package's ``static`` folder."""
88
+ if configured is not None:
89
+ return configured if (configured / "index.html").exists() else None
90
+ with resources.as_file(resources.files("hawser_server") / "static") as path:
91
+ bundled = Path(path)
92
+ return bundled if (bundled / "index.html").exists() else None
93
+
94
+
95
+ def _mount_web(app: FastAPI, configured: Path | None) -> None:
96
+ root = web_dir(configured)
97
+ if root is None:
98
+ return
99
+ index = root / "index.html"
100
+
101
+ @app.get("/", include_in_schema=False)
102
+ async def _root() -> RedirectResponse:
103
+ return RedirectResponse("/app/")
104
+
105
+ @app.get("/app", include_in_schema=False)
106
+ @app.get("/app/{path:path}", include_in_schema=False)
107
+ async def _app(path: str = "") -> FileResponse:
108
+ # Single-page app: real files are served as-is, every other path gets index.html.
109
+ candidate = (root / path).resolve() if path else index
110
+ if path and candidate.is_file() and root.resolve() in candidate.parents:
111
+ return FileResponse(candidate)
112
+ return FileResponse(index)
113
+
114
+
115
+ __all__ = ["create_app"]
hawser_server/auth.py ADDED
@@ -0,0 +1,187 @@
1
+ """API keys and tenants.
2
+
3
+ Keys are ``hk_live_<40 url-safe chars>``, shown once, stored as an argon2id hash. Lookup is by
4
+ a short hash prefix (so any token shape works for ``HAWSERD_DEV_API_KEY``), then a full verify.
5
+ Verified tokens are cached in memory for a few minutes to keep argon2 off the request path.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import secrets
12
+ import threading
13
+ import time
14
+ from dataclasses import dataclass
15
+
16
+ from argon2 import PasswordHasher
17
+ from argon2.exceptions import VerificationError
18
+
19
+ from hawser.config import LOCAL_TENANT
20
+ from hawser.ids import new_id
21
+ from hawser.storage.sql import SqlBackend
22
+ from hawser_server.util import ts
23
+
24
+ KEY_PREFIX = "hk_live_"
25
+ _CACHE_TTL_S = 300.0
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class Principal:
30
+ tenant_id: str
31
+ key_id: str
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class Tenant:
36
+ id: str
37
+ name: str
38
+ created_at: str
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class ApiKey:
43
+ id: str
44
+ tenant_id: str
45
+ name: str
46
+ prefix: str
47
+ created_at: str
48
+ revoked_at: str | None
49
+ last_used_at: str | None
50
+
51
+
52
+ def _prefix(token: str) -> str:
53
+ return hashlib.sha256(token.encode()).hexdigest()[:16]
54
+
55
+
56
+ def generate_key() -> str:
57
+ return KEY_PREFIX + secrets.token_urlsafe(30)[:40]
58
+
59
+
60
+ class AuthService:
61
+ def __init__(self, backend: SqlBackend) -> None:
62
+ self.backend = backend
63
+ self.hasher = PasswordHasher()
64
+ self._cache: dict[str, tuple[Principal, float]] = {}
65
+ self._lock = threading.Lock()
66
+
67
+ # -- tenants ---------------------------------------------------------------------------
68
+ def create_tenant(self, name: str, *, tenant_id: str | None = None) -> Tenant:
69
+ tenant = Tenant(id=tenant_id or new_id("ten"), name=name, created_at=ts())
70
+ with self.backend.transaction(write=True) as tx:
71
+ tx.execute(
72
+ "INSERT INTO tenants (id, name, created_at) VALUES (?,?,?) ON CONFLICT DO NOTHING",
73
+ (tenant.id, tenant.name, tenant.created_at),
74
+ )
75
+ return tenant
76
+
77
+ def get_tenant(self, tenant_id: str) -> Tenant | None:
78
+ with self.backend.transaction() as tx:
79
+ rows = tx.execute("SELECT id, name, created_at FROM tenants WHERE id = ?", (tenant_id,))
80
+ return None if not rows else Tenant(*(str(c) for c in rows[0]))
81
+
82
+ def list_tenants(self) -> list[Tenant]:
83
+ with self.backend.transaction() as tx:
84
+ rows = tx.execute("SELECT id, name, created_at FROM tenants ORDER BY created_at")
85
+ return [Tenant(*(str(c) for c in r)) for r in rows]
86
+
87
+ # -- keys ------------------------------------------------------------------------------
88
+ def create_key(
89
+ self, tenant_id: str, name: str, *, token: str | None = None
90
+ ) -> tuple[ApiKey, str]:
91
+ """Create a key; returns the record and the plaintext token (shown once)."""
92
+ if self.get_tenant(tenant_id) is None:
93
+ raise KeyError(tenant_id)
94
+ token = token or generate_key()
95
+ key = ApiKey(
96
+ id=new_id("key"),
97
+ tenant_id=tenant_id,
98
+ name=name,
99
+ prefix=_prefix(token),
100
+ created_at=ts(),
101
+ revoked_at=None,
102
+ last_used_at=None,
103
+ )
104
+ with self.backend.transaction(write=True) as tx:
105
+ tx.execute(
106
+ "INSERT INTO api_keys (id, tenant_id, name, prefix, hash, created_at)"
107
+ " VALUES (?,?,?,?,?,?)",
108
+ (
109
+ key.id,
110
+ key.tenant_id,
111
+ key.name,
112
+ key.prefix,
113
+ self.hasher.hash(token),
114
+ key.created_at,
115
+ ),
116
+ )
117
+ return key, token
118
+
119
+ def revoke_key(self, key_id: str) -> bool:
120
+ with self.backend.transaction(write=True) as tx:
121
+ tx.execute(
122
+ "UPDATE api_keys SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL",
123
+ (ts(), key_id),
124
+ )
125
+ rows = tx.execute("SELECT revoked_at FROM api_keys WHERE id = ?", (key_id,))
126
+ with self._lock:
127
+ self._cache = {k: v for k, v in self._cache.items() if v[0].key_id != key_id}
128
+ return bool(rows) and rows[0][0] is not None
129
+
130
+ def list_keys(self, tenant_id: str) -> list[ApiKey]:
131
+ with self.backend.transaction() as tx:
132
+ rows = tx.execute(
133
+ "SELECT id, tenant_id, name, prefix, created_at, revoked_at, last_used_at"
134
+ " FROM api_keys WHERE tenant_id = ? ORDER BY created_at",
135
+ (tenant_id,),
136
+ )
137
+ return [
138
+ ApiKey(
139
+ id=str(r[0]),
140
+ tenant_id=str(r[1]),
141
+ name=str(r[2]),
142
+ prefix=str(r[3]),
143
+ created_at=str(r[4]),
144
+ revoked_at=None if r[5] is None else str(r[5]),
145
+ last_used_at=None if r[6] is None else str(r[6]),
146
+ )
147
+ for r in rows
148
+ ]
149
+
150
+ def authenticate(self, token: str) -> Principal | None:
151
+ cache_key = hashlib.sha256(token.encode()).hexdigest()
152
+ with self._lock:
153
+ hit = self._cache.get(cache_key)
154
+ if hit is not None and hit[1] > time.monotonic():
155
+ return hit[0]
156
+ with self.backend.transaction() as tx:
157
+ rows = tx.execute(
158
+ "SELECT id, tenant_id, hash FROM api_keys WHERE prefix = ? AND revoked_at IS NULL",
159
+ (_prefix(token),),
160
+ )
161
+ for key_id, tenant_id, digest in rows:
162
+ try:
163
+ self.hasher.verify(str(digest), token)
164
+ except VerificationError:
165
+ continue
166
+ principal = Principal(tenant_id=str(tenant_id), key_id=str(key_id))
167
+ with self._lock:
168
+ self._cache[cache_key] = (principal, time.monotonic() + _CACHE_TTL_S)
169
+ with self.backend.transaction(write=True) as tx:
170
+ tx.execute("UPDATE api_keys SET last_used_at = ? WHERE id = ?", (ts(), key_id))
171
+ return principal
172
+ return None
173
+
174
+ # -- bootstrap -------------------------------------------------------------------------
175
+ def ensure_dev_key(self, token: str) -> Principal:
176
+ """Development only: make ``token`` a valid key for the local tenant."""
177
+ existing = self.authenticate(token)
178
+ if existing is not None:
179
+ return existing
180
+ tenant = self.get_tenant(LOCAL_TENANT) or self.create_tenant(
181
+ "local", tenant_id=LOCAL_TENANT
182
+ )
183
+ key, _ = self.create_key(tenant.id, "dev", token=token)
184
+ return Principal(tenant_id=tenant.id, key_id=key.id)
185
+
186
+
187
+ __all__ = ["ApiKey", "AuthService", "Principal", "Tenant", "generate_key"]
hawser_server/cli.py ADDED
@@ -0,0 +1,255 @@
1
+ """``hawserd``: serve, worker, migrate, admin, openapi."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import os
8
+ import secrets
9
+ import tempfile
10
+ from pathlib import Path
11
+ from typing import Annotated, Any
12
+
13
+ import typer
14
+ from rich.console import Console
15
+
16
+ from hawser_server import __version__
17
+ from hawser_server.config import ServerConfig
18
+ from hawser_server.services import Services
19
+
20
+ app = typer.Typer(name="hawserd", help="Hawser control plane.", no_args_is_help=True)
21
+ admin_app = typer.Typer(help="Tenants and API keys.", no_args_is_help=True)
22
+ app.add_typer(admin_app, name="admin")
23
+ console = Console()
24
+ err = Console(stderr=True)
25
+
26
+ DEV_DIR = Path(".hawserd")
27
+
28
+
29
+ def _version(value: bool) -> None:
30
+ if value:
31
+ console.print(f"hawserd {__version__}")
32
+ raise typer.Exit()
33
+
34
+
35
+ @app.callback()
36
+ def _main(
37
+ version: Annotated[
38
+ bool, typer.Option("--version", callback=_version, is_eager=True, help="Show the version.")
39
+ ] = False,
40
+ ) -> None:
41
+ """Hawser control plane: API, durable worker, migrations."""
42
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s")
43
+
44
+
45
+ def _dev_key() -> str:
46
+ explicit = os.environ.get("HAWSERD_DEV_API_KEY")
47
+ if explicit:
48
+ return explicit
49
+ DEV_DIR.mkdir(exist_ok=True)
50
+ path = DEV_DIR / "dev.key"
51
+ if path.exists():
52
+ return path.read_text(encoding="utf-8").strip()
53
+ key = "hk_live_" + secrets.token_urlsafe(30)[:40]
54
+ path.write_text(key + "\n", encoding="utf-8")
55
+ path.chmod(0o600)
56
+ return key
57
+
58
+
59
+ def _config(*, dev: bool, **overrides: Any) -> ServerConfig:
60
+ if dev:
61
+ overrides.setdefault("dev_api_key", _dev_key())
62
+ return ServerConfig.load(**overrides)
63
+
64
+
65
+ @app.command()
66
+ def serve( # noqa: PLR0917 - CLI options
67
+ host: Annotated[str | None, typer.Option("--host")] = None,
68
+ port: Annotated[int | None, typer.Option("--port")] = None,
69
+ dev: Annotated[
70
+ bool, typer.Option("--dev", help="SQLite, local files, in-process worker, dev API key.")
71
+ ] = False,
72
+ with_worker: Annotated[
73
+ bool, typer.Option("--with-worker", help="Run a worker in this process too.")
74
+ ] = False,
75
+ migrate: Annotated[
76
+ bool, typer.Option("--migrate", help="Apply pending migrations before serving.")
77
+ ] = False,
78
+ web_dir: Annotated[
79
+ Path | None, typer.Option("--web-dir", help="Built web app to serve at /app.")
80
+ ] = None,
81
+ ) -> None:
82
+ """Serve the HTTP API (and the web app at /app when a build is available)."""
83
+ import uvicorn # noqa: PLC0415 - heavy import only when serving
84
+
85
+ from hawser_server.app import create_app # noqa: PLC0415
86
+ from hawser_server.worker import Worker # noqa: PLC0415
87
+
88
+ cfg = _config(dev=dev, host=host, port=port, web_dir=web_dir)
89
+ svc = Services(cfg, migrate=True if (migrate or dev) else None)
90
+ who = svc.bootstrap()
91
+ if dev and who is not None:
92
+ console.print(
93
+ f"[bold]dev mode[/]: database {cfg.database_url}, artifacts {cfg.artifacts_dir}\n"
94
+ f"API key: [bold]{cfg.dev_api_key}[/] (tenant {who.tenant_id})\n"
95
+ f" export HAWSER_SERVER_URL=http://{cfg.host}:{cfg.port} "
96
+ f"HAWSER_API_KEY={cfg.dev_api_key}"
97
+ )
98
+ worker: Worker | None = None
99
+ if with_worker or dev:
100
+ worker = Worker(svc)
101
+ worker.start()
102
+ try:
103
+ uvicorn.run(create_app(svc), host=cfg.host, port=cfg.port, log_level="info")
104
+ finally:
105
+ if worker is not None:
106
+ worker.stop(wait=False)
107
+ svc.close()
108
+
109
+
110
+ @app.command()
111
+ def worker(
112
+ concurrency: Annotated[int | None, typer.Option("--concurrency", "-c")] = None,
113
+ dev: Annotated[bool, typer.Option("--dev")] = False,
114
+ ) -> None:
115
+ """Run a durable worker (lease runs, execute from checkpoints, deliver webhooks)."""
116
+ from hawser_server.worker import Worker # noqa: PLC0415
117
+
118
+ cfg = _config(dev=dev)
119
+ svc = Services(cfg, migrate=True if dev else None)
120
+ svc.bootstrap()
121
+ w = Worker(svc, concurrency=concurrency)
122
+ try:
123
+ w.run_forever()
124
+ except KeyboardInterrupt:
125
+ w.stop()
126
+ finally:
127
+ svc.close()
128
+
129
+
130
+ @app.command()
131
+ def migrate(
132
+ status: Annotated[bool, typer.Option("--status", help="Show what is applied.")] = False,
133
+ ) -> None:
134
+ """Apply pending migrations (forward-only)."""
135
+ from hawser_server.db import open_backend # noqa: PLC0415
136
+ from hawser_server.migrations import ALL_MIGRATIONS # noqa: PLC0415
137
+
138
+ cfg = ServerConfig.load()
139
+ backend = open_backend(cfg.database_url)
140
+ try:
141
+ if status:
142
+ with backend.transaction() as tx:
143
+ tx.execute(
144
+ "CREATE TABLE IF NOT EXISTS schema_migrations"
145
+ " (name TEXT PRIMARY KEY, applied_at TEXT NOT NULL)"
146
+ )
147
+ done = {
148
+ str(r[0]): str(r[1])
149
+ for r in tx.execute("SELECT name, applied_at FROM schema_migrations")
150
+ }
151
+ for m in ALL_MIGRATIONS:
152
+ mark = (
153
+ f"[green]applied {done[m.name]}[/]" if m.name in done else "[yellow]pending[/]"
154
+ )
155
+ console.print(f"{m.name:24} {mark}")
156
+ return
157
+ from hawser.storage.schema import apply_migrations # noqa: PLC0415
158
+
159
+ applied = apply_migrations(backend, ALL_MIGRATIONS)
160
+ console.print(
161
+ f"applied {len(applied)} migration(s)" + (": " + ", ".join(applied) if applied else "")
162
+ )
163
+ finally:
164
+ backend.close()
165
+
166
+
167
+ @admin_app.command("create-tenant")
168
+ def create_tenant(
169
+ name: Annotated[str, typer.Argument()],
170
+ tenant_id: Annotated[str | None, typer.Option("--id", help="Explicit ten_... id.")] = None,
171
+ ) -> None:
172
+ """Create a tenant."""
173
+ svc = Services(ServerConfig.load(), migrate=None)
174
+ try:
175
+ t = svc.auth.create_tenant(name, tenant_id=tenant_id)
176
+ console.print(f"tenant [bold]{t.id}[/] ({t.name})")
177
+ finally:
178
+ svc.close()
179
+
180
+
181
+ @admin_app.command("list-tenants")
182
+ def list_tenants() -> None:
183
+ svc = Services(ServerConfig.load(), migrate=None)
184
+ try:
185
+ for t in svc.auth.list_tenants():
186
+ console.print(f"{t.id} {t.name} {t.created_at}")
187
+ finally:
188
+ svc.close()
189
+
190
+
191
+ @admin_app.command("create-key")
192
+ def create_key(
193
+ tenant: Annotated[str, typer.Option("--tenant", help="Tenant id.")],
194
+ name: Annotated[str, typer.Option("--name")] = "default",
195
+ ) -> None:
196
+ """Create an API key for a tenant. The key is printed once."""
197
+ svc = Services(ServerConfig.load(), migrate=None)
198
+ try:
199
+ try:
200
+ key, token = svc.auth.create_key(tenant, name)
201
+ except KeyError:
202
+ err.print(f"[red]unknown tenant {tenant}[/]")
203
+ raise typer.Exit(1) from None
204
+ console.print(f"key [bold]{key.id}[/] for {tenant}\n{token}")
205
+ finally:
206
+ svc.close()
207
+
208
+
209
+ @admin_app.command("revoke-key")
210
+ def revoke_key(key_id: Annotated[str, typer.Argument()]) -> None:
211
+ svc = Services(ServerConfig.load(), migrate=None)
212
+ try:
213
+ if svc.auth.revoke_key(key_id):
214
+ console.print(f"revoked {key_id}")
215
+ else:
216
+ err.print(f"[red]unknown key {key_id}[/]")
217
+ raise typer.Exit(1)
218
+ finally:
219
+ svc.close()
220
+
221
+
222
+ def openapi_document() -> str:
223
+ """The OpenAPI document, rendered deterministically."""
224
+ from hawser_server.app import create_app # noqa: PLC0415
225
+
226
+ cfg = ServerConfig(
227
+ database_url="sqlite:///:memory:", artifacts_dir=Path(tempfile.mkdtemp(prefix="hawserd-"))
228
+ )
229
+ svc = Services(cfg, migrate=True)
230
+ try:
231
+ doc = create_app(svc).openapi()
232
+ finally:
233
+ svc.close()
234
+ return json.dumps(doc, indent=2, sort_keys=True, ensure_ascii=False) + "\n"
235
+
236
+
237
+ @app.command()
238
+ def openapi(
239
+ out: Annotated[Path, typer.Option("--out")] = Path("api/openapi/openapi.json"),
240
+ check: Annotated[bool, typer.Option("--check", help="Fail if the file is stale.")] = False,
241
+ ) -> None:
242
+ """Write (or verify) the generated OpenAPI document."""
243
+ text = openapi_document()
244
+ if check:
245
+ if not out.exists() or out.read_text(encoding="utf-8") != text:
246
+ err.print(f"[red]{out} is stale; run `hawserd openapi`[/]")
247
+ raise typer.Exit(1)
248
+ console.print(f"{out} is up to date")
249
+ return
250
+ out.parent.mkdir(parents=True, exist_ok=True)
251
+ out.write_text(text, encoding="utf-8")
252
+ console.print(f"wrote {out}")
253
+
254
+
255
+ __all__ = ["app", "openapi_document"]
@@ -0,0 +1,90 @@
1
+ """Server configuration: ``HAWSERD_*`` environment variables over defaults.
2
+
3
+ The runtime settings the engine needs (default model, record mode, skill paths, limits) come
4
+ from the regular ``HAWSER_*`` variables through :class:`hawser.config.HawserConfig`.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
14
+
15
+ from hawser.config import HawserConfig
16
+
17
+ ENV_PREFIX = "HAWSERD_"
18
+
19
+
20
+ class ServerConfig(BaseModel):
21
+ model_config = ConfigDict(extra="forbid")
22
+
23
+ #: ``postgresql://user:pass@host:5432/db`` or ``sqlite:///path/to/file.db`` (single node).
24
+ database_url: str = "sqlite:///./.hawserd/hawserd.db"
25
+ #: Local artifact directory; ignored when ``s3_bucket`` is set.
26
+ artifacts_dir: Path = Path("./.hawserd/store")
27
+ s3_bucket: str | None = None
28
+ s3_endpoint_url: str | None = None
29
+ s3_region: str | None = None
30
+ s3_access_key: str | None = None
31
+ s3_secret_key: str | None = None
32
+ s3_presign_ttl_s: int = Field(default=900, ge=1, le=7 * 24 * 3600)
33
+ #: Serve artifact bytes through the API instead of presigned URLs.
34
+ artifacts_proxy: bool = True
35
+
36
+ host: str = "127.0.0.1"
37
+ port: int = 8080
38
+ #: Base URL clients use to reach this server (in webhook payloads and Location headers).
39
+ public_url: str = "http://127.0.0.1:8080"
40
+
41
+ #: Bootstrap one tenant and this API key at start-up. Development and Compose only.
42
+ dev_api_key: str | None = None
43
+
44
+ lease_seconds: int = Field(default=60, ge=5)
45
+ worker_concurrency: int = Field(default=2, ge=1)
46
+ worker_max_attempts: int = Field(default=3, ge=1)
47
+ poll_interval_s: float = Field(default=0.5, gt=0)
48
+ max_upload_bytes: int = 50 * 1024 * 1024
49
+
50
+ webhook_timeout_s: float = 10.0
51
+ #: Give up on a delivery after this long (dead letter).
52
+ webhook_max_age_s: int = 24 * 3600
53
+
54
+ sse_keepalive_s: float = 15.0
55
+
56
+ #: Browser origins allowed to call the API (the Vite dev server by default).
57
+ cors_origins: list[str] = ["http://localhost:5173", "http://127.0.0.1:5173"]
58
+ #: Directory with the built web app (``web/dist``); default: the bundled ``static`` folder.
59
+ web_dir: Path | None = None
60
+ #: Rendered-page cache entries kept in memory per process.
61
+ page_cache_entries: int = 64
62
+
63
+ #: Engine configuration (``HAWSER_*``).
64
+ runtime: HawserConfig = Field(default_factory=HawserConfig.load)
65
+
66
+ @field_validator("cors_origins", mode="before")
67
+ @classmethod
68
+ def _split_origins(cls, value: Any) -> Any:
69
+ if isinstance(value, str):
70
+ return [o.strip() for o in value.split(",") if o.strip()]
71
+ return value
72
+
73
+ @classmethod
74
+ def load(cls, **overrides: Any) -> ServerConfig:
75
+ values: dict[str, Any] = {}
76
+ for name in cls.model_fields:
77
+ if name == "runtime":
78
+ continue
79
+ env = os.environ.get(f"{ENV_PREFIX}{name.upper()}")
80
+ if env: # empty means unset (compose passes blanks through)
81
+ values[name] = env
82
+ values.update({k: v for k, v in overrides.items() if v is not None})
83
+ return cls.model_validate(values)
84
+
85
+ @property
86
+ def is_sqlite(self) -> bool:
87
+ return self.database_url.startswith("sqlite:")
88
+
89
+
90
+ __all__ = ["ENV_PREFIX", "ServerConfig"]