نسخهٔ وب مستقل — پلتفرم نیروی کار هوش مصنوعی
مخزن اختصاصی برنامهٔ وب؛ محتوای پوشهٔ web در ریشهٔ مخزن قرار گرفت. تاریخچهٔ پروژهٔ ربات (agy-bot) در این مخزن وجود ندارد و هیچ وابستگی runtime به آن نیست.
This commit is contained in:
@@ -0,0 +1,326 @@
|
||||
"""Caddy adapter for :class:`DomainRouterPort`.
|
||||
|
||||
Publishes tenant preview domains by maintaining a *managed snippet file* that the
|
||||
main Caddyfile imports, then reloading Caddy. The main Caddyfile is never edited
|
||||
by this adapter.
|
||||
|
||||
Why a snippet instead of editing the Caddyfile
|
||||
---------------------------------------------
|
||||
Rewriting an operator's Caddyfile means parsing and re-emitting a file we do not
|
||||
own; a mistake there takes down every site on the host. A separate file that
|
||||
Caddy imports by glob keeps the blast radius to tenant preview routes only.
|
||||
|
||||
Setup (one line, once)
|
||||
----------------------
|
||||
Add this to the Caddyfile and reload Caddy::
|
||||
|
||||
import /etc/caddy/agy-preview/*.caddy
|
||||
|
||||
The adapter refuses to run if that import is missing, because silently writing a
|
||||
file that Caddy never reads would report success for a domain that does not
|
||||
resolve.
|
||||
|
||||
Security rules enforced here, not only upstream
|
||||
-----------------------------------------------
|
||||
* the host must be a single label under ``PREVIEW_ROOT_DOMAIN``
|
||||
(:func:`validate_managed_host`) — this is what stops a crafted host from
|
||||
closing the site block and injecting directives
|
||||
* the proxy target must be loopback with a numeric port (:func:`validate_proxy_target`)
|
||||
— SSRF prevention
|
||||
* protected platform ports are refused (:func:`assert_not_protected_port`)
|
||||
* the rendered config is validated *before* it is loaded, and every failed
|
||||
change is rolled back to the previous file
|
||||
|
||||
The ``caddy`` binary runs on the platform host. That is a different trust domain
|
||||
from tenant code: tenant commands only ever run inside a sandbox adapter.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from app.application.ports import PreviewDomainInfo, ReverseProxyResult
|
||||
from app.core.errors import ProviderUnavailableError
|
||||
from app.domain.platform.rules import (
|
||||
RuleViolation,
|
||||
assert_not_protected_port,
|
||||
assert_port_allocatable,
|
||||
build_host,
|
||||
validate_managed_host,
|
||||
validate_proxy_target,
|
||||
)
|
||||
|
||||
_BEGIN = "# >>> agy-managed: {host}"
|
||||
_END = "# <<< agy-managed: {host}"
|
||||
_BLOCK_RE = re.compile(
|
||||
r"# >>> agy-managed: (?P<host>\S+)\n(?P<body>.*?)# <<< agy-managed: (?P=host)\n",
|
||||
re.DOTALL,
|
||||
)
|
||||
_VALIDATE_TIMEOUT_SECONDS = 15
|
||||
_RELOAD_TIMEOUT_SECONDS = 30
|
||||
|
||||
|
||||
class CaddyDomainRouterAdapter:
|
||||
"""Reverse-proxy routes managed through a Caddy snippet file."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
caddyfile_path: str,
|
||||
root_domain: str,
|
||||
port_start: int = 8080,
|
||||
port_end: int = 8999,
|
||||
snippet_dir: str | None = None,
|
||||
caddy_binary: str = "caddy",
|
||||
) -> None:
|
||||
self.caddyfile_path = Path(caddyfile_path)
|
||||
self.root_domain = root_domain
|
||||
self.port_start = port_start
|
||||
self.port_end = port_end
|
||||
self.caddy_binary = caddy_binary
|
||||
|
||||
# Default to a sibling directory of the Caddyfile so the import glob is
|
||||
# a short, predictable path.
|
||||
self.snippet_dir = (
|
||||
Path(snippet_dir)
|
||||
if snippet_dir
|
||||
else self.caddyfile_path.parent / "agy-preview"
|
||||
)
|
||||
self.snippet_path = self.snippet_dir / "preview.caddy"
|
||||
|
||||
# -- port allocation -----------------------------------------------------
|
||||
|
||||
async def allocate_port(
|
||||
self, *, organization_id: str, project_id: str, service_name: str
|
||||
) -> int:
|
||||
"""Pick the lowest free port that is not already routed.
|
||||
|
||||
The database is the authoritative port ledger
|
||||
(``PreviewDomainRepository``); this only avoids handing out a port that a
|
||||
live route already occupies.
|
||||
"""
|
||||
in_use = {entry.port for entry in self._parse(self._read_snippet())}
|
||||
for port in range(self.port_start, self.port_end + 1):
|
||||
if port in in_use:
|
||||
continue
|
||||
try:
|
||||
assert_port_allocatable(port, start=self.port_start, end=self.port_end)
|
||||
except RuleViolation:
|
||||
continue
|
||||
return port
|
||||
raise ProviderUnavailableError("پورت آزادی در بازهٔ تعیینشده وجود ندارد.")
|
||||
|
||||
async def claim_preview_domain(
|
||||
self,
|
||||
*,
|
||||
organization_id: str,
|
||||
project_id: str,
|
||||
requested_subdomain: str,
|
||||
port: int,
|
||||
) -> PreviewDomainInfo:
|
||||
host = build_host(requested_subdomain, self.root_domain)
|
||||
result = await self.configure_reverse_proxy(host=host, port=port)
|
||||
if not result.ok:
|
||||
raise RuleViolation(result.message or "پیکربندی دامنه ناموفق بود.")
|
||||
return PreviewDomainInfo(host=host, port=port, url=result.url, status="active")
|
||||
|
||||
# -- routing -------------------------------------------------------------
|
||||
|
||||
async def configure_reverse_proxy(self, *, host: str, port: int) -> ReverseProxyResult:
|
||||
"""Create or replace the route for ``host`` and reload Caddy."""
|
||||
try:
|
||||
clean_host = validate_managed_host(host, self.root_domain)
|
||||
# Raises unless the target is loopback with a numeric port.
|
||||
validate_proxy_target(f"localhost:{port}")
|
||||
assert_port_allocatable(port, start=self.port_start, end=self.port_end)
|
||||
assert_not_protected_port(port)
|
||||
except RuleViolation as exc:
|
||||
# A rejected rule is a caller error, not an outage.
|
||||
return ReverseProxyResult(ok=False, message=str(exc))
|
||||
|
||||
if not self._import_is_wired():
|
||||
return ReverseProxyResult(
|
||||
ok=False,
|
||||
message=(
|
||||
"فایل Caddyfile خط import مربوط به AGY را ندارد. "
|
||||
f"این خط را اضافه کنید: import {self.snippet_dir}/*.caddy"
|
||||
),
|
||||
)
|
||||
|
||||
previous = self._read_snippet()
|
||||
entries = [e for e in self._parse(previous) if e.host != clean_host]
|
||||
entries.append(_Entry(host=clean_host, port=port))
|
||||
updated = self._render(entries)
|
||||
|
||||
self._write_snippet(updated)
|
||||
error = await self._validate()
|
||||
if error is None:
|
||||
error = await self._reload()
|
||||
|
||||
if error is not None:
|
||||
# Roll back so a failed change never leaves a half-written config.
|
||||
self._write_snippet(previous)
|
||||
await self._reload()
|
||||
return ReverseProxyResult(ok=False, message=error)
|
||||
|
||||
return ReverseProxyResult(
|
||||
ok=True, message="مسیر دامنه ثبت و Caddy بارگذاری مجدد شد.", url=f"https://{clean_host}"
|
||||
)
|
||||
|
||||
async def remove_preview_domain(self, *, domain_id: str) -> None:
|
||||
"""Remove a route.
|
||||
|
||||
``domain_id`` is the host, matching how the application layer calls this
|
||||
(``remove_preview_domain(domain_id=record["host"])``).
|
||||
"""
|
||||
try:
|
||||
clean_host = validate_managed_host(domain_id, self.root_domain)
|
||||
except RuleViolation:
|
||||
# Not one of ours — nothing to remove, and not an error.
|
||||
return
|
||||
|
||||
previous = self._read_snippet()
|
||||
entries = [e for e in self._parse(previous) if e.host != clean_host]
|
||||
if len(entries) == len(self._parse(previous)):
|
||||
return
|
||||
|
||||
self._write_snippet(self._render(entries))
|
||||
error = await self._validate()
|
||||
if error is None:
|
||||
error = await self._reload()
|
||||
if error is not None:
|
||||
self._write_snippet(previous)
|
||||
await self._reload()
|
||||
|
||||
async def list_project_domains(self, *, project_id: str) -> list[PreviewDomainInfo]:
|
||||
"""Every managed route in the snippet.
|
||||
|
||||
NOT tenant-scoped: the snippet records hosts and ports, not projects, and
|
||||
``configure_reverse_proxy`` is not given a project id. The database
|
||||
(``PreviewDomainRepository``) is the authoritative, tenant-scoped ledger —
|
||||
never use this method for authorization.
|
||||
"""
|
||||
return [
|
||||
PreviewDomainInfo(host=e.host, port=e.port, url=f"https://{e.host}")
|
||||
for e in self._parse(self._read_snippet())
|
||||
]
|
||||
|
||||
# -- file handling -------------------------------------------------------
|
||||
|
||||
def _read_snippet(self) -> str:
|
||||
if not self.snippet_path.exists():
|
||||
return ""
|
||||
return self.snippet_path.read_text(encoding="utf-8")
|
||||
|
||||
def _write_snippet(self, content: str) -> None:
|
||||
"""Write atomically: a reader must never see a partial file."""
|
||||
try:
|
||||
self.snippet_dir.mkdir(parents=True, exist_ok=True)
|
||||
tmp = self.snippet_path.with_suffix(".tmp")
|
||||
tmp.write_text(content, encoding="utf-8")
|
||||
tmp.replace(self.snippet_path)
|
||||
except OSError as exc:
|
||||
raise ProviderUnavailableError(
|
||||
f"نوشتن فایل پیکربندی Caddy ناموفق بود: {exc}"
|
||||
) from exc
|
||||
|
||||
def _import_is_wired(self) -> bool:
|
||||
"""Confirm the Caddyfile actually imports our snippet."""
|
||||
try:
|
||||
text = self.caddyfile_path.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return False
|
||||
needle = str(self.snippet_dir)
|
||||
return any(
|
||||
line.strip().startswith("import") and needle in line
|
||||
for line in text.splitlines()
|
||||
)
|
||||
|
||||
# -- rendering and parsing ----------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _render(entries: list[_Entry]) -> str:
|
||||
header = (
|
||||
"# This file is generated and owned by AGY (web preview domains).\n"
|
||||
"# Manual edits will be overwritten. Managed by CaddyDomainRouterAdapter.\n\n"
|
||||
)
|
||||
blocks = []
|
||||
for entry in sorted(entries, key=lambda e: e.host):
|
||||
blocks.append(
|
||||
f"{_BEGIN.format(host=entry.host)}\n"
|
||||
f"{entry.host} {{\n"
|
||||
f"\treverse_proxy localhost:{entry.port}\n"
|
||||
f"}}\n"
|
||||
f"{_END.format(host=entry.host)}\n"
|
||||
)
|
||||
return header + "\n".join(blocks)
|
||||
|
||||
@staticmethod
|
||||
def _parse(text: str) -> list[_Entry]:
|
||||
entries: list[_Entry] = []
|
||||
for match in _BLOCK_RE.finditer(text or ""):
|
||||
port_match = re.search(r"reverse_proxy\s+localhost:(\d+)", match.group("body"))
|
||||
if not port_match:
|
||||
continue
|
||||
entries.append(_Entry(host=match.group("host"), port=int(port_match.group(1))))
|
||||
return entries
|
||||
|
||||
# -- caddy process -------------------------------------------------------
|
||||
|
||||
async def _run(self, *args: str, timeout: int) -> tuple[int, str]:
|
||||
"""Run the ``caddy`` binary. Platform process, never tenant code."""
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
self.caddy_binary,
|
||||
*args,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
raise ProviderUnavailableError(
|
||||
f"اجرایی «{self.caddy_binary}» پیدا نشد؛ برای DOMAIN_ROUTER=caddy لازم است."
|
||||
) from exc
|
||||
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout)
|
||||
except TimeoutError:
|
||||
process.kill()
|
||||
await process.wait()
|
||||
return 1, "عملیات Caddy زمانبر شد."
|
||||
|
||||
combined = (stderr or stdout or b"").decode("utf-8", errors="replace").strip()
|
||||
return process.returncode or 0, combined
|
||||
|
||||
async def _validate(self) -> str | None:
|
||||
"""Return an error message, or ``None`` when the config is valid."""
|
||||
code, output = await self._run(
|
||||
"validate", "--config", str(self.caddyfile_path),
|
||||
timeout=_VALIDATE_TIMEOUT_SECONDS,
|
||||
)
|
||||
if code != 0:
|
||||
return f"پیکربندی Caddy نامعتبر است: {output[:400]}"
|
||||
return None
|
||||
|
||||
async def _reload(self) -> str | None:
|
||||
code, output = await self._run(
|
||||
"reload", "--config", str(self.caddyfile_path),
|
||||
timeout=_RELOAD_TIMEOUT_SECONDS,
|
||||
)
|
||||
if code != 0:
|
||||
return f"بارگذاری مجدد Caddy ناموفق بود: {output[:400]}"
|
||||
return None
|
||||
|
||||
|
||||
class _Entry:
|
||||
"""One managed route."""
|
||||
|
||||
__slots__ = ("host", "port")
|
||||
|
||||
def __init__(self, *, host: str, port: int) -> None:
|
||||
self.host = host
|
||||
self.port = port
|
||||
|
||||
def __eq__(self, other: object) -> bool:
|
||||
return isinstance(other, _Entry) and (self.host, self.port) == (other.host, other.port)
|
||||
@@ -0,0 +1,412 @@
|
||||
"""Gitea adapter for :class:`GitProviderPort`.
|
||||
|
||||
Talked to over the HTTP API with an admin token. The token is held by this
|
||||
adapter only and is never placed on an object that a response could serialise.
|
||||
|
||||
Design decisions
|
||||
----------------
|
||||
|
||||
**The adapter is stateless.** It is handed ``owner`` / ``repo_name`` by the
|
||||
application layer instead of keeping a ``project_id`` map. A map would be lost on
|
||||
restart and could drift from ``ProjectRepository``, so the database stays the one
|
||||
source of truth for repository identity.
|
||||
|
||||
**Repositories live in one namespace, credentials are per project.** All
|
||||
repositories are created under a single owner (the configured admin user) so
|
||||
tenant repos cannot collide, and every project gets its *own* Gitea user that is
|
||||
added as a collaborator on that one repository. A leaked clone credential
|
||||
therefore reaches exactly one repository — least privilege rather than a shared
|
||||
platform token.
|
||||
|
||||
**Names are re-validated here.** The application layer already produces safe
|
||||
slugs, but the adapter must not depend on a caller behaving well: an unvalidated
|
||||
name is the classic path to a path-traversal or an unintended API path segment.
|
||||
|
||||
Reference: ``docs/current-infra-notes.md`` §4.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import re
|
||||
import secrets
|
||||
from urllib.parse import quote
|
||||
|
||||
import httpx
|
||||
|
||||
from app.application.ports import (
|
||||
BranchInfo,
|
||||
CommitInfo,
|
||||
GitCredentials,
|
||||
MergeResult,
|
||||
PullRequestInfo,
|
||||
RepoInfo,
|
||||
RepoStatus,
|
||||
)
|
||||
from app.core.errors import ProviderUnavailableError, ValidationError
|
||||
|
||||
#: Gitea accepts ``[a-zA-Z0-9._-]`` for repository names; we are stricter and
|
||||
#: keep the platform's lowercase-slug convention.
|
||||
_REPO_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9._-]{0,99}$")
|
||||
#: Gitea usernames: alphanumeric plus ``-``/``_``, at most 40 characters, and
|
||||
#: they may not start or end with a separator.
|
||||
_USERNAME_RE = re.compile(r"^[a-z0-9]([a-z0-9_-]{1,38}[a-z0-9])$")
|
||||
|
||||
DEFAULT_TIMEOUT_SECONDS = 20.0
|
||||
#: Cloning/committing can be slower than a metadata read.
|
||||
WRITE_TIMEOUT_SECONDS = 60.0
|
||||
|
||||
|
||||
class GiteaGitAdapter:
|
||||
"""Git provider backed by a Gitea instance."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
base_url: str,
|
||||
admin_user: str,
|
||||
admin_token: str,
|
||||
owner: str | None = None,
|
||||
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
|
||||
) -> None:
|
||||
if not base_url:
|
||||
raise ProviderUnavailableError(
|
||||
"GIT_PROVIDER=gitea نیازمند تنظیم GITEA_INTERNAL_URL یا GITEA_URL است."
|
||||
)
|
||||
if not admin_token:
|
||||
raise ProviderUnavailableError(
|
||||
"GIT_PROVIDER=gitea نیازمند تنظیم GITEA_ADMIN_TOKEN است."
|
||||
)
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.admin_user = admin_user
|
||||
self._admin_token = admin_token
|
||||
#: Namespace that owns every project repository.
|
||||
self.owner = owner or admin_user
|
||||
self.timeout_seconds = timeout_seconds
|
||||
|
||||
# -- plumbing ------------------------------------------------------------
|
||||
|
||||
def _client(self, *, write: bool = False) -> httpx.AsyncClient:
|
||||
return httpx.AsyncClient(
|
||||
base_url=self.base_url,
|
||||
headers={
|
||||
"Authorization": f"token {self._admin_token}",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
timeout=WRITE_TIMEOUT_SECONDS if write else self.timeout_seconds,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _sanitize_repo_name(repo_name: str) -> str:
|
||||
clean = (repo_name or "").strip().lower()
|
||||
if not _REPO_NAME_RE.match(clean):
|
||||
raise ValidationError(
|
||||
"نام مخزن نامعتبر است؛ فقط حروف کوچک، عدد، نقطه، خط تیره و زیرخط مجاز است."
|
||||
)
|
||||
return clean
|
||||
|
||||
@staticmethod
|
||||
def _sanitize_username(username: str) -> str:
|
||||
clean = (username or "").strip().lower()
|
||||
if not _USERNAME_RE.match(clean):
|
||||
raise ValidationError("نام کاربری مخزن نامعتبر است.")
|
||||
return clean
|
||||
|
||||
@staticmethod
|
||||
def _seg(value: str) -> str:
|
||||
"""Percent-encode a path segment so it cannot break out of the path."""
|
||||
return quote(value, safe="")
|
||||
|
||||
async def _send(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
write: bool = False,
|
||||
expect: tuple[int, ...] = (200, 201),
|
||||
**kwargs,
|
||||
) -> httpx.Response:
|
||||
"""Perform a request and translate transport failures.
|
||||
|
||||
A network problem must surface as ``ProviderUnavailableError`` (a 503),
|
||||
never as a raw ``httpx`` exception leaking through the API.
|
||||
"""
|
||||
try:
|
||||
async with self._client(write=write) as client:
|
||||
response = await client.request(method, path, **kwargs)
|
||||
except httpx.TimeoutException as exc:
|
||||
raise ProviderUnavailableError("ارتباط با Gitea زمانبر شد.") from exc
|
||||
except httpx.HTTPError as exc:
|
||||
raise ProviderUnavailableError("ارتباط با Gitea برقرار نشد.") from exc
|
||||
|
||||
if response.status_code not in expect:
|
||||
raise ProviderUnavailableError(
|
||||
f"Gitea پاسخ ناموفق داد ({response.status_code}) برای {method} {path}."
|
||||
)
|
||||
return response
|
||||
|
||||
async def _get_json(self, path: str, *, expect: tuple[int, ...] = (200,)) -> dict | list | None:
|
||||
"""GET that tolerates 404 by returning ``None``."""
|
||||
try:
|
||||
async with self._client() as client:
|
||||
response = await client.get(path)
|
||||
except httpx.HTTPError as exc:
|
||||
raise ProviderUnavailableError("ارتباط با Gitea برقرار نشد.") from exc
|
||||
if response.status_code == 404:
|
||||
return None
|
||||
if response.status_code not in expect:
|
||||
raise ProviderUnavailableError(
|
||||
f"Gitea پاسخ ناموفق داد ({response.status_code}) برای {path}."
|
||||
)
|
||||
return response.json()
|
||||
|
||||
# -- repository lifecycle ------------------------------------------------
|
||||
|
||||
async def ensure_project_repo(
|
||||
self, *, project_id: str, repo_name: str, description: str = ""
|
||||
) -> RepoInfo:
|
||||
name = self._sanitize_repo_name(repo_name)
|
||||
owner = self._sanitize_username(self.owner)
|
||||
path = f"/api/v1/repos/{self._seg(owner)}/{self._seg(name)}"
|
||||
|
||||
existing = await self._get_json(path)
|
||||
if existing is None:
|
||||
# The admin endpoint creates the repository inside the owner's
|
||||
# namespace, which a plain user token could not do.
|
||||
await self._send(
|
||||
"POST",
|
||||
f"/api/v1/admin/users/{self._seg(owner)}/repos",
|
||||
json={
|
||||
"name": name,
|
||||
"description": description,
|
||||
"private": True,
|
||||
"auto_init": True,
|
||||
"default_branch": "main",
|
||||
},
|
||||
)
|
||||
existing = await self._get_json(path)
|
||||
if existing is None:
|
||||
raise ProviderUnavailableError("ساخت مخزن در Gitea تأیید نشد.")
|
||||
|
||||
if not isinstance(existing, dict):
|
||||
raise ProviderUnavailableError("پاسخ Gitea برای مخزن قابل تفسیر نبود.")
|
||||
return self._to_repo_info(existing, fallback_name=name, fallback_owner=owner)
|
||||
|
||||
def _to_repo_info(self, payload: dict, *, fallback_name: str, fallback_owner: str) -> RepoInfo:
|
||||
owner = ((payload.get("owner") or {}).get("login")) or fallback_owner
|
||||
name = payload.get("name") or fallback_name
|
||||
return RepoInfo(
|
||||
owner=owner,
|
||||
repo_name=name,
|
||||
clone_url=payload.get("clone_url") or f"{self.base_url}/{owner}/{name}.git",
|
||||
web_url=payload.get("html_url") or f"{self.base_url}/{owner}/{name}",
|
||||
default_branch=payload.get("default_branch") or "main",
|
||||
# Never let a remote payload make a tenant repository public.
|
||||
private=True,
|
||||
)
|
||||
|
||||
async def ensure_credentials(
|
||||
self, *, project_id: str, owner: str = "", repo_name: str = ""
|
||||
) -> GitCredentials:
|
||||
"""Create (or reset) a dedicated Gitea user for this project.
|
||||
|
||||
The user is added as a *collaborator on one repository only*, so the
|
||||
returned password cannot reach any other tenant's code.
|
||||
"""
|
||||
repo_owner = self._sanitize_username(owner or self.owner)
|
||||
name = self._sanitize_repo_name(repo_name)
|
||||
username = self._sanitize_username(f"proj-{project_id[:24]}")
|
||||
|
||||
# A long random password, generated fresh every call. Gitea hashes it.
|
||||
password = secrets.token_urlsafe(24)
|
||||
email = f"{username}@users.noreply.agy.invalid"
|
||||
|
||||
created = await self._send(
|
||||
"POST",
|
||||
"/api/v1/admin/users",
|
||||
expect=(200, 201, 422),
|
||||
json={
|
||||
"username": username,
|
||||
"email": email,
|
||||
"password": password,
|
||||
"must_change_password": False,
|
||||
"visibility": "private",
|
||||
},
|
||||
)
|
||||
if created.status_code == 422:
|
||||
# Already exists — rotate its password instead of failing, so a
|
||||
# retried `ensure_credentials` stays idempotent.
|
||||
await self._send(
|
||||
"PATCH",
|
||||
f"/api/v1/admin/users/{self._seg(username)}",
|
||||
json={"password": password, "must_change_password": False},
|
||||
)
|
||||
|
||||
# Grant write access to exactly this repository.
|
||||
await self._send(
|
||||
"PUT",
|
||||
f"/api/v1/repos/{self._seg(repo_owner)}/{self._seg(name)}"
|
||||
f"/collaborators/{self._seg(username)}",
|
||||
json={"permission": "write"},
|
||||
)
|
||||
|
||||
return GitCredentials(username=username, password=password)
|
||||
|
||||
# -- branches and commits ------------------------------------------------
|
||||
|
||||
async def create_branch(
|
||||
self,
|
||||
*,
|
||||
project_id: str,
|
||||
branch_name: str,
|
||||
from_branch: str,
|
||||
owner: str = "",
|
||||
repo_name: str = "",
|
||||
) -> BranchInfo:
|
||||
base_path = self._repo_path(owner, repo_name)
|
||||
response = await self._send(
|
||||
"POST",
|
||||
f"{base_path}/branches",
|
||||
expect=(200, 201),
|
||||
json={"new_branch_name": branch_name, "old_branch_name": from_branch},
|
||||
)
|
||||
payload = response.json()
|
||||
commit = payload.get("commit") or {}
|
||||
return BranchInfo(name=payload.get("name", branch_name), sha=commit.get("id", ""))
|
||||
|
||||
async def commit_files(
|
||||
self,
|
||||
*,
|
||||
project_id: str,
|
||||
branch_name: str,
|
||||
files: dict[str, str],
|
||||
message: str,
|
||||
owner: str = "",
|
||||
repo_name: str = "",
|
||||
) -> CommitInfo:
|
||||
"""Commit every file, one Contents-API call each.
|
||||
|
||||
Gitea's contents endpoint handles a single file per request, so a
|
||||
multi-file change is a sequence of commits. Each update needs the blob's
|
||||
current ``sha``, which we read first.
|
||||
"""
|
||||
base_path = self._repo_path(owner, repo_name)
|
||||
last_sha = ""
|
||||
changed = 0
|
||||
|
||||
for raw_path, content in files.items():
|
||||
path = self._safe_content_path(raw_path)
|
||||
encoded = quote(path, safe="/")
|
||||
existing = await self._get_json(f"{base_path}/contents/{encoded}?ref={branch_name}")
|
||||
body = {
|
||||
"content": base64.b64encode(content.encode("utf-8")).decode("ascii"),
|
||||
"message": message,
|
||||
"branch": branch_name,
|
||||
}
|
||||
if isinstance(existing, dict) and existing.get("sha"):
|
||||
body["sha"] = existing["sha"]
|
||||
response = await self._send(
|
||||
"PUT", f"{base_path}/contents/{encoded}", write=True, json=body
|
||||
)
|
||||
else:
|
||||
response = await self._send(
|
||||
"POST", f"{base_path}/contents/{encoded}", write=True, json=body
|
||||
)
|
||||
payload = response.json()
|
||||
last_sha = (payload.get("commit") or {}).get("sha") or last_sha
|
||||
changed += 1
|
||||
|
||||
return CommitInfo(
|
||||
sha=last_sha, branch=branch_name, message=message, files_changed=changed
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _safe_content_path(raw_path: str) -> str:
|
||||
"""Reject absolute paths and traversal before they reach the URL."""
|
||||
clean = (raw_path or "").strip().lstrip("/")
|
||||
if not clean:
|
||||
raise ValidationError("مسیر فایل نامعتبر است.")
|
||||
segments = clean.split("/")
|
||||
if any(seg in {"", ".", ".."} for seg in segments):
|
||||
raise ValidationError("مسیر فایل نامعتبر است.")
|
||||
return "/".join(segments)
|
||||
|
||||
# -- pull requests -------------------------------------------------------
|
||||
|
||||
async def open_pull_request(
|
||||
self,
|
||||
*,
|
||||
project_id: str,
|
||||
head: str,
|
||||
base: str,
|
||||
title: str,
|
||||
body: str = "",
|
||||
owner: str = "",
|
||||
repo_name: str = "",
|
||||
) -> PullRequestInfo:
|
||||
response = await self._send(
|
||||
"POST",
|
||||
f"{self._repo_path(owner, repo_name)}/pulls",
|
||||
expect=(200, 201),
|
||||
json={"head": head, "base": base, "title": title, "body": body},
|
||||
)
|
||||
payload = response.json()
|
||||
return PullRequestInfo(
|
||||
number=int(payload.get("number", 0)),
|
||||
url=payload.get("html_url", ""),
|
||||
state=payload.get("state", "open"),
|
||||
)
|
||||
|
||||
async def merge_pull_request(
|
||||
self, *, project_id: str, pr_number: int, owner: str = "", repo_name: str = ""
|
||||
) -> MergeResult:
|
||||
try:
|
||||
await self._send(
|
||||
"POST",
|
||||
f"{self._repo_path(owner, repo_name)}/pulls/{int(pr_number)}/merge",
|
||||
json={"Do": "merge"},
|
||||
)
|
||||
except ProviderUnavailableError as exc:
|
||||
# A merge conflict is a normal outcome, not an outage.
|
||||
return MergeResult(merged=False, message=str(exc))
|
||||
return MergeResult(merged=True, message="PR ادغام شد.")
|
||||
|
||||
# -- status --------------------------------------------------------------
|
||||
|
||||
async def get_repo_status(
|
||||
self, *, project_id: str, owner: str = "", repo_name: str = ""
|
||||
) -> RepoStatus:
|
||||
base_path = self._repo_path(owner, repo_name)
|
||||
repo = await self._get_json(base_path)
|
||||
if repo is None:
|
||||
return RepoStatus(exists=False)
|
||||
if not isinstance(repo, dict):
|
||||
raise ProviderUnavailableError("پاسخ Gitea برای مخزن قابل تفسیر نبود.")
|
||||
|
||||
branches: list[str] = []
|
||||
branch_payload = await self._get_json(f"{base_path}/branches")
|
||||
if isinstance(branch_payload, list):
|
||||
branches = sorted(b.get("name", "") for b in branch_payload if b.get("name"))
|
||||
|
||||
open_prs = 0
|
||||
pr_payload = await self._get_json(f"{base_path}/pulls?state=open&limit=50")
|
||||
if isinstance(pr_payload, list):
|
||||
open_prs = len(pr_payload)
|
||||
|
||||
return RepoStatus(
|
||||
exists=True,
|
||||
private=True,
|
||||
default_branch=repo.get("default_branch") or "main",
|
||||
branches=branches,
|
||||
open_pull_requests=open_prs,
|
||||
)
|
||||
|
||||
# -- helpers -------------------------------------------------------------
|
||||
|
||||
def _repo_path(self, owner: str, repo_name: str) -> str:
|
||||
"""Build ``/api/v1/repos/{owner}/{repo}`` from validated segments."""
|
||||
if not repo_name:
|
||||
raise ValidationError("نام مخزن مشخص نیست.")
|
||||
clean_owner = self._sanitize_username(owner or self.owner)
|
||||
clean_repo = self._sanitize_repo_name(repo_name)
|
||||
return f"/api/v1/repos/{self._seg(clean_owner)}/{self._seg(clean_repo)}"
|
||||
@@ -0,0 +1,520 @@
|
||||
"""Mock adapters — every port has one so the stack runs with zero credentials.
|
||||
|
||||
These are not toys: they enforce the same contracts (timeouts, output caps,
|
||||
validation) as the real adapters, so switching a ``*_PROVIDER`` env var does not
|
||||
change application behaviour.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import secrets
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Iterable
|
||||
from typing import Any
|
||||
|
||||
from app.application.ports import (
|
||||
BranchInfo,
|
||||
ChatMessage,
|
||||
CheckoutSession,
|
||||
CommandResult,
|
||||
CommitInfo,
|
||||
GitCredentials,
|
||||
MergeResult,
|
||||
ModelHealth,
|
||||
ModelResponse,
|
||||
Notification,
|
||||
PaymentResult,
|
||||
PreviewDomainInfo,
|
||||
PullRequestInfo,
|
||||
RepoInfo,
|
||||
RepoStatus,
|
||||
ReverseProxyResult,
|
||||
SessionInfo,
|
||||
SnapshotInfo,
|
||||
StoredObject,
|
||||
VectorHit,
|
||||
WorkspaceInfo,
|
||||
)
|
||||
from app.domain.ai_control.models import AiModel
|
||||
from app.domain.platform.rules import (
|
||||
assert_port_allocatable,
|
||||
build_host,
|
||||
validate_managed_host,
|
||||
validate_proxy_target,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Model
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class MockModelProviderAdapter:
|
||||
"""Deterministic, offline stand-in for an LLM.
|
||||
|
||||
Returns a canned Persian acknowledgement that names the task so tests can
|
||||
assert on it without a network call.
|
||||
"""
|
||||
|
||||
def __init__(self, latency_ms: int = 0) -> None:
|
||||
self._latency_ms = latency_ms
|
||||
|
||||
async def complete(
|
||||
self,
|
||||
*,
|
||||
model: AiModel,
|
||||
credential=None,
|
||||
messages: list[ChatMessage],
|
||||
system: str = "",
|
||||
temperature: float = 0.2,
|
||||
max_tokens: int = 2048,
|
||||
) -> ModelResponse:
|
||||
started = time.perf_counter()
|
||||
if self._latency_ms:
|
||||
await asyncio.sleep(self._latency_ms / 1000)
|
||||
last_user = next((m.content for m in reversed(messages) if m.role == "user"), "")
|
||||
prompt_tokens = sum(len(m.content.split()) for m in messages)
|
||||
# The canned text must read like model output, not like a debug tag: the
|
||||
# provider/model identity is platform-internal and must never surface in
|
||||
# anything a customer can read (see ``test_run_payload_never_leaks_model_identity``).
|
||||
content = (
|
||||
"این پاسخ آزمایشی است. "
|
||||
f"درخواست دریافتی: {last_user[:280] or '—'}"
|
||||
)
|
||||
return ModelResponse(
|
||||
content=content,
|
||||
model_internal_name=model.internal_name,
|
||||
input_tokens=prompt_tokens + len(system.split()),
|
||||
output_tokens=len(content.split()),
|
||||
latency_ms=int((time.perf_counter() - started) * 1000),
|
||||
raw={"mock": True},
|
||||
)
|
||||
|
||||
async def health(self) -> ModelHealth:
|
||||
return ModelHealth(ok=True, detail="mock provider")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Git
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class MockGitAdapter:
|
||||
"""In-memory Git provider. Nothing touches a real forge.
|
||||
|
||||
``owner`` / ``repo_name`` are accepted for interface parity with the Gitea
|
||||
adapter but ignored here — this adapter keys everything by ``project_id``.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._repos: dict[str, RepoInfo] = {}
|
||||
self._credentials: dict[str, GitCredentials] = {}
|
||||
self._branches: dict[str, dict[str, str]] = {}
|
||||
self._prs: dict[str, list[PullRequestInfo]] = {}
|
||||
|
||||
async def ensure_project_repo(
|
||||
self, *, project_id: str, repo_name: str, description: str = ""
|
||||
) -> RepoInfo:
|
||||
if project_id not in self._repos:
|
||||
self._repos[project_id] = RepoInfo(
|
||||
owner="mock",
|
||||
repo_name=repo_name,
|
||||
clone_url=f"https://git.example.invalid/mock/{repo_name}.git",
|
||||
web_url=f"https://git.example.invalid/mock/{repo_name}",
|
||||
default_branch="main",
|
||||
private=True,
|
||||
)
|
||||
self._branches[project_id] = {"main": hashlib.sha1(b"init").hexdigest()[:40]}
|
||||
self._prs[project_id] = []
|
||||
return self._repos[project_id]
|
||||
|
||||
async def ensure_credentials(
|
||||
self, *, project_id: str, owner: str = "", repo_name: str = ""
|
||||
) -> GitCredentials:
|
||||
if project_id not in self._credentials:
|
||||
self._credentials[project_id] = GitCredentials(
|
||||
username=f"proj_{project_id[:8]}",
|
||||
password=secrets.token_urlsafe(12),
|
||||
)
|
||||
return self._credentials[project_id]
|
||||
|
||||
async def create_branch(
|
||||
self,
|
||||
*,
|
||||
project_id: str,
|
||||
branch_name: str,
|
||||
from_branch: str,
|
||||
owner: str = "",
|
||||
repo_name: str = "",
|
||||
) -> BranchInfo:
|
||||
branches = self._branches.setdefault(project_id, {"main": "0" * 40})
|
||||
base = branches.get(from_branch, "0" * 40)
|
||||
sha = hashlib.sha1(f"{base}:{branch_name}".encode()).hexdigest()[:40]
|
||||
branches[branch_name] = sha
|
||||
return BranchInfo(name=branch_name, sha=sha)
|
||||
|
||||
async def commit_files(
|
||||
self,
|
||||
*,
|
||||
project_id: str,
|
||||
branch_name: str,
|
||||
files: dict[str, str],
|
||||
message: str,
|
||||
owner: str = "",
|
||||
repo_name: str = "",
|
||||
) -> CommitInfo:
|
||||
digest = hashlib.sha1(
|
||||
f"{branch_name}:{message}:{sorted(files)}".encode()
|
||||
).hexdigest()[:40]
|
||||
self._branches.setdefault(project_id, {})[branch_name] = digest
|
||||
return CommitInfo(
|
||||
sha=digest, branch=branch_name, message=message, files_changed=len(files)
|
||||
)
|
||||
|
||||
async def open_pull_request(
|
||||
self,
|
||||
*,
|
||||
project_id: str,
|
||||
head: str,
|
||||
base: str,
|
||||
title: str,
|
||||
body: str = "",
|
||||
owner: str = "",
|
||||
repo_name: str = "",
|
||||
) -> PullRequestInfo:
|
||||
prs = self._prs.setdefault(project_id, [])
|
||||
number = len(prs) + 1
|
||||
repo = self._repos.get(project_id)
|
||||
web = repo.web_url if repo else "https://git.example.invalid/mock"
|
||||
pr = PullRequestInfo(number=number, url=f"{web}/pulls/{number}", state="open")
|
||||
prs.append(pr)
|
||||
return pr
|
||||
|
||||
async def merge_pull_request(
|
||||
self, *, project_id: str, pr_number: int, owner: str = "", repo_name: str = ""
|
||||
) -> MergeResult:
|
||||
prs = self._prs.setdefault(project_id, [])
|
||||
for pr in prs:
|
||||
if pr.number == pr_number:
|
||||
pr.state = "merged"
|
||||
return MergeResult(merged=True, message="ادغام شد (mock).")
|
||||
return MergeResult(merged=False, message="PR یافت نشد.")
|
||||
|
||||
async def get_repo_status(
|
||||
self, *, project_id: str, owner: str = "", repo_name: str = ""
|
||||
) -> RepoStatus:
|
||||
repo = self._repos.get(project_id)
|
||||
if repo is None:
|
||||
return RepoStatus(exists=False)
|
||||
return RepoStatus(
|
||||
exists=True,
|
||||
private=repo.private,
|
||||
default_branch=repo.default_branch,
|
||||
branches=sorted(self._branches.get(project_id, {}).keys()),
|
||||
open_pull_requests=sum(
|
||||
1 for pr in self._prs.get(project_id, []) if pr.state == "open"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Domain router
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class MockDomainRouterAdapter:
|
||||
"""In-memory port + subdomain ledger.
|
||||
|
||||
Applies the *same* validation rules as the real Caddy adapter, so tests that
|
||||
pass here describe behaviour the production adapter also guarantees.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
root_domain: str = "msa.artacloud.ir",
|
||||
port_start: int = 8080,
|
||||
port_end: int = 8999,
|
||||
) -> None:
|
||||
self.root_domain = root_domain
|
||||
self.port_start = port_start
|
||||
self.port_end = port_end
|
||||
self._ports: dict[tuple[str, str], int] = {}
|
||||
self._domains: dict[str, PreviewDomainInfo] = {}
|
||||
self._project_domains: dict[str, list[str]] = {}
|
||||
|
||||
async def allocate_port(
|
||||
self, *, organization_id: str, project_id: str, service_name: str
|
||||
) -> int:
|
||||
key = (project_id, service_name)
|
||||
if key in self._ports:
|
||||
return self._ports[key]
|
||||
used = set(self._ports.values())
|
||||
for port in range(self.port_start, self.port_end + 1):
|
||||
if port in used:
|
||||
continue
|
||||
try:
|
||||
assert_port_allocatable(port, start=self.port_start, end=self.port_end)
|
||||
except Exception:
|
||||
continue
|
||||
self._ports[key] = port
|
||||
return port
|
||||
raise RuntimeError("No available ports in the configured range.")
|
||||
|
||||
async def claim_preview_domain(
|
||||
self,
|
||||
*,
|
||||
organization_id: str,
|
||||
project_id: str,
|
||||
requested_subdomain: str,
|
||||
port: int,
|
||||
) -> PreviewDomainInfo:
|
||||
host = build_host(requested_subdomain, self.root_domain)
|
||||
if host in self._domains:
|
||||
owner_domains = self._project_domains.get(project_id, [])
|
||||
if host not in owner_domains:
|
||||
raise ValueError(f"دامنهٔ {host} قبلاً ثبت شده است.")
|
||||
info = PreviewDomainInfo(
|
||||
host=host, port=port, url=f"https://{host}", status="active"
|
||||
)
|
||||
self._domains[host] = info
|
||||
self._project_domains.setdefault(project_id, []).append(host)
|
||||
return info
|
||||
|
||||
async def configure_reverse_proxy(self, *, host: str, port: int) -> ReverseProxyResult:
|
||||
validate_managed_host(host, self.root_domain)
|
||||
validate_proxy_target(f"localhost:{port}")
|
||||
return ReverseProxyResult(ok=True, message="route ثبت شد (mock).", url=f"https://{host}")
|
||||
|
||||
async def remove_preview_domain(self, *, domain_id: str) -> None:
|
||||
for host, info in list(self._domains.items()):
|
||||
if info.host == domain_id or domain_id in self._project_domains.get(domain_id, []):
|
||||
self._domains.pop(host, None)
|
||||
self._domains.pop(domain_id, None)
|
||||
for hosts in self._project_domains.values():
|
||||
if domain_id in hosts:
|
||||
hosts.remove(domain_id)
|
||||
|
||||
async def list_project_domains(self, *, project_id: str) -> list[PreviewDomainInfo]:
|
||||
return [
|
||||
self._domains[h]
|
||||
for h in self._project_domains.get(project_id, [])
|
||||
if h in self._domains
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sandbox
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class LocalMockSandboxAdapter:
|
||||
"""Executes nothing. Returns plausible results and records commands.
|
||||
|
||||
``run_command`` still honours the timeout and output cap contract so callers
|
||||
cannot depend on behaviour the real adapters will not provide.
|
||||
"""
|
||||
|
||||
def __init__(self, max_output_bytes: int = 200_000) -> None:
|
||||
self._workspaces: dict[str, str] = {}
|
||||
self._sessions: dict[str, str] = {}
|
||||
self._files: dict[str, dict[str, str]] = {}
|
||||
self.commands: list[str] = []
|
||||
self.max_output_bytes = max_output_bytes
|
||||
|
||||
async def create_workspace(
|
||||
self, *, organization_id: str, project_id: str, template_id: str, image: str
|
||||
) -> WorkspaceInfo:
|
||||
external_id = f"ws_{uuid.uuid4().hex[:12]}"
|
||||
self._workspaces[external_id] = image
|
||||
return WorkspaceInfo(external_id=external_id, status="ready", raw={"image": image})
|
||||
|
||||
async def start_session(self, *, workspace_external_id: str) -> SessionInfo:
|
||||
external_id = f"ses_{uuid.uuid4().hex[:12]}"
|
||||
self._sessions[external_id] = workspace_external_id
|
||||
self._files.setdefault(external_id, {})
|
||||
return SessionInfo(external_id=external_id, status="running")
|
||||
|
||||
async def run_command(
|
||||
self,
|
||||
*,
|
||||
session_external_id: str,
|
||||
command: str,
|
||||
timeout_seconds: int,
|
||||
max_output_bytes: int | None = None,
|
||||
env: dict[str, str] | None = None,
|
||||
) -> CommandResult:
|
||||
limit = max_output_bytes or self.max_output_bytes
|
||||
started = time.perf_counter()
|
||||
self.commands.append(command)
|
||||
stdout = f"[mock] اجرا شد: {command}"
|
||||
truncated = len(stdout.encode()) > limit
|
||||
if truncated:
|
||||
stdout = stdout.encode()[:limit].decode(errors="ignore")
|
||||
return CommandResult(
|
||||
exit_code=0,
|
||||
stdout=stdout,
|
||||
stderr="",
|
||||
duration_ms=max(1, int((time.perf_counter() - started) * 1000)),
|
||||
truncated=truncated,
|
||||
)
|
||||
|
||||
async def read_file(self, *, session_external_id: str, path: str) -> str:
|
||||
return self._files.get(session_external_id, {}).get(path, "")
|
||||
|
||||
async def write_file(self, *, session_external_id: str, path: str, content: str) -> None:
|
||||
self._files.setdefault(session_external_id, {})[path] = content
|
||||
|
||||
async def snapshot(self, *, session_external_id: str, name: str) -> SnapshotInfo:
|
||||
return SnapshotInfo(external_id=f"snap_{uuid.uuid4().hex[:12]}", name=name)
|
||||
|
||||
async def pause(self, *, session_external_id: str) -> None:
|
||||
return None
|
||||
|
||||
async def resume(self, *, session_external_id: str) -> SessionInfo:
|
||||
return SessionInfo(external_id=session_external_id, status="running")
|
||||
|
||||
async def destroy(self, *, session_external_id: str) -> None:
|
||||
self._sessions.pop(session_external_id, None)
|
||||
self._files.pop(session_external_id, None)
|
||||
|
||||
async def get_preview_url(self, *, session_external_id: str, port: int) -> str:
|
||||
return f"http://127.0.0.1:{port}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Payment
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class LocalMockPaymentAdapter:
|
||||
async def create_checkout(
|
||||
self,
|
||||
*,
|
||||
organization_id: str,
|
||||
amount: float,
|
||||
currency: str,
|
||||
success_url: str,
|
||||
cancel_url: str,
|
||||
) -> CheckoutSession:
|
||||
external_id = f"mock_cs_{uuid.uuid4().hex[:12]}"
|
||||
return CheckoutSession(
|
||||
external_id=external_id,
|
||||
url=f"{success_url}?mock_checkout={external_id}",
|
||||
amount=amount,
|
||||
currency=currency,
|
||||
)
|
||||
|
||||
async def refund(
|
||||
self, *, external_payment_id: str, amount: float | None = None
|
||||
) -> PaymentResult:
|
||||
return PaymentResult(ok=True, external_id=external_payment_id, message="بازگشت (mock).")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Storage / vector / notification
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class InMemoryStorageAdapter:
|
||||
def __init__(self) -> None:
|
||||
self._objects: dict[str, bytes] = {}
|
||||
|
||||
async def put(self, *, key: str, data: bytes, content_type: str = "") -> StoredObject:
|
||||
self._objects[key] = data
|
||||
return StoredObject(key=key, size_bytes=len(data), url=f"memory://{key}")
|
||||
|
||||
async def get(self, *, key: str) -> bytes:
|
||||
return self._objects.get(key, b"")
|
||||
|
||||
async def delete(self, *, key: str) -> None:
|
||||
self._objects.pop(key, None)
|
||||
|
||||
async def signed_url(self, *, key: str, expires_seconds: int = 900) -> str:
|
||||
return f"memory://{key}?expires={expires_seconds}"
|
||||
|
||||
|
||||
class InMemoryVectorStoreAdapter:
|
||||
"""Keyword-overlap search. Good enough for MVP; swappable for pgvector."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._chunks: dict[str, tuple[str, str, str, dict]] = {}
|
||||
|
||||
async def upsert(
|
||||
self,
|
||||
*,
|
||||
organization_id: str,
|
||||
chunks: Iterable[tuple[str, str, list[float]]],
|
||||
metadata: dict | None = None,
|
||||
) -> int:
|
||||
count = 0
|
||||
for chunk_id, content, _embedding in chunks:
|
||||
document_id = str((metadata or {}).get("document_id", ""))
|
||||
self._chunks[chunk_id] = (organization_id, document_id, content, metadata or {})
|
||||
count += 1
|
||||
return count
|
||||
|
||||
async def search(
|
||||
self,
|
||||
*,
|
||||
organization_id: str,
|
||||
query: str,
|
||||
limit: int = 5,
|
||||
project_id: str | None = None,
|
||||
) -> list[VectorHit]:
|
||||
terms = {t for t in query.lower().split() if t}
|
||||
scored: list[VectorHit] = []
|
||||
for chunk_id, (org, document_id, content, meta) in self._chunks.items():
|
||||
if org != organization_id:
|
||||
continue
|
||||
if project_id and meta.get("project_id") not in (None, project_id):
|
||||
continue
|
||||
overlap = len(terms & set(content.lower().split()))
|
||||
if overlap:
|
||||
scored.append(
|
||||
VectorHit(
|
||||
chunk_id=chunk_id,
|
||||
document_id=document_id,
|
||||
content=content,
|
||||
score=float(overlap),
|
||||
metadata=meta,
|
||||
)
|
||||
)
|
||||
scored.sort(key=lambda h: h.score, reverse=True)
|
||||
return scored[:limit]
|
||||
|
||||
async def delete_document(self, *, document_id: str) -> None:
|
||||
for chunk_id, (_, doc_id, _, _) in list(self._chunks.items()):
|
||||
if doc_id == document_id:
|
||||
self._chunks.pop(chunk_id, None)
|
||||
|
||||
|
||||
class ConsoleNotificationAdapter:
|
||||
"""Records notifications instead of sending them."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.sent: list[Notification] = []
|
||||
|
||||
async def send(self, *, notification: Notification) -> bool:
|
||||
self.sent.append(notification)
|
||||
return True
|
||||
|
||||
|
||||
class InMemoryAuditAdapter:
|
||||
"""In-memory AuditPort, used by unit tests that do not want a database."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
# Records whatever the caller hands over; the port is untyped here.
|
||||
self.events: list[Any] = []
|
||||
|
||||
async def record(self, *, event):
|
||||
self.events.append(event)
|
||||
return event
|
||||
|
||||
async def list_for_organization(
|
||||
self, *, organization_id: str, limit: int = 100, offset: int = 0
|
||||
):
|
||||
matching = [e for e in self.events if e.organization_id == organization_id]
|
||||
return matching[offset : offset + limit]
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Real model adapter for any OpenAI-compatible endpoint.
|
||||
|
||||
Works with OpenAI, OpenRouter, vLLM, LiteLLM, Ollama's OpenAI shim, etc. The
|
||||
API key is read from the credential record (decrypted by the caller), never from
|
||||
the environment at request time.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
from app.application.ports import ChatMessage, ModelHealth, ModelResponse
|
||||
from app.core.errors import ProviderUnavailableError
|
||||
from app.domain.ai_control.models import AiModel, ModelCredential
|
||||
|
||||
|
||||
class OpenAICompatibleModelAdapter:
|
||||
def __init__(self, *, base_url: str, default_api_key: str = "", timeout: float = 120.0) -> None:
|
||||
self.base_url = (base_url or "").rstrip("/")
|
||||
self.default_api_key = default_api_key
|
||||
self.timeout = timeout
|
||||
|
||||
def _resolve_key(self, credential: ModelCredential | None, decrypt) -> str:
|
||||
if credential is not None and credential.encrypted_api_key:
|
||||
try:
|
||||
return decrypt(credential.encrypted_api_key)
|
||||
except Exception: # noqa: BLE001 - fall through to the env default
|
||||
pass
|
||||
return self.default_api_key
|
||||
|
||||
async def complete(
|
||||
self,
|
||||
*,
|
||||
model: AiModel,
|
||||
credential: ModelCredential | None,
|
||||
messages: list[ChatMessage],
|
||||
system: str = "",
|
||||
temperature: float = 0.2,
|
||||
max_tokens: int = 2048,
|
||||
) -> ModelResponse:
|
||||
if not self.base_url:
|
||||
raise ProviderUnavailableError(
|
||||
"OPENAI_COMPATIBLE_BASE_URL تنظیم نشده است."
|
||||
)
|
||||
|
||||
payload_messages = ([{"role": "system", "content": system}] if system else []) + [
|
||||
{"role": m.role, "content": m.content} for m in messages
|
||||
]
|
||||
payload = {
|
||||
"model": model.internal_name,
|
||||
"messages": payload_messages,
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
}
|
||||
headers = {"Content-Type": "application/json"}
|
||||
api_key = self.default_api_key
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
|
||||
started = time.perf_counter()
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.post(
|
||||
f"{self.base_url}/chat/completions", json=payload, headers=headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
raise ProviderUnavailableError(
|
||||
f"ارائهدهندهٔ مدل خطا برگرداند ({exc.response.status_code})."
|
||||
) from exc
|
||||
except httpx.HTTPError as exc:
|
||||
raise ProviderUnavailableError("اتصال به ارائهدهندهٔ مدل برقرار نشد.") from exc
|
||||
|
||||
choice = (data.get("choices") or [{}])[0]
|
||||
content = (choice.get("message") or {}).get("content", "") or ""
|
||||
usage = data.get("usage") or {}
|
||||
return ModelResponse(
|
||||
content=content,
|
||||
model_internal_name=data.get("model", model.internal_name),
|
||||
input_tokens=int(usage.get("prompt_tokens", 0) or 0),
|
||||
output_tokens=int(usage.get("completion_tokens", 0) or 0),
|
||||
latency_ms=int((time.perf_counter() - started) * 1000),
|
||||
raw={"id": data.get("id", "")},
|
||||
)
|
||||
|
||||
async def health(self) -> ModelHealth:
|
||||
if not self.base_url:
|
||||
return ModelHealth(ok=False, detail="base_url تنظیم نشده است.")
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(f"{self.base_url}/models")
|
||||
return ModelHealth(ok=response.status_code < 500, detail=str(response.status_code))
|
||||
except httpx.HTTPError as exc:
|
||||
return ModelHealth(ok=False, detail=str(exc))
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Adapter registry.
|
||||
|
||||
One place decides which concrete adapter backs each port, driven purely by the
|
||||
``*_PROVIDER`` environment switches. Everything defaults to ``mock`` so a fresh
|
||||
clone runs with no credentials.
|
||||
|
||||
Adapters that hold process state (the mock Git forge, the mock port ledger) are
|
||||
cached as singletons so behaviour is consistent within a process.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.errors import ProviderUnavailableError
|
||||
from app.infrastructure.adapters import mock
|
||||
from app.infrastructure.adapters.openai_compatible import OpenAICompatibleModelAdapter
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_model_provider():
|
||||
provider = settings.ai_default_provider.lower()
|
||||
if provider == "mock":
|
||||
return mock.MockModelProviderAdapter()
|
||||
if provider == "openai_compatible":
|
||||
return OpenAICompatibleModelAdapter(
|
||||
base_url=settings.openai_compatible_base_url,
|
||||
default_api_key=settings.openai_compatible_api_key,
|
||||
)
|
||||
raise ProviderUnavailableError(
|
||||
f"AI_DEFAULT_PROVIDER='{provider}' پشتیبانی نمیشود. "
|
||||
"مقادیر مجاز: mock | openai_compatible"
|
||||
)
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_git_provider():
|
||||
provider = settings.git_provider.lower()
|
||||
if provider == "mock":
|
||||
return mock.MockGitAdapter()
|
||||
if provider == "gitea":
|
||||
from app.infrastructure.adapters.gitea import GiteaGitAdapter
|
||||
|
||||
return GiteaGitAdapter(
|
||||
base_url=settings.gitea_internal_url or settings.gitea_url,
|
||||
admin_user=settings.gitea_admin_user,
|
||||
admin_token=settings.gitea_admin_token,
|
||||
)
|
||||
raise ProviderUnavailableError(
|
||||
f"GIT_PROVIDER='{provider}' پشتیبانی نمیشود. مقادیر مجاز: mock | gitea"
|
||||
)
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_domain_router():
|
||||
provider = settings.domain_router.lower()
|
||||
if provider == "mock":
|
||||
return mock.MockDomainRouterAdapter(
|
||||
root_domain=settings.preview_root_domain,
|
||||
port_start=settings.preview_port_start,
|
||||
port_end=settings.preview_port_end,
|
||||
)
|
||||
if provider == "caddy":
|
||||
from app.infrastructure.adapters.caddy import CaddyDomainRouterAdapter
|
||||
|
||||
return CaddyDomainRouterAdapter(
|
||||
caddyfile_path=settings.caddyfile_path,
|
||||
root_domain=settings.preview_root_domain,
|
||||
port_start=settings.preview_port_start,
|
||||
port_end=settings.preview_port_end,
|
||||
)
|
||||
raise ProviderUnavailableError(
|
||||
f"DOMAIN_ROUTER='{provider}' پشتیبانی نمیشود. مقادیر مجاز: mock | caddy"
|
||||
)
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_sandbox_provider():
|
||||
provider = settings.sandbox_provider.lower()
|
||||
if provider == "mock":
|
||||
return mock.LocalMockSandboxAdapter(max_output_bytes=settings.sandbox_max_output_bytes)
|
||||
if provider in {"daytona", "e2b"}:
|
||||
from app.infrastructure.adapters.sandbox_remote import build_remote_sandbox_adapter
|
||||
|
||||
return build_remote_sandbox_adapter(
|
||||
provider,
|
||||
daytona_api_key=settings.daytona_api_key,
|
||||
daytona_api_url=settings.daytona_api_url,
|
||||
e2b_api_key=settings.e2b_api_key,
|
||||
)
|
||||
raise ProviderUnavailableError(
|
||||
f"SANDBOX_PROVIDER='{provider}' پشتیبانی نمیشود. مقادیر مجاز: mock | daytona | e2b"
|
||||
)
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_payment_provider():
|
||||
provider = settings.payment_provider.lower()
|
||||
if provider == "mock":
|
||||
return mock.LocalMockPaymentAdapter()
|
||||
if provider == "stripe":
|
||||
from app.infrastructure.adapters.stripe_payments import StripePaymentAdapter
|
||||
|
||||
return StripePaymentAdapter(
|
||||
secret_key=settings.stripe_secret_key,
|
||||
webhook_secret=settings.stripe_webhook_secret,
|
||||
)
|
||||
raise ProviderUnavailableError(
|
||||
f"PAYMENT_PROVIDER='{provider}' پشتیبانی نمیشود. مقادیر مجاز: mock | stripe"
|
||||
)
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_storage():
|
||||
return mock.InMemoryStorageAdapter()
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_vector_store():
|
||||
return mock.InMemoryVectorStoreAdapter()
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_notifier():
|
||||
return mock.ConsoleNotificationAdapter()
|
||||
|
||||
|
||||
def reset_adapters() -> None:
|
||||
"""Drop cached singletons. Used by tests that need a clean slate."""
|
||||
for fn in (
|
||||
get_model_provider,
|
||||
get_git_provider,
|
||||
get_domain_router,
|
||||
get_sandbox_provider,
|
||||
get_payment_provider,
|
||||
get_storage,
|
||||
get_vector_store,
|
||||
get_notifier,
|
||||
):
|
||||
fn.cache_clear()
|
||||
@@ -0,0 +1,430 @@
|
||||
"""Remote sandbox adapters (Daytona / E2B) for :class:`SandboxProviderPort`.
|
||||
|
||||
This is the **only** place tenant code may execute. It never shells out on the
|
||||
platform host: every command is sent to a remote, disposable sandbox through the
|
||||
provider's official Python SDK.
|
||||
|
||||
Why the SDKs are optional extras
|
||||
--------------------------------
|
||||
``daytona`` and ``e2b`` are large, fast-moving dependencies. They are declared as
|
||||
the ``sandbox-remote`` extra so a default install stays small, and they are
|
||||
imported lazily inside :meth:`_load_sdk`. A missing extra produces an actionable
|
||||
``ProviderUnavailableError`` instead of an ``ImportError`` at startup.
|
||||
|
||||
Fail loudly, not silently
|
||||
-------------------------
|
||||
The two SDKs change their surface between minor releases. Rather than assume a
|
||||
method exists and fail halfway through a customer's build, :meth:`preflight`
|
||||
probes for every attribute this adapter relies on and refuses to construct the
|
||||
adapter if any is missing. A version drift therefore surfaces as a clear
|
||||
configuration error the moment ``SANDBOX_PROVIDER`` is switched on.
|
||||
|
||||
Known gap
|
||||
---------
|
||||
Provider-native snapshots (a resumable VM image) are **not** wired yet. Calling
|
||||
:meth:`snapshot` raises a clear error rather than pretending to have saved one.
|
||||
The mock provider implements snapshots fully, and the database already records
|
||||
them, so only the remote path is affected.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from app.application.ports import CommandResult, SessionInfo, SnapshotInfo, WorkspaceInfo
|
||||
from app.core.errors import ProviderUnavailableError
|
||||
|
||||
#: Never let a caller ask for an unbounded run.
|
||||
ABSOLUTE_MAX_TIMEOUT_SECONDS = 900
|
||||
#: Mirror of the platform output cap; enforced client-side as well because a
|
||||
#: remote SDK is under no obligation to truncate.
|
||||
ABSOLUTE_MAX_OUTPUT_BYTES = 2_000_000
|
||||
|
||||
|
||||
def _load_sdk(provider: str) -> Any:
|
||||
"""Import the provider SDK, or explain exactly how to install it."""
|
||||
try:
|
||||
if provider == "daytona":
|
||||
import daytona # noqa: PLC0415
|
||||
|
||||
return daytona
|
||||
if provider == "e2b":
|
||||
import e2b # noqa: PLC0415
|
||||
|
||||
return e2b
|
||||
except ImportError as exc:
|
||||
raise ProviderUnavailableError(
|
||||
f"SDK «{provider}» نصب نیست. برای فعالسازی اجرا کنید: "
|
||||
f"pip install 'agy-web-api[sandbox-remote]'"
|
||||
) from exc
|
||||
raise ProviderUnavailableError(f"ارائهدهندهٔ sandbox «{provider}» پشتیبانی نمیشود.")
|
||||
|
||||
|
||||
def _require(obj: Any, attr: str, *, provider: str, where: str) -> Any:
|
||||
"""Return ``obj.attr`` or fail with a precise version-mismatch message."""
|
||||
if not hasattr(obj, attr):
|
||||
raise ProviderUnavailableError(
|
||||
f"SDK «{provider}» با این نسخه سازگار نیست: «{where}.{attr}» یافت نشد. "
|
||||
"نسخهٔ پشتیبانیشده را نصب کنید یا SANDBOX_PROVIDER=mock بگذارید."
|
||||
)
|
||||
return getattr(obj, attr)
|
||||
|
||||
|
||||
def _truncate(text: str, limit: int) -> tuple[str, bool]:
|
||||
encoded = text.encode("utf-8", errors="replace")
|
||||
if len(encoded) <= limit:
|
||||
return text, False
|
||||
return encoded[:limit].decode("utf-8", errors="ignore"), True
|
||||
|
||||
|
||||
class _RemoteSandboxAdapter:
|
||||
"""Shared behaviour for both remote providers."""
|
||||
|
||||
provider_name = "remote"
|
||||
|
||||
def __init__(self, *, max_output_bytes: int = 200_000) -> None:
|
||||
self.max_output_bytes = min(max_output_bytes, ABSOLUTE_MAX_OUTPUT_BYTES)
|
||||
self._client: Any = None
|
||||
self._sandboxes: dict[str, Any] = {}
|
||||
self._sessions: dict[str, str] = {}
|
||||
|
||||
# -- lifecycle -----------------------------------------------------------
|
||||
|
||||
async def _ensure_client(self) -> Any: # pragma: no cover - overridden
|
||||
raise NotImplementedError
|
||||
|
||||
async def create_workspace(
|
||||
self, *, organization_id: str, project_id: str, template_id: str, image: str
|
||||
) -> WorkspaceInfo:
|
||||
client = await self._ensure_client()
|
||||
sandbox = await self._create_sandbox(client, image=image, template_id=template_id)
|
||||
external_id = str(
|
||||
_require(sandbox, "sandbox_id", provider=self.provider_name, where="sandbox")
|
||||
)
|
||||
self._sandboxes[external_id] = sandbox
|
||||
return WorkspaceInfo(
|
||||
external_id=external_id,
|
||||
status="ready",
|
||||
raw={"provider": self.provider_name, "image": image},
|
||||
)
|
||||
|
||||
async def start_session(self, *, workspace_external_id: str) -> SessionInfo:
|
||||
# Remote sandboxes are long-lived; a "session" is a handle onto one.
|
||||
external_id = f"ses_{workspace_external_id}"
|
||||
self._sessions[external_id] = workspace_external_id
|
||||
return SessionInfo(external_id=external_id, status="running")
|
||||
|
||||
async def _sandbox_for_session(self, session_external_id: str) -> Any:
|
||||
workspace_id = self._sessions.get(session_external_id, session_external_id)
|
||||
sandbox = self._sandboxes.get(workspace_id)
|
||||
if sandbox is None:
|
||||
raise ProviderUnavailableError("نشست sandbox یافت نشد یا منقضی شده است.")
|
||||
return sandbox
|
||||
|
||||
# -- execution -----------------------------------------------------------
|
||||
|
||||
async def run_command(
|
||||
self,
|
||||
*,
|
||||
session_external_id: str,
|
||||
command: str,
|
||||
timeout_seconds: int,
|
||||
max_output_bytes: int | None = None,
|
||||
env: dict[str, str] | None = None,
|
||||
) -> CommandResult:
|
||||
sandbox = await self._sandbox_for_session(session_external_id)
|
||||
limit = min(max_output_bytes or self.max_output_bytes, ABSOLUTE_MAX_OUTPUT_BYTES)
|
||||
# Clamp here too: the port contract says the caller can never ask for
|
||||
# "no limit", and a bug upstream must not become an unbounded remote run.
|
||||
timeout = max(1, min(int(timeout_seconds), ABSOLUTE_MAX_TIMEOUT_SECONDS))
|
||||
|
||||
started = time.perf_counter()
|
||||
raw = await self._exec(sandbox, command=command, timeout=timeout, env=env or {})
|
||||
duration_ms = max(1, int((time.perf_counter() - started) * 1000))
|
||||
|
||||
# Pull the three fields out defensively; a missing one is a version
|
||||
# mismatch and must fail with a precise message.
|
||||
exit_code = _require(
|
||||
raw, "exit_code", provider=self.provider_name, where="command result"
|
||||
)
|
||||
raw_stdout = _require(
|
||||
raw, "stdout", provider=self.provider_name, where="command result"
|
||||
)
|
||||
raw_stderr = _require(
|
||||
raw, "stderr", provider=self.provider_name, where="command result"
|
||||
)
|
||||
|
||||
stdout, cut_out = _truncate(str(raw_stdout or ""), limit)
|
||||
stderr, cut_err = _truncate(str(raw_stderr or ""), limit)
|
||||
|
||||
return CommandResult(
|
||||
exit_code=int(exit_code or 0),
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
duration_ms=duration_ms,
|
||||
truncated=cut_out or cut_err,
|
||||
)
|
||||
|
||||
# -- files ---------------------------------------------------------------
|
||||
|
||||
async def read_file(self, *, session_external_id: str, path: str) -> str:
|
||||
sandbox = await self._sandbox_for_session(session_external_id)
|
||||
return await self._read(sandbox, path)
|
||||
|
||||
async def write_file(self, *, session_external_id: str, path: str, content: str) -> None:
|
||||
sandbox = await self._sandbox_for_session(session_external_id)
|
||||
await self._write(sandbox, path, content)
|
||||
|
||||
# -- snapshots / preview -------------------------------------------------
|
||||
|
||||
async def snapshot(self, *, session_external_id: str, name: str) -> SnapshotInfo:
|
||||
raise ProviderUnavailableError(
|
||||
"snapshot بومی ارائهدهندهٔ راهدور هنوز فعال نشده است. "
|
||||
"برای snapshot از SANDBOX_PROVIDER=mock استفاده کنید یا این قابلیت را "
|
||||
"برای ارائهدهندهٔ خود پیادهسازی کنید."
|
||||
)
|
||||
|
||||
async def pause(self, *, session_external_id: str) -> None:
|
||||
sandbox = await self._sandbox_for_session(session_external_id)
|
||||
await self._pause(sandbox)
|
||||
|
||||
async def resume(self, *, session_external_id: str) -> SessionInfo:
|
||||
sandbox = await self._sandbox_for_session(session_external_id)
|
||||
await self._resume(sandbox)
|
||||
return SessionInfo(external_id=session_external_id, status="running")
|
||||
|
||||
async def destroy(self, *, session_external_id: str) -> None:
|
||||
workspace_id = self._sessions.pop(session_external_id, session_external_id)
|
||||
sandbox = self._sandboxes.pop(workspace_id, None)
|
||||
if sandbox is not None:
|
||||
await self._destroy(sandbox)
|
||||
|
||||
async def get_preview_url(self, *, session_external_id: str, port: int) -> str:
|
||||
sandbox = await self._sandbox_for_session(session_external_id)
|
||||
return await self._preview_url(sandbox, port)
|
||||
|
||||
# -- provider hooks ------------------------------------------------------
|
||||
|
||||
async def _create_sandbox(self, client: Any, *, image: str, template_id: str) -> Any:
|
||||
raise NotImplementedError
|
||||
|
||||
async def _exec(self, sandbox: Any, *, command: str, timeout: int, env: dict[str, str]) -> Any:
|
||||
raise NotImplementedError
|
||||
|
||||
async def _read(self, sandbox: Any, path: str) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
async def _write(self, sandbox: Any, path: str, content: str) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
async def _pause(self, sandbox: Any) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
async def _resume(self, sandbox: Any) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
async def _destroy(self, sandbox: Any) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
async def _preview_url(self, sandbox: Any, port: int) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class DaytonaSandboxAdapter(_RemoteSandboxAdapter):
|
||||
"""Daytona Cloud sandboxes via the async SDK.
|
||||
|
||||
Targets the documented surface: ``AsyncDaytona(DaytonaConfig(...))``,
|
||||
``client.create()``, ``sandbox.process.exec(...)``, ``sandbox.fs``,
|
||||
``client.delete(sandbox)``.
|
||||
"""
|
||||
|
||||
provider_name = "daytona"
|
||||
|
||||
def __init__(
|
||||
self, *, api_key: str, api_url: str = "", max_output_bytes: int = 200_000
|
||||
) -> None:
|
||||
super().__init__(max_output_bytes=max_output_bytes)
|
||||
if not api_key:
|
||||
raise ProviderUnavailableError(
|
||||
"SANDBOX_PROVIDER=daytona نیازمند تنظیم DAYTONA_API_KEY است."
|
||||
)
|
||||
self._api_key = api_key
|
||||
self._api_url = api_url
|
||||
self._sdk = _load_sdk("daytona")
|
||||
self._preflight()
|
||||
|
||||
def _preflight(self) -> None:
|
||||
_require(self._sdk, "AsyncDaytona", provider="daytona", where="daytona")
|
||||
_require(self._sdk, "DaytonaConfig", provider="daytona", where="daytona")
|
||||
|
||||
async def _ensure_client(self) -> Any:
|
||||
if self._client is None:
|
||||
config_kwargs: dict[str, Any] = {"api_key": self._api_key}
|
||||
if self._api_url:
|
||||
config_kwargs["api_url"] = self._api_url
|
||||
config = self._sdk.DaytonaConfig(**config_kwargs)
|
||||
self._client = self._sdk.AsyncDaytona(config)
|
||||
return self._client
|
||||
|
||||
async def _create_sandbox(self, client: Any, *, image: str, template_id: str) -> Any:
|
||||
# `create` accepts no arguments on the default image; a custom image is
|
||||
# passed positionally as a snapshot/image reference.
|
||||
try:
|
||||
return await client.create()
|
||||
except TypeError as exc:
|
||||
raise ProviderUnavailableError(
|
||||
"ساخت sandbox در Daytona با این نسخهٔ SDK سازگار نیست: "
|
||||
f"{exc}. نسخهٔ پشتیبانیشدهٔ daytona را نصب کنید."
|
||||
) from exc
|
||||
|
||||
async def _exec(self, sandbox: Any, *, command: str, timeout: int, env: dict[str, str]) -> Any:
|
||||
process = _require(sandbox, "process", provider="daytona", where="sandbox")
|
||||
exec_fn = _require(process, "exec", provider="daytona", where="sandbox.process")
|
||||
response = await exec_fn(command, timeout=timeout)
|
||||
# Daytona reports the exit code separately from the combined output.
|
||||
result = str(
|
||||
_require(response, "result", provider="daytona", where="process response") or ""
|
||||
)
|
||||
return _DaytonaCommandOutput(
|
||||
exit_code=int(getattr(response, "exit_code", 0) or 0),
|
||||
stdout=result,
|
||||
stderr="",
|
||||
)
|
||||
|
||||
async def _read(self, sandbox: Any, path: str) -> str:
|
||||
fs = _require(sandbox, "fs", provider="daytona", where="sandbox")
|
||||
read_fn = _require(fs, "download_file", provider="daytona", where="sandbox.fs")
|
||||
content = await read_fn(path)
|
||||
if isinstance(content, bytes):
|
||||
return content.decode("utf-8", errors="replace")
|
||||
return str(content or "")
|
||||
|
||||
async def _write(self, sandbox: Any, path: str, content: str) -> None:
|
||||
fs = _require(sandbox, "fs", provider="daytona", where="sandbox")
|
||||
write_fn = _require(fs, "upload_file", provider="daytona", where="sandbox.fs")
|
||||
await write_fn(content.encode("utf-8"), path)
|
||||
|
||||
async def _pause(self, sandbox: Any) -> None:
|
||||
stop_fn = _require(sandbox, "stop", provider="daytona", where="sandbox")
|
||||
await stop_fn()
|
||||
|
||||
async def _resume(self, sandbox: Any) -> None:
|
||||
start_fn = _require(sandbox, "start", provider="daytona", where="sandbox")
|
||||
await start_fn()
|
||||
|
||||
async def _destroy(self, sandbox: Any) -> None:
|
||||
client = await self._ensure_client()
|
||||
delete_fn = _require(client, "delete", provider="daytona", where="AsyncDaytona")
|
||||
await delete_fn(sandbox)
|
||||
|
||||
async def _preview_url(self, sandbox: Any, port: int) -> str:
|
||||
link_fn = _require(sandbox, "get_preview_link", provider="daytona", where="sandbox")
|
||||
link = await link_fn(port)
|
||||
return str(getattr(link, "url", link))
|
||||
|
||||
|
||||
class E2BSandboxAdapter(_RemoteSandboxAdapter):
|
||||
"""E2B sandboxes via the async SDK.
|
||||
|
||||
Targets the documented surface: ``AsyncSandbox.create(...)``,
|
||||
``sandbox.commands.run(...)``, ``sandbox.files.read/write``,
|
||||
``sandbox.kill()``.
|
||||
"""
|
||||
|
||||
provider_name = "e2b"
|
||||
|
||||
def __init__(self, *, api_key: str, max_output_bytes: int = 200_000) -> None:
|
||||
super().__init__(max_output_bytes=max_output_bytes)
|
||||
if not api_key:
|
||||
raise ProviderUnavailableError(
|
||||
"SANDBOX_PROVIDER=e2b نیازمند تنظیم E2B_API_KEY است."
|
||||
)
|
||||
self._api_key = api_key
|
||||
self._sdk = _load_sdk("e2b")
|
||||
self._preflight()
|
||||
|
||||
def _preflight(self) -> None:
|
||||
_require(self._sdk, "AsyncSandbox", provider="e2b", where="e2b")
|
||||
|
||||
async def _ensure_client(self) -> Any:
|
||||
# E2B has no long-lived client object; the API key is passed per call.
|
||||
return self._sdk.AsyncSandbox
|
||||
|
||||
async def _create_sandbox(self, client: Any, *, image: str, template_id: str) -> Any:
|
||||
create_fn = _require(client, "create", provider="e2b", where="AsyncSandbox")
|
||||
return await create_fn(api_key=self._api_key)
|
||||
|
||||
async def _exec(self, sandbox: Any, *, command: str, timeout: int, env: dict[str, str]) -> Any:
|
||||
commands = _require(sandbox, "commands", provider="e2b", where="sandbox")
|
||||
run_fn = _require(commands, "run", provider="e2b", where="sandbox.commands")
|
||||
result = await run_fn(command, timeout=timeout, envs=env or None)
|
||||
return _E2BCommandOutput(
|
||||
exit_code=int(getattr(result, "exit_code", 0) or 0),
|
||||
stdout=str(getattr(result, "stdout", "") or ""),
|
||||
stderr=str(getattr(result, "stderr", "") or ""),
|
||||
)
|
||||
|
||||
async def _read(self, sandbox: Any, path: str) -> str:
|
||||
files = _require(sandbox, "files", provider="e2b", where="sandbox")
|
||||
read_fn = _require(files, "read", provider="e2b", where="sandbox.files")
|
||||
content = await read_fn(path)
|
||||
if isinstance(content, bytes):
|
||||
return content.decode("utf-8", errors="replace")
|
||||
return str(content or "")
|
||||
|
||||
async def _write(self, sandbox: Any, path: str, content: str) -> None:
|
||||
files = _require(sandbox, "files", provider="e2b", where="sandbox")
|
||||
write_fn = _require(files, "write", provider="e2b", where="sandbox.files")
|
||||
await write_fn(path, content)
|
||||
|
||||
async def _pause(self, sandbox: Any) -> None:
|
||||
pause_fn = _require(sandbox, "pause", provider="e2b", where="sandbox")
|
||||
await pause_fn()
|
||||
|
||||
async def _resume(self, sandbox: Any) -> None:
|
||||
# E2B resumes by reconnecting to the sandbox id.
|
||||
connect_fn = _require(
|
||||
self._sdk.AsyncSandbox, "connect", provider="e2b", where="AsyncSandbox"
|
||||
)
|
||||
await connect_fn(sandbox.sandbox_id, api_key=self._api_key)
|
||||
|
||||
async def _destroy(self, sandbox: Any) -> None:
|
||||
kill_fn = _require(sandbox, "kill", provider="e2b", where="sandbox")
|
||||
await kill_fn()
|
||||
|
||||
async def _preview_url(self, sandbox: Any, port: int) -> str:
|
||||
host_fn = _require(sandbox, "get_host", provider="e2b", where="sandbox")
|
||||
return f"https://{await host_fn(port)}"
|
||||
|
||||
|
||||
class _DaytonaCommandOutput:
|
||||
__slots__ = ("exit_code", "stdout", "stderr")
|
||||
|
||||
def __init__(self, *, exit_code: int, stdout: str, stderr: str) -> None:
|
||||
self.exit_code = exit_code
|
||||
self.stdout = stdout
|
||||
self.stderr = stderr
|
||||
|
||||
|
||||
class _E2BCommandOutput:
|
||||
__slots__ = ("exit_code", "stdout", "stderr")
|
||||
|
||||
def __init__(self, *, exit_code: int, stdout: str, stderr: str) -> None:
|
||||
self.exit_code = exit_code
|
||||
self.stdout = stdout
|
||||
self.stderr = stderr
|
||||
|
||||
|
||||
def build_remote_sandbox_adapter(
|
||||
provider: str, *, daytona_api_key: str = "", daytona_api_url: str = "", e2b_api_key: str = ""
|
||||
):
|
||||
"""Factory used by the adapter registry."""
|
||||
normalised = (provider or "").strip().lower()
|
||||
if normalised == "daytona":
|
||||
return DaytonaSandboxAdapter(api_key=daytona_api_key, api_url=daytona_api_url)
|
||||
if normalised == "e2b":
|
||||
return E2BSandboxAdapter(api_key=e2b_api_key)
|
||||
raise ProviderUnavailableError(
|
||||
f"SANDBOX_PROVIDER='{provider}' پشتیبانی نمیشود. مقادیر مجاز: mock | daytona | e2b"
|
||||
)
|
||||
@@ -0,0 +1,203 @@
|
||||
"""Stripe adapter for :class:`PaymentProviderPort`.
|
||||
|
||||
Uses Stripe's REST API directly over ``httpx`` rather than the official SDK: the
|
||||
two calls we need are stable, and it keeps the API process free of an extra
|
||||
dependency and its release cadence.
|
||||
|
||||
Responsibility boundary
|
||||
-----------------------
|
||||
The internal usage ledger is the **source of truth** for what a tenant owes.
|
||||
This adapter only moves money — it never decides a balance, and a successful
|
||||
checkout does not by itself credit a wallet. The application layer settles the
|
||||
payment and then writes the credit, so a webhook replayed twice cannot
|
||||
double-credit.
|
||||
|
||||
Amounts are converted to Stripe's minor units (cents) with ``Decimal`` rounding,
|
||||
never float multiplication, so ``19.99`` cannot become ``1998``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import time
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
|
||||
import httpx
|
||||
|
||||
from app.application.ports import CheckoutSession, PaymentResult
|
||||
from app.core.errors import ProviderUnavailableError, ValidationError
|
||||
|
||||
STRIPE_API_BASE = "https://api.stripe.com/v1"
|
||||
DEFAULT_TIMEOUT_SECONDS = 20.0
|
||||
#: Stripe rejects signatures older than this; mirrored locally to fail early.
|
||||
WEBHOOK_TOLERANCE_SECONDS = 300
|
||||
#: Card networks require a minimum charge. Refuse early with a clear message
|
||||
#: rather than letting Stripe return a confusing error.
|
||||
MINIMUM_CHARGE_USD = Decimal("0.50")
|
||||
|
||||
|
||||
class StripePaymentAdapter:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
secret_key: str,
|
||||
webhook_secret: str = "",
|
||||
api_base: str = STRIPE_API_BASE,
|
||||
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
|
||||
) -> None:
|
||||
if not secret_key:
|
||||
raise ProviderUnavailableError(
|
||||
"PAYMENT_PROVIDER=stripe نیازمند تنظیم STRIPE_SECRET_KEY است."
|
||||
)
|
||||
self._secret_key = secret_key
|
||||
self._webhook_secret = webhook_secret
|
||||
self.api_base = api_base.rstrip("/")
|
||||
self.timeout_seconds = timeout_seconds
|
||||
|
||||
# -- port ----------------------------------------------------------------
|
||||
|
||||
async def create_checkout(
|
||||
self,
|
||||
*,
|
||||
organization_id: str,
|
||||
amount: float,
|
||||
currency: str,
|
||||
success_url: str,
|
||||
cancel_url: str,
|
||||
) -> CheckoutSession:
|
||||
"""Create a one-off Checkout Session for a wallet top-up."""
|
||||
minor_units = self._to_minor_units(amount)
|
||||
currency_code = (currency or "usd").strip().lower()
|
||||
|
||||
form = {
|
||||
"mode": "payment",
|
||||
"success_url": success_url,
|
||||
"cancel_url": cancel_url,
|
||||
# Ties the Stripe object back to our tenant without exposing an id
|
||||
# the customer can edit meaningfully.
|
||||
"client_reference_id": organization_id,
|
||||
"metadata[organization_id]": organization_id,
|
||||
"line_items[0][quantity]": "1",
|
||||
"line_items[0][price_data][currency]": currency_code,
|
||||
"line_items[0][price_data][unit_amount]": str(minor_units),
|
||||
"line_items[0][price_data][product_data][name]": "AGY credit top-up",
|
||||
}
|
||||
|
||||
payload = await self._request("POST", "/checkout/sessions", data=form)
|
||||
url = payload.get("url")
|
||||
if not url:
|
||||
raise ProviderUnavailableError("Stripe آدرس پرداخت را برنگرداند.")
|
||||
|
||||
return CheckoutSession(
|
||||
external_id=payload.get("id", ""),
|
||||
url=url,
|
||||
# Report back in major units so the caller never sees minor units.
|
||||
amount=float(Decimal(payload.get("amount_total") or minor_units) / 100),
|
||||
currency=(payload.get("currency") or currency_code).upper(),
|
||||
)
|
||||
|
||||
async def refund(
|
||||
self, *, external_payment_id: str, amount: float | None = None
|
||||
) -> PaymentResult:
|
||||
if not external_payment_id:
|
||||
raise ValidationError("شناسهٔ پرداخت برای بازگشت وجه لازم است.")
|
||||
|
||||
form: dict[str, str] = {}
|
||||
# A Checkout Session is not refundable directly; its PaymentIntent is.
|
||||
# Accept either so a caller does not have to know Stripe's object graph.
|
||||
if external_payment_id.startswith("cs_"):
|
||||
session = await self._request(
|
||||
"GET", f"/checkout/sessions/{external_payment_id}", params={}
|
||||
)
|
||||
intent = session.get("payment_intent")
|
||||
if not intent:
|
||||
return PaymentResult(
|
||||
ok=False,
|
||||
external_id=external_payment_id,
|
||||
message="این پرداخت هنوز تکمیل نشده است.",
|
||||
)
|
||||
form["payment_intent"] = str(intent)
|
||||
else:
|
||||
form["payment_intent"] = external_payment_id
|
||||
|
||||
if amount is not None:
|
||||
form["amount"] = str(self._to_minor_units(amount))
|
||||
|
||||
payload = await self._request("POST", "/refunds", data=form)
|
||||
return PaymentResult(
|
||||
ok=payload.get("status") in {"succeeded", "pending"},
|
||||
external_id=payload.get("id", ""),
|
||||
message=f"وضعیت بازگشت وجه: {payload.get('status', 'unknown')}",
|
||||
)
|
||||
|
||||
# -- webhook -------------------------------------------------------------
|
||||
|
||||
def verify_webhook_signature(self, *, payload: bytes, signature_header: str) -> bool:
|
||||
"""Verify a ``Stripe-Signature`` header over the raw request body.
|
||||
|
||||
The body must be the *raw bytes* — re-serialising parsed JSON changes the
|
||||
byte sequence and the HMAC will never match.
|
||||
"""
|
||||
if not self._webhook_secret:
|
||||
return False
|
||||
|
||||
timestamp = ""
|
||||
signatures: list[str] = []
|
||||
for part in (signature_header or "").split(","):
|
||||
key, _, value = part.partition("=")
|
||||
if key.strip() == "t":
|
||||
timestamp = value.strip()
|
||||
elif key.strip() == "v1":
|
||||
signatures.append(value.strip())
|
||||
|
||||
if not timestamp or not signatures:
|
||||
return False
|
||||
try:
|
||||
age = abs(time.time() - int(timestamp))
|
||||
except ValueError:
|
||||
return False
|
||||
if age > WEBHOOK_TOLERANCE_SECONDS:
|
||||
# Replay protection.
|
||||
return False
|
||||
|
||||
signed_payload = f"{timestamp}.".encode() + payload
|
||||
expected = hmac.new(
|
||||
self._webhook_secret.encode(), signed_payload, hashlib.sha256
|
||||
).hexdigest()
|
||||
# Constant-time compare against every offered signature.
|
||||
return any(hmac.compare_digest(expected, candidate) for candidate in signatures)
|
||||
|
||||
# -- helpers -------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _to_minor_units(amount: float) -> int:
|
||||
value = Decimal(str(amount)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
if value <= 0:
|
||||
raise ValidationError("مبلغ باید بزرگتر از صفر باشد.")
|
||||
if value < MINIMUM_CHARGE_USD:
|
||||
raise ValidationError(f"حداقل مبلغ پرداخت {MINIMUM_CHARGE_USD} دلار است.")
|
||||
return int(value * 100)
|
||||
|
||||
async def _request(self, method: str, path: str, **kwargs) -> dict:
|
||||
headers = {"Authorization": f"Bearer {self._secret_key}"}
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
base_url=self.api_base, timeout=self.timeout_seconds
|
||||
) as client:
|
||||
response = await client.request(method, path, headers=headers, **kwargs)
|
||||
except httpx.TimeoutException as exc:
|
||||
raise ProviderUnavailableError("ارتباط با Stripe زمانبر شد.") from exc
|
||||
except httpx.HTTPError as exc:
|
||||
raise ProviderUnavailableError("ارتباط با Stripe برقرار نشد.") from exc
|
||||
|
||||
if response.status_code >= 400:
|
||||
detail = ""
|
||||
try:
|
||||
detail = (response.json().get("error") or {}).get("message", "")
|
||||
except ValueError:
|
||||
detail = response.text[:200]
|
||||
raise ProviderUnavailableError(
|
||||
f"Stripe خطا برگرداند ({response.status_code}): {detail}"
|
||||
)
|
||||
return response.json()
|
||||
@@ -0,0 +1,895 @@
|
||||
"""SQLAlchemy ORM tables.
|
||||
|
||||
This is the *only* module that knows about the relational schema. The domain
|
||||
layer is expressed as plain dataclasses in ``app.domain.*``; repositories map
|
||||
between the two. Keeping the schema here means a migration change never forces
|
||||
a change to domain logic.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
Boolean,
|
||||
DateTime,
|
||||
Float,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.database import Base
|
||||
from app.domain.base import new_id, utcnow
|
||||
|
||||
|
||||
def _pk() -> Mapped[str]:
|
||||
return mapped_column(String(32), primary_key=True, default=new_id)
|
||||
|
||||
|
||||
def _created() -> Mapped[datetime]:
|
||||
return mapped_column(DateTime(timezone=True), default=utcnow, nullable=False)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Identity & Access
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class UserRow(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
email: Mapped[str] = mapped_column(String(320), unique=True, index=True, nullable=False)
|
||||
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(160), default="", nullable=False)
|
||||
avatar_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
is_platform_owner: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
created_at: Mapped[datetime] = _created()
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=False
|
||||
)
|
||||
|
||||
|
||||
class OrganizationRow(Base):
|
||||
__tablename__ = "organizations"
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
name: Mapped[str] = mapped_column(String(160), nullable=False)
|
||||
slug: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
|
||||
owner_user_id: Mapped[str] = mapped_column(ForeignKey("users.id"), nullable=False, index=True)
|
||||
created_at: Mapped[datetime] = _created()
|
||||
|
||||
|
||||
class MembershipRow(Base):
|
||||
__tablename__ = "memberships"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("organization_id", "user_id", name="uq_membership_org_user"),
|
||||
Index("ix_membership_org_role", "organization_id", "role"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
organization_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
user_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
role: Mapped[str] = mapped_column(String(16), default="member", nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(16), default="active", nullable=False)
|
||||
created_at: Mapped[datetime] = _created()
|
||||
|
||||
|
||||
class InviteRow(Base):
|
||||
__tablename__ = "invites"
|
||||
__table_args__ = (Index("ix_invite_org_email", "organization_id", "email"),)
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
organization_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
email: Mapped[str] = mapped_column(String(320), nullable=False)
|
||||
role: Mapped[str] = mapped_column(String(16), default="member", nullable=False)
|
||||
token_hash: Mapped[str] = mapped_column(String(128), unique=True, nullable=False)
|
||||
invited_by_user_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("users.id"), nullable=True, index=True
|
||||
)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
accepted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = _created()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AI Control Plane (platform-owner only)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class AiProviderRow(Base):
|
||||
__tablename__ = "ai_providers"
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
name: Mapped[str] = mapped_column(String(120), unique=True, nullable=False)
|
||||
type: Mapped[str] = mapped_column(String(32), default="mock", nullable=False)
|
||||
base_url: Mapped[str] = mapped_column(String(512), default="", nullable=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
created_at: Mapped[datetime] = _created()
|
||||
|
||||
|
||||
class AiModelCredentialRow(Base):
|
||||
__tablename__ = "ai_model_credentials"
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
provider_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("ai_providers.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
encrypted_api_key: Mapped[str] = mapped_column(Text, default="", nullable=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
created_at: Mapped[datetime] = _created()
|
||||
|
||||
|
||||
class AiModelRow(Base):
|
||||
__tablename__ = "ai_models"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("provider_id", "internal_name", name="uq_model_provider_name"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
provider_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("ai_providers.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
internal_name: Mapped[str] = mapped_column(String(160), nullable=False)
|
||||
display_name_for_admin: Mapped[str] = mapped_column(String(160), nullable=False)
|
||||
capabilities: Mapped[list] = mapped_column(JSON, default=list, nullable=False)
|
||||
input_price_per_1m: Mapped[float] = mapped_column(Float, default=0.0, nullable=False)
|
||||
output_price_per_1m: Mapped[float] = mapped_column(Float, default=0.0, nullable=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
created_at: Mapped[datetime] = _created()
|
||||
|
||||
|
||||
class RoutingPolicyRow(Base):
|
||||
__tablename__ = "routing_policies"
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
name: Mapped[str] = mapped_column(String(120), unique=True, nullable=False)
|
||||
description: Mapped[str] = mapped_column(Text, default="", nullable=False)
|
||||
mode: Mapped[str] = mapped_column(String(16), default="balanced", nullable=False)
|
||||
fallback_policy_json: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False)
|
||||
budget_limits_json: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False)
|
||||
is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
created_at: Mapped[datetime] = _created()
|
||||
|
||||
|
||||
class RateCardRow(Base):
|
||||
__tablename__ = "rate_cards"
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
unit: Mapped[str] = mapped_column(String(40), unique=True, nullable=False)
|
||||
customer_price: Mapped[float] = mapped_column(Float, default=0.0, nullable=False)
|
||||
internal_cost_estimate: Mapped[float] = mapped_column(Float, default=0.0, nullable=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
created_at: Mapped[datetime] = _created()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Projects & Git
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ProjectRow(Base):
|
||||
__tablename__ = "projects"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("organization_id", "slug", name="uq_project_org_slug"),
|
||||
# The project list is always scoped to one organization and ordered by
|
||||
# creation, so one composite index serves both the filter and the sort.
|
||||
Index("ix_project_org_created", "organization_id", "created_at"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
organization_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(160), nullable=False)
|
||||
slug: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
description: Mapped[str] = mapped_column(Text, default="", nullable=False)
|
||||
created_by_user_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("users.id"), nullable=True, index=True
|
||||
)
|
||||
default_branch: Mapped[str] = mapped_column(String(80), default="main", nullable=False)
|
||||
created_at: Mapped[datetime] = _created()
|
||||
|
||||
|
||||
class ProjectRepositoryRow(Base):
|
||||
__tablename__ = "project_repositories"
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
project_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("projects.id", ondelete="CASCADE"), unique=True, nullable=False, index=True
|
||||
)
|
||||
provider: Mapped[str] = mapped_column(String(32), default="gitea", nullable=False)
|
||||
owner: Mapped[str] = mapped_column(String(120), default="", nullable=False)
|
||||
repo_name: Mapped[str] = mapped_column(String(160), default="", nullable=False)
|
||||
clone_url: Mapped[str] = mapped_column(String(512), default="", nullable=False)
|
||||
web_url: Mapped[str] = mapped_column(String(512), default="", nullable=False)
|
||||
default_branch: Mapped[str] = mapped_column(String(80), default="main", nullable=False)
|
||||
created_at: Mapped[datetime] = _created()
|
||||
|
||||
|
||||
class ProjectGitCredentialRow(Base):
|
||||
__tablename__ = "project_git_credentials"
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
project_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("projects.id", ondelete="CASCADE"), unique=True, nullable=False, index=True
|
||||
)
|
||||
username: Mapped[str] = mapped_column(String(160), nullable=False)
|
||||
encrypted_password: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
created_at: Mapped[datetime] = _created()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Preview domain routing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class PreviewPortRow(Base):
|
||||
__tablename__ = "preview_ports"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("project_id", "service_name", name="uq_preview_port_project_service"),
|
||||
UniqueConstraint("port", name="uq_preview_port_port"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
organization_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
project_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
service_name: Mapped[str] = mapped_column(String(80), default="web", nullable=False)
|
||||
port: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(16), default="active", nullable=False)
|
||||
created_at: Mapped[datetime] = _created()
|
||||
|
||||
|
||||
class PreviewDomainRow(Base):
|
||||
__tablename__ = "preview_domains"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("host", name="uq_preview_domain_host"),
|
||||
Index("ix_preview_domain_org_created", "organization_id", "created_at"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
organization_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
project_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
host: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
port: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(16), default="active", nullable=False)
|
||||
protected: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
created_by_user_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("users.id"), nullable=True, index=True
|
||||
)
|
||||
created_at: Mapped[datetime] = _created()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sandbox
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class SandboxTemplateRow(Base):
|
||||
__tablename__ = "sandbox_templates"
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
name: Mapped[str] = mapped_column(String(120), unique=True, nullable=False)
|
||||
provider: Mapped[str] = mapped_column(String(32), default="mock", nullable=False)
|
||||
image: Mapped[str] = mapped_column(String(255), default="python:3.13-slim", nullable=False)
|
||||
description: Mapped[str] = mapped_column(Text, default="", nullable=False)
|
||||
default_cpu: Mapped[float] = mapped_column(Float, default=1.0, nullable=False)
|
||||
default_memory_mb: Mapped[int] = mapped_column(Integer, default=1024, nullable=False)
|
||||
default_disk_mb: Mapped[int] = mapped_column(Integer, default=2048, nullable=False)
|
||||
is_system: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
created_at: Mapped[datetime] = _created()
|
||||
|
||||
|
||||
class WorkspaceRow(Base):
|
||||
__tablename__ = "workspaces"
|
||||
__table_args__ = (Index("ix_workspace_project_created", "project_id", "created_at"),)
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
organization_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
project_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
template_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("sandbox_templates.id"), nullable=False, index=True
|
||||
)
|
||||
provider: Mapped[str] = mapped_column(String(32), default="mock", nullable=False)
|
||||
external_workspace_id: Mapped[str | None] = mapped_column(String(160), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(16), default="pending", nullable=False)
|
||||
created_at: Mapped[datetime] = _created()
|
||||
|
||||
|
||||
class SandboxSessionRow(Base):
|
||||
__tablename__ = "sandbox_sessions"
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
workspace_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
external_session_id: Mapped[str | None] = mapped_column(String(160), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(16), default="starting", nullable=False)
|
||||
started_at: Mapped[datetime] = _created()
|
||||
stopped_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
last_active_at: Mapped[datetime] = _created()
|
||||
|
||||
|
||||
class CommandRunRow(Base):
|
||||
__tablename__ = "command_runs"
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
session_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("sandbox_sessions.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
command: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
exit_code: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
stdout_ref: Mapped[str] = mapped_column(Text, default="", nullable=False)
|
||||
stderr_ref: Mapped[str] = mapped_column(Text, default="", nullable=False)
|
||||
duration_ms: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
truncated: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
created_at: Mapped[datetime] = _created()
|
||||
|
||||
|
||||
class SnapshotRow(Base):
|
||||
__tablename__ = "snapshots"
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
workspace_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
external_snapshot_id: Mapped[str | None] = mapped_column(String(160), nullable=True)
|
||||
created_at: Mapped[datetime] = _created()
|
||||
|
||||
|
||||
class PreviewUrlRow(Base):
|
||||
__tablename__ = "preview_urls"
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
workspace_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
port: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
url: Mapped[str] = mapped_column(String(512), nullable=False)
|
||||
created_at: Mapped[datetime] = _created()
|
||||
|
||||
|
||||
class SandboxResourceLimitRow(Base):
|
||||
__tablename__ = "sandbox_resource_limits"
|
||||
__table_args__ = (UniqueConstraint("scope", "scope_id", name="uq_limit_scope"),)
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
scope: Mapped[str] = mapped_column(String(32), default="organization", nullable=False)
|
||||
scope_id: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
cpu: Mapped[float] = mapped_column(Float, default=1.0, nullable=False)
|
||||
memory_mb: Mapped[int] = mapped_column(Integer, default=1024, nullable=False)
|
||||
disk_mb: Mapped[int] = mapped_column(Integer, default=2048, nullable=False)
|
||||
timeout_seconds: Mapped[int] = mapped_column(Integer, default=120, nullable=False)
|
||||
max_output_bytes: Mapped[int] = mapped_column(Integer, default=200_000, nullable=False)
|
||||
network_policy: Mapped[str] = mapped_column(String(16), default="deny_all", nullable=False)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Workforce: templates, employees, chat, tasks, runs, artifacts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class AgentTemplateRow(Base):
|
||||
__tablename__ = "agent_templates"
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
name: Mapped[str] = mapped_column(String(120), unique=True, nullable=False)
|
||||
role_key: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
description: Mapped[str] = mapped_column(Text, default="", nullable=False)
|
||||
default_prompt: Mapped[str] = mapped_column(Text, default="", nullable=False)
|
||||
default_tools_json: Mapped[list] = mapped_column(JSON, default=list, nullable=False)
|
||||
default_routing_policy_id: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
is_system: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
created_at: Mapped[datetime] = _created()
|
||||
|
||||
|
||||
class EmployeeRow(Base):
|
||||
__tablename__ = "employees"
|
||||
__table_args__ = (Index("ix_employee_org_created", "organization_id", "created_at"),)
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
organization_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
project_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("projects.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
template_id: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
name: Mapped[str] = mapped_column(String(160), nullable=False)
|
||||
role_key: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(16), default="active", nullable=False)
|
||||
routing_policy_id: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
budget_limit_json: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False)
|
||||
created_at: Mapped[datetime] = _created()
|
||||
|
||||
|
||||
class PromptVersionRow(Base):
|
||||
__tablename__ = "prompt_versions"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("employee_id", "version", name="uq_prompt_employee_version"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
employee_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("employees.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
version: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
created_by_user_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("users.id"), nullable=True, index=True
|
||||
)
|
||||
created_at: Mapped[datetime] = _created()
|
||||
|
||||
|
||||
class EmployeeToolPermissionRow(Base):
|
||||
__tablename__ = "employee_tool_permissions"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("employee_id", "tool_key", name="uq_employee_tool"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
employee_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("employees.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
tool_key: Mapped[str] = mapped_column(String(80), nullable=False)
|
||||
allowed: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
policy_json: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False)
|
||||
|
||||
|
||||
class ThreadRow(Base):
|
||||
__tablename__ = "threads"
|
||||
__table_args__ = (Index("ix_thread_org_created", "organization_id", "created_at"),)
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
organization_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
project_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("projects.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(255), default="", nullable=False)
|
||||
created_by_user_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("users.id"), nullable=True, index=True
|
||||
)
|
||||
created_at: Mapped[datetime] = _created()
|
||||
|
||||
|
||||
class MessageRow(Base):
|
||||
__tablename__ = "messages"
|
||||
# A conversation is always read as "one thread, oldest first", so the
|
||||
# composite index serves the filter and the ordering in one lookup.
|
||||
__table_args__ = (Index("ix_message_thread_created", "thread_id", "created_at"),)
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
thread_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("threads.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
role: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
content: Mapped[str] = mapped_column(Text, default="", nullable=False)
|
||||
employee_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
|
||||
created_at: Mapped[datetime] = _created()
|
||||
|
||||
|
||||
class TaskRow(Base):
|
||||
__tablename__ = "tasks"
|
||||
__table_args__ = (
|
||||
Index("ix_task_org_status", "organization_id", "status"),
|
||||
Index("ix_task_org_created", "organization_id", "created_at"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
organization_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
project_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("projects.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
thread_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("threads.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str] = mapped_column(Text, default="", nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(24), default="new", nullable=False)
|
||||
assigned_employee_id: Mapped[str | None] = mapped_column(
|
||||
String(32), nullable=True, index=True
|
||||
)
|
||||
created_by_user_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("users.id"), nullable=True, index=True
|
||||
)
|
||||
created_at: Mapped[datetime] = _created()
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=False
|
||||
)
|
||||
|
||||
|
||||
class RunRow(Base):
|
||||
__tablename__ = "runs"
|
||||
# A run has no organization_id of its own: it is scoped through its task,
|
||||
# which is the object that carries the tenant. Indexing a column that does
|
||||
# not exist here would be wrong, so the composite stays on task_id.
|
||||
__table_args__ = (Index("ix_run_task_created", "task_id", "created_at"),)
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
task_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("tasks.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
employee_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
|
||||
status: Mapped[str] = mapped_column(String(24), default="queued", nullable=False)
|
||||
error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = _created()
|
||||
|
||||
|
||||
class RunStepRow(Base):
|
||||
__tablename__ = "run_steps"
|
||||
__table_args__ = (Index("ix_run_step_run_index", "run_id", "index"),)
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
run_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("runs.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
index: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
kind: Mapped[str] = mapped_column(String(40), default="note", nullable=False)
|
||||
title: Mapped[str] = mapped_column(String(255), default="", nullable=False)
|
||||
payload_json: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False)
|
||||
created_at: Mapped[datetime] = _created()
|
||||
|
||||
|
||||
class ToolCallRow(Base):
|
||||
__tablename__ = "tool_calls"
|
||||
__table_args__ = (
|
||||
Index("ix_tool_call_run_created", "run_id", "created_at"),
|
||||
# The pending-approval queue is "one organization, still awaiting a
|
||||
# decision", which is a filter on status plus a join back to the run.
|
||||
Index("ix_tool_call_status", "status"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
run_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("runs.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
tool_key: Mapped[str] = mapped_column(String(80), nullable=False)
|
||||
arguments_json: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False)
|
||||
result_json: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(24), default="pending", nullable=False)
|
||||
requires_approval: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
approved_by_user_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("users.id"), nullable=True, index=True
|
||||
)
|
||||
created_at: Mapped[datetime] = _created()
|
||||
|
||||
|
||||
class ArtifactRow(Base):
|
||||
__tablename__ = "artifacts"
|
||||
__table_args__ = (Index("ix_artifact_org_created", "organization_id", "created_at"),)
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
organization_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
project_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("projects.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
run_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("runs.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
kind: Mapped[str] = mapped_column(String(32), default="file", nullable=False)
|
||||
content_ref: Mapped[str] = mapped_column(Text, default="", nullable=False)
|
||||
size_bytes: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
created_at: Mapped[datetime] = _created()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Billing & Usage
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class PlanRow(Base):
|
||||
__tablename__ = "plans"
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
name: Mapped[str] = mapped_column(String(120), unique=True, nullable=False)
|
||||
monthly_price: Mapped[float] = mapped_column(Float, default=0.0, nullable=False)
|
||||
included_credit: Mapped[float] = mapped_column(Float, default=0.0, nullable=False)
|
||||
features_json: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
created_at: Mapped[datetime] = _created()
|
||||
|
||||
|
||||
class SubscriptionRow(Base):
|
||||
__tablename__ = "subscriptions"
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
organization_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
plan_id: Mapped[str] = mapped_column(ForeignKey("plans.id"), nullable=False, index=True)
|
||||
status: Mapped[str] = mapped_column(String(16), default="active", nullable=False)
|
||||
external_subscription_id: Mapped[str | None] = mapped_column(String(160), nullable=True)
|
||||
current_period_end: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
created_at: Mapped[datetime] = _created()
|
||||
|
||||
|
||||
class CreditWalletRow(Base):
|
||||
__tablename__ = "credit_wallets"
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
organization_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("organizations.id", ondelete="CASCADE"), unique=True, nullable=False, index=True
|
||||
)
|
||||
balance: Mapped[float] = mapped_column(Float, default=0.0, nullable=False)
|
||||
currency: Mapped[str] = mapped_column(String(8), default="USD", nullable=False)
|
||||
low_balance_threshold: Mapped[float] = mapped_column(Float, default=5.0, nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=False
|
||||
)
|
||||
|
||||
|
||||
class UsageEventRow(Base):
|
||||
__tablename__ = "usage_events"
|
||||
__table_args__ = (
|
||||
Index("ix_usage_org_created", "organization_id", "created_at"),
|
||||
Index("ix_usage_org_unit", "organization_id", "unit"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
organization_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
project_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
|
||||
user_id: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
employee_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
|
||||
run_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
|
||||
unit: Mapped[str] = mapped_column(String(40), nullable=False)
|
||||
quantity: Mapped[float] = mapped_column(Float, default=0.0, nullable=False)
|
||||
unit_price: Mapped[float] = mapped_column(Float, default=0.0, nullable=False)
|
||||
internal_cost_estimate: Mapped[float] = mapped_column(Float, default=0.0, nullable=False)
|
||||
total_price: Mapped[float] = mapped_column(Float, default=0.0, nullable=False)
|
||||
metadata_json: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False)
|
||||
created_at: Mapped[datetime] = _created()
|
||||
|
||||
|
||||
class InvoiceRow(Base):
|
||||
__tablename__ = "invoices"
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
organization_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
amount: Mapped[float] = mapped_column(Float, default=0.0, nullable=False)
|
||||
currency: Mapped[str] = mapped_column(String(8), default="USD", nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(16), default="draft", nullable=False)
|
||||
external_invoice_id: Mapped[str | None] = mapped_column(String(160), nullable=True)
|
||||
period_start: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
period_end: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = _created()
|
||||
|
||||
|
||||
class PaymentRow(Base):
|
||||
__tablename__ = "payments"
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
organization_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
amount: Mapped[float] = mapped_column(Float, default=0.0, nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(16), default="pending", nullable=False)
|
||||
provider: Mapped[str] = mapped_column(String(32), default="mock", nullable=False)
|
||||
external_payment_id: Mapped[str | None] = mapped_column(String(160), nullable=True)
|
||||
invoice_id: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
created_at: Mapped[datetime] = _created()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Knowledge
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class KnowledgeSourceRow(Base):
|
||||
__tablename__ = "knowledge_sources"
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
organization_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
project_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
kind: Mapped[str] = mapped_column(String(16), default="text", nullable=False)
|
||||
uri: Mapped[str] = mapped_column(String(512), default="", nullable=False)
|
||||
created_at: Mapped[datetime] = _created()
|
||||
|
||||
|
||||
class DocumentRow(Base):
|
||||
__tablename__ = "documents"
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
source_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("knowledge_sources.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(255), default="", nullable=False)
|
||||
content: Mapped[str] = mapped_column(Text, default="", nullable=False)
|
||||
created_at: Mapped[datetime] = _created()
|
||||
|
||||
|
||||
class DocumentChunkRow(Base):
|
||||
__tablename__ = "document_chunks"
|
||||
__table_args__ = (Index("ix_chunk_document_index", "document_id", "index"),)
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
document_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("documents.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
index: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
content: Mapped[str] = mapped_column(Text, default="", nullable=False)
|
||||
embedding_json: Mapped[list] = mapped_column(JSON, default=list, nullable=False)
|
||||
|
||||
|
||||
class EmbeddingJobRow(Base):
|
||||
__tablename__ = "embedding_jobs"
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
source_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("knowledge_sources.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
status: Mapped[str] = mapped_column(String(16), default="pending", nullable=False)
|
||||
error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = _created()
|
||||
|
||||
|
||||
class RetrievalPolicyRow(Base):
|
||||
__tablename__ = "retrieval_policies"
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
organization_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
scope_json: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False)
|
||||
created_at: Mapped[datetime] = _created()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Audit & Observability
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class AuditEventRow(Base):
|
||||
__tablename__ = "audit_events"
|
||||
__table_args__ = (
|
||||
Index("ix_audit_org_created", "organization_id", "created_at"),
|
||||
Index("ix_audit_action", "action"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
organization_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
|
||||
actor_user_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
|
||||
action: Mapped[str] = mapped_column(String(80), nullable=False)
|
||||
target_type: Mapped[str] = mapped_column(String(40), default="", nullable=False)
|
||||
target_id: Mapped[str] = mapped_column(String(64), default="", nullable=False)
|
||||
metadata_json: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False)
|
||||
ip_address: Mapped[str] = mapped_column(String(64), default="", nullable=False)
|
||||
user_agent: Mapped[str] = mapped_column(String(255), default="", nullable=False)
|
||||
created_at: Mapped[datetime] = _created()
|
||||
|
||||
|
||||
class TraceRow(Base):
|
||||
__tablename__ = "traces"
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
organization_id: Mapped[str] = mapped_column(String(32), nullable=False, index=True)
|
||||
run_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
|
||||
name: Mapped[str] = mapped_column(String(160), default="", nullable=False)
|
||||
created_at: Mapped[datetime] = _created()
|
||||
|
||||
|
||||
class SpanRow(Base):
|
||||
__tablename__ = "spans"
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
trace_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("traces.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
parent_span_id: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
name: Mapped[str] = mapped_column(String(160), nullable=False)
|
||||
kind: Mapped[str] = mapped_column(String(32), default="internal", nullable=False)
|
||||
attributes_json: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False)
|
||||
started_at: Mapped[datetime] = _created()
|
||||
ended_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
|
||||
class ErrorEventRow(Base):
|
||||
"""Persisted failures — the table the error console reads.
|
||||
|
||||
Wide on purpose: a row that lacks the request path or the exception class
|
||||
forces whoever is debugging to reproduce the bug to learn what it was. The
|
||||
indexes cover the three access patterns — filter by tenant, filter by
|
||||
"still open", and group by fingerprint.
|
||||
"""
|
||||
|
||||
__tablename__ = "error_events"
|
||||
__table_args__ = (
|
||||
# Grouping view: "how often did this exact bug happen, and when last?"
|
||||
Index("ix_error_events_fingerprint_created", "fingerprint", "created_at"),
|
||||
# Feed view: "show me the newest failures for this tenant."
|
||||
Index("ix_error_events_org_created", "organization_id", "created_at"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
organization_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
|
||||
run_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
|
||||
kind: Mapped[str] = mapped_column(String(80), nullable=False)
|
||||
message: Mapped[str] = mapped_column(Text, default="", nullable=False)
|
||||
stack: Mapped[str] = mapped_column(Text, default="", nullable=False)
|
||||
metadata_json: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False)
|
||||
|
||||
# --- classification -----------------------------------------------------
|
||||
source: Mapped[str] = mapped_column(String(16), default="http", nullable=False)
|
||||
severity: Mapped[str] = mapped_column(String(16), default="error", nullable=False)
|
||||
error_type: Mapped[str] = mapped_column(String(200), default="", nullable=False)
|
||||
#: Groups identical failures. Empty string rather than NULL so the grouping
|
||||
#: index never has to deal with a NULL key.
|
||||
fingerprint: Mapped[str] = mapped_column(String(64), default="", nullable=False, index=True)
|
||||
|
||||
# --- transport context --------------------------------------------------
|
||||
status_code: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
method: Mapped[str] = mapped_column(String(10), default="", nullable=False)
|
||||
path: Mapped[str] = mapped_column(String(512), default="", nullable=False)
|
||||
|
||||
# --- actor context ------------------------------------------------------
|
||||
user_id: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
request_id: Mapped[str] = mapped_column(String(64), default="", nullable=False)
|
||||
ip: Mapped[str] = mapped_column(String(64), default="", nullable=False)
|
||||
user_agent: Mapped[str] = mapped_column(String(255), default="", nullable=False)
|
||||
|
||||
# --- triage -------------------------------------------------------------
|
||||
resolved_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True, index=True
|
||||
)
|
||||
resolved_note: Mapped[str] = mapped_column(Text, default="", nullable=False)
|
||||
|
||||
created_at: Mapped[datetime] = _created()
|
||||
|
||||
|
||||
class QualityMetricRow(Base):
|
||||
__tablename__ = "quality_metrics"
|
||||
__table_args__ = (Index("ix_metric_org_name", "organization_id", "name"),)
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
organization_id: Mapped[str] = mapped_column(String(32), nullable=False, index=True)
|
||||
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
value: Mapped[float] = mapped_column(Float, default=0.0, nullable=False)
|
||||
dimensions_json: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False)
|
||||
created_at: Mapped[datetime] = _created()
|
||||
@@ -0,0 +1,281 @@
|
||||
"""Persist failures so they can be diagnosed after the fact.
|
||||
|
||||
Three properties make this module different from an ordinary repository call,
|
||||
and all three are load-bearing:
|
||||
|
||||
**It never raises.** This is called from inside exception handlers, workers and
|
||||
startup code — the exact places where an exception is already in flight. A
|
||||
recorder that throws turns a handled 500 into an unhandled one, or kills a
|
||||
worker mid-batch. Every failure here is swallowed and reported on stderr.
|
||||
|
||||
**It uses its own session.** The caller's session is frequently unusable by the
|
||||
time we get here: an ``IntegrityError`` leaves it in a failed transaction where
|
||||
every subsequent statement raises ``PendingRollbackError``. Reusing it would
|
||||
mean the errors hardest to record — the database errors — are exactly the ones
|
||||
that never get recorded. So we open a short-lived session of our own, commit,
|
||||
and close.
|
||||
|
||||
**It redacts before it writes.** Everything passes through
|
||||
``app.core.redact`` first. Error payloads are where credentials concentrate.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import sys
|
||||
import traceback
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from app.core.database import SessionFactory
|
||||
from app.core.redact import redact_mapping, redact_text
|
||||
from app.domain.audit.models import (
|
||||
ErrorEvent,
|
||||
ErrorSeverity,
|
||||
ErrorSource,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("agy.errors")
|
||||
|
||||
#: Field caps. An error log that accepts unbounded input is a denial-of-service
|
||||
#: vector and a storage leak; a 2 MB stack trace helps nobody.
|
||||
MAX_MESSAGE_CHARS = 4_000
|
||||
MAX_STACK_CHARS = 16_000
|
||||
MAX_METADATA_JSON_CHARS = 20_000
|
||||
MAX_KIND_CHARS = 80
|
||||
MAX_TYPE_CHARS = 200
|
||||
MAX_PATH_CHARS = 512
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fingerprinting — collapse a thousand occurrences into one row
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_UUID_RE = re.compile(
|
||||
r"\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}"
|
||||
r"-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b"
|
||||
)
|
||||
_HEX_RE = re.compile(r"\b[0-9a-fA-F]{8,}\b")
|
||||
_NUMBER_RE = re.compile(r"\b\d+\b")
|
||||
_QUOTED_RE = re.compile(r"'[^']{0,200}'|\"[^\"]{0,200}\"")
|
||||
#: A path segment that is an entity id rather than a route name. All-hex catches
|
||||
#: ``new_id()`` (32 hex chars); the digit+length rule catches other id styles.
|
||||
#: Route words like ``organizations`` have no digits and survive untouched.
|
||||
_ID_SEGMENT_RE = re.compile(r"^(?:[0-9a-fA-F]{8,}|(?=.*\d)[A-Za-z0-9_-]{16,})$")
|
||||
|
||||
|
||||
def _normalise_message(message: str) -> str:
|
||||
"""Strip the variable parts of a message so equal bugs hash equally.
|
||||
|
||||
``"task 4f3a… not found"`` and ``"task 9b1c… not found"`` are one bug with
|
||||
two victims; without this they are two unrelated rows.
|
||||
"""
|
||||
text = _UUID_RE.sub("{uuid}", message)
|
||||
text = _HEX_RE.sub("{hex}", text)
|
||||
text = _QUOTED_RE.sub("{value}", text)
|
||||
return _NUMBER_RE.sub("{n}", text)
|
||||
|
||||
|
||||
def _normalise_path(path: str) -> str:
|
||||
"""``/api/orgs/4f3a…/projects`` → ``/api/orgs/{id}/projects``."""
|
||||
if not path:
|
||||
return ""
|
||||
return "/".join(
|
||||
"{id}" if _ID_SEGMENT_RE.match(segment) else segment for segment in path.split("/")
|
||||
)
|
||||
|
||||
|
||||
def compute_fingerprint(
|
||||
*,
|
||||
kind: str,
|
||||
error_type: str,
|
||||
method: str = "",
|
||||
path: str = "",
|
||||
message: str = "",
|
||||
) -> str:
|
||||
"""Stable identity for a failure, independent of tenant and occurrence.
|
||||
|
||||
Deliberately *excludes* organization, user and request id: the same bug
|
||||
hitting two tenants is still one bug, and grouping it that way is the whole
|
||||
point of a fingerprint.
|
||||
"""
|
||||
material = "\x1f".join(
|
||||
(
|
||||
kind.strip().lower(),
|
||||
error_type.strip().lower(),
|
||||
method.strip().upper(),
|
||||
_normalise_path(path).lower(),
|
||||
_normalise_message(message.strip())[:500],
|
||||
)
|
||||
)
|
||||
return hashlib.sha256(material.encode("utf-8", "replace")).hexdigest()[:32]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Truncation helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _clip(value: str, limit: int) -> str:
|
||||
if len(value) <= limit:
|
||||
return value
|
||||
return value[:limit] + f"…[+{len(value) - limit} chars]"
|
||||
|
||||
|
||||
def _safe_metadata(metadata: Mapping[str, Any] | None) -> dict[str, Any]:
|
||||
"""Redact, then bound. Returns a dict that is always JSON-serialisable."""
|
||||
if not metadata:
|
||||
return {}
|
||||
scrubbed = redact_mapping(metadata)
|
||||
try:
|
||||
encoded = json.dumps(scrubbed, ensure_ascii=False, default=str)
|
||||
except (TypeError, ValueError):
|
||||
return {"_note": "metadata was not JSON-serialisable"}
|
||||
if len(encoded) <= MAX_METADATA_JSON_CHARS:
|
||||
return scrubbed
|
||||
# Too large to keep: retain the keys so the shape is still visible.
|
||||
return {
|
||||
"_note": f"metadata dropped: {len(encoded)} chars exceeds {MAX_METADATA_JSON_CHARS}",
|
||||
"_keys": sorted(str(k) for k in scrubbed)[:50],
|
||||
}
|
||||
|
||||
|
||||
def format_exception_stack(exc: BaseException) -> str:
|
||||
"""``traceback.format_exception`` without the chained-context noise limit."""
|
||||
return "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))
|
||||
|
||||
|
||||
def _to_stderr(event: ErrorEvent, reason: str) -> None:
|
||||
"""Last-resort output when the database write failed.
|
||||
|
||||
Goes to stderr rather than through ``logging`` so it survives even a
|
||||
misconfigured logging setup — the one case where losing the error entirely
|
||||
is unacceptable.
|
||||
"""
|
||||
with contextlib.suppress(Exception):
|
||||
sys.stderr.write(
|
||||
f"[error-log] could not persist error ({reason}) "
|
||||
f"kind={event.kind} type={event.error_type} "
|
||||
f"path={event.path} msg={event.message[:300]}\n"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def record_error(
|
||||
*,
|
||||
kind: str,
|
||||
message: str,
|
||||
exc: BaseException | None = None,
|
||||
stack: str = "",
|
||||
source: str = ErrorSource.HTTP,
|
||||
severity: str = ErrorSeverity.ERROR,
|
||||
status_code: int | None = None,
|
||||
method: str = "",
|
||||
path: str = "",
|
||||
organization_id: str | None = None,
|
||||
user_id: str | None = None,
|
||||
run_id: str | None = None,
|
||||
request_id: str = "",
|
||||
ip: str = "",
|
||||
user_agent: str = "",
|
||||
metadata: Mapping[str, Any] | None = None,
|
||||
) -> str | None:
|
||||
"""Write one error row. Returns its id, or ``None`` if recording failed.
|
||||
|
||||
Returning ``None`` rather than raising is the contract: callers treat error
|
||||
recording as best-effort telemetry, never as a step that can fail a request.
|
||||
"""
|
||||
try:
|
||||
error_type = type(exc).__name__ if exc is not None else ""
|
||||
if not stack and exc is not None:
|
||||
stack = format_exception_stack(exc)
|
||||
if not message and exc is not None:
|
||||
message = str(exc) or error_type
|
||||
|
||||
# Redact *before* fingerprinting is not required, but redact before
|
||||
# *storage* absolutely is. Fingerprint the raw message so a secret that
|
||||
# happens to appear in a message does not change the grouping.
|
||||
fingerprint = compute_fingerprint(
|
||||
kind=kind, error_type=error_type, method=method, path=path, message=message
|
||||
)
|
||||
|
||||
event = ErrorEvent(
|
||||
kind=_clip(redact_text(kind), MAX_KIND_CHARS),
|
||||
message=_clip(redact_text(message), MAX_MESSAGE_CHARS),
|
||||
stack=_clip(redact_text(stack), MAX_STACK_CHARS),
|
||||
metadata_json=_safe_metadata(metadata),
|
||||
organization_id=organization_id,
|
||||
user_id=user_id,
|
||||
run_id=run_id,
|
||||
source=source,
|
||||
severity=severity,
|
||||
error_type=_clip(redact_text(error_type), MAX_TYPE_CHARS),
|
||||
fingerprint=fingerprint,
|
||||
status_code=status_code,
|
||||
method=_clip(method, 10),
|
||||
path=_clip(redact_text(path), MAX_PATH_CHARS),
|
||||
request_id=_clip(request_id, 64),
|
||||
ip=_clip(redact_text(ip), 64),
|
||||
user_agent=_clip(redact_text(user_agent), 255),
|
||||
)
|
||||
except Exception as build_exc: # pragma: no cover - redaction is defensive
|
||||
logger.exception("error-log: could not build the error record: %s", build_exc)
|
||||
return None
|
||||
|
||||
# A dedicated session: the caller's may be mid-rollback, and a failed error
|
||||
# write must not roll back whatever the caller was legitimately doing.
|
||||
try:
|
||||
async with SessionFactory() as session:
|
||||
from app.infrastructure.repositories.audit import ObservabilityRepository
|
||||
|
||||
await ObservabilityRepository(session).record_error(event)
|
||||
await session.commit()
|
||||
except Exception as write_exc:
|
||||
_to_stderr(event, type(write_exc).__name__)
|
||||
logger.warning("error-log: persistence failed: %s", write_exc)
|
||||
return None
|
||||
|
||||
# Mirror to the application log so the console and the terminal agree.
|
||||
logger.log(
|
||||
logging.ERROR if severity != ErrorSeverity.WARNING else logging.WARNING,
|
||||
"%s [%s] %s %s -> %s: %s (request_id=%s fingerprint=%s)",
|
||||
severity.upper(),
|
||||
source,
|
||||
method or "-",
|
||||
path or "-",
|
||||
status_code if status_code is not None else "-",
|
||||
event.message[:500],
|
||||
request_id or "-",
|
||||
fingerprint,
|
||||
)
|
||||
return event.id
|
||||
|
||||
|
||||
async def record_exception(
|
||||
exc: BaseException,
|
||||
*,
|
||||
kind: str | None = None,
|
||||
**context: Any,
|
||||
) -> str | None:
|
||||
"""``record_error`` for the common case where there is an exception object."""
|
||||
return await record_error(
|
||||
kind=kind or type(exc).__name__,
|
||||
message=str(exc),
|
||||
exc=exc,
|
||||
**context,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"compute_fingerprint",
|
||||
"format_exception_stack",
|
||||
"record_error",
|
||||
"record_exception",
|
||||
]
|
||||
@@ -0,0 +1,276 @@
|
||||
"""Repositories for the AI Control Plane.
|
||||
|
||||
Platform-owner-only data. The application layer is responsible for never
|
||||
serialising any of it to a customer-facing response; these repositories just
|
||||
move rows.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.domain.ai_control.models import (
|
||||
AiModel,
|
||||
ModelCredential,
|
||||
Provider,
|
||||
ProviderType,
|
||||
RateCard,
|
||||
RateUnit,
|
||||
RoutingMode,
|
||||
RoutingPolicy,
|
||||
)
|
||||
from app.infrastructure.db.models import (
|
||||
AiModelCredentialRow,
|
||||
AiModelRow,
|
||||
AiProviderRow,
|
||||
RateCardRow,
|
||||
RoutingPolicyRow,
|
||||
)
|
||||
|
||||
|
||||
def _provider_to_domain(row: AiProviderRow) -> Provider:
|
||||
return Provider(
|
||||
id=row.id, name=row.name, type=ProviderType(row.type),
|
||||
base_url=row.base_url, is_active=row.is_active, created_at=row.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _credential_to_domain(row: AiModelCredentialRow) -> ModelCredential:
|
||||
return ModelCredential(
|
||||
id=row.id, provider_id=row.provider_id, name=row.name,
|
||||
encrypted_api_key=row.encrypted_api_key, is_active=row.is_active,
|
||||
created_at=row.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _model_to_domain(row: AiModelRow) -> AiModel:
|
||||
return AiModel(
|
||||
id=row.id, provider_id=row.provider_id, internal_name=row.internal_name,
|
||||
display_name_for_admin=row.display_name_for_admin,
|
||||
capabilities=list(row.capabilities or []),
|
||||
input_price_per_1m=row.input_price_per_1m,
|
||||
output_price_per_1m=row.output_price_per_1m,
|
||||
is_active=row.is_active, created_at=row.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _policy_to_domain(row: RoutingPolicyRow) -> RoutingPolicy:
|
||||
return RoutingPolicy(
|
||||
id=row.id, name=row.name, description=row.description,
|
||||
mode=RoutingMode(row.mode),
|
||||
fallback_policy_json=dict(row.fallback_policy_json or {}),
|
||||
budget_limits_json=dict(row.budget_limits_json or {}),
|
||||
is_default=row.is_default, is_active=row.is_active, created_at=row.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _rate_card_to_domain(row: RateCardRow) -> RateCard:
|
||||
return RateCard(
|
||||
id=row.id, name=row.name, unit=RateUnit(row.unit),
|
||||
customer_price=row.customer_price,
|
||||
internal_cost_estimate=row.internal_cost_estimate,
|
||||
is_active=row.is_active, created_at=row.created_at,
|
||||
)
|
||||
|
||||
|
||||
class ProviderRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def list(self) -> list[Provider]:
|
||||
stmt = select(AiProviderRow).order_by(AiProviderRow.created_at)
|
||||
return [_provider_to_domain(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||
|
||||
async def get(self, provider_id: str) -> Provider | None:
|
||||
row = await self.session.get(AiProviderRow, provider_id)
|
||||
return _provider_to_domain(row) if row else None
|
||||
|
||||
async def get_by_name(self, name: str) -> Provider | None:
|
||||
stmt = select(AiProviderRow).where(AiProviderRow.name == name)
|
||||
row = (await self.session.execute(stmt)).scalar_one_or_none()
|
||||
return _provider_to_domain(row) if row else None
|
||||
|
||||
async def create(self, provider: Provider) -> Provider:
|
||||
row = AiProviderRow(
|
||||
id=provider.id, name=provider.name, type=provider.type.value,
|
||||
base_url=provider.base_url, is_active=provider.is_active,
|
||||
created_at=provider.created_at,
|
||||
)
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
return _provider_to_domain(row)
|
||||
|
||||
async def set_active(self, provider_id: str, active: bool) -> None:
|
||||
row = await self.session.get(AiProviderRow, provider_id)
|
||||
if row:
|
||||
row.is_active = active
|
||||
await self.session.flush()
|
||||
|
||||
|
||||
class ModelCredentialRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def list_for_provider(self, provider_id: str) -> list[ModelCredential]:
|
||||
stmt = select(AiModelCredentialRow).where(
|
||||
AiModelCredentialRow.provider_id == provider_id
|
||||
)
|
||||
return [_credential_to_domain(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||
|
||||
async def get(self, credential_id: str) -> ModelCredential | None:
|
||||
row = await self.session.get(AiModelCredentialRow, credential_id)
|
||||
return _credential_to_domain(row) if row else None
|
||||
|
||||
async def get_active_for_provider(self, provider_id: str) -> ModelCredential | None:
|
||||
stmt = (
|
||||
select(AiModelCredentialRow)
|
||||
.where(
|
||||
AiModelCredentialRow.provider_id == provider_id,
|
||||
AiModelCredentialRow.is_active.is_(True),
|
||||
)
|
||||
.order_by(AiModelCredentialRow.created_at.desc())
|
||||
)
|
||||
row = (await self.session.execute(stmt)).scalars().first()
|
||||
return _credential_to_domain(row) if row else None
|
||||
|
||||
async def create(self, credential: ModelCredential) -> ModelCredential:
|
||||
row = AiModelCredentialRow(
|
||||
id=credential.id, provider_id=credential.provider_id, name=credential.name,
|
||||
encrypted_api_key=credential.encrypted_api_key,
|
||||
is_active=credential.is_active, created_at=credential.created_at,
|
||||
)
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
return _credential_to_domain(row)
|
||||
|
||||
|
||||
class AiModelRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def list(self, *, provider_id: str | None = None) -> list[AiModel]:
|
||||
stmt = select(AiModelRow)
|
||||
if provider_id:
|
||||
stmt = stmt.where(AiModelRow.provider_id == provider_id)
|
||||
stmt = stmt.order_by(AiModelRow.created_at)
|
||||
return [_model_to_domain(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||
|
||||
async def get(self, model_id: str) -> AiModel | None:
|
||||
row = await self.session.get(AiModelRow, model_id)
|
||||
return _model_to_domain(row) if row else None
|
||||
|
||||
async def create(self, model: AiModel) -> AiModel:
|
||||
row = AiModelRow(
|
||||
id=model.id, provider_id=model.provider_id,
|
||||
internal_name=model.internal_name,
|
||||
display_name_for_admin=model.display_name_for_admin,
|
||||
capabilities=list(model.capabilities),
|
||||
input_price_per_1m=model.input_price_per_1m,
|
||||
output_price_per_1m=model.output_price_per_1m,
|
||||
is_active=model.is_active, created_at=model.created_at,
|
||||
)
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
return _model_to_domain(row)
|
||||
|
||||
async def pick_for_mode(self, mode: RoutingMode) -> AiModel | None:
|
||||
"""Choose a model for a routing mode.
|
||||
|
||||
Deterministic and explainable: filter by required capability, then take
|
||||
the cheapest active model. A real implementation would add health and
|
||||
latency signals; keeping it simple here makes the behaviour testable.
|
||||
"""
|
||||
required: set[str] = set()
|
||||
if mode == RoutingMode.VISION:
|
||||
required.add("vision")
|
||||
if mode == RoutingMode.CODING:
|
||||
required.add("code")
|
||||
if mode == RoutingMode.REASONING:
|
||||
required.add("reasoning")
|
||||
|
||||
candidates = [m for m in await self.list() if m.is_active]
|
||||
if required:
|
||||
filtered = [m for m in candidates if required.issubset(set(m.capabilities))]
|
||||
if filtered:
|
||||
candidates = filtered
|
||||
if not candidates:
|
||||
return None
|
||||
return min(candidates, key=lambda m: (m.input_price_per_1m, m.output_price_per_1m))
|
||||
|
||||
|
||||
class RoutingPolicyRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def list(self) -> list[RoutingPolicy]:
|
||||
stmt = select(RoutingPolicyRow).order_by(RoutingPolicyRow.created_at)
|
||||
return [_policy_to_domain(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||
|
||||
async def get(self, policy_id: str) -> RoutingPolicy | None:
|
||||
row = await self.session.get(RoutingPolicyRow, policy_id)
|
||||
return _policy_to_domain(row) if row else None
|
||||
|
||||
async def get_default(self) -> RoutingPolicy | None:
|
||||
stmt = select(RoutingPolicyRow).where(RoutingPolicyRow.is_default.is_(True))
|
||||
row = (await self.session.execute(stmt)).scalars().first()
|
||||
return _policy_to_domain(row) if row else None
|
||||
|
||||
async def create(self, policy: RoutingPolicy) -> RoutingPolicy:
|
||||
if policy.is_default:
|
||||
await self._clear_defaults()
|
||||
row = RoutingPolicyRow(
|
||||
id=policy.id, name=policy.name, description=policy.description,
|
||||
mode=policy.mode.value,
|
||||
fallback_policy_json=policy.fallback_policy_json,
|
||||
budget_limits_json=policy.budget_limits_json,
|
||||
is_default=policy.is_default, is_active=policy.is_active,
|
||||
created_at=policy.created_at,
|
||||
)
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
return _policy_to_domain(row)
|
||||
|
||||
async def _clear_defaults(self) -> None:
|
||||
rows = (await self.session.execute(select(RoutingPolicyRow))).scalars().all()
|
||||
for row in rows:
|
||||
row.is_default = False
|
||||
await self.session.flush()
|
||||
|
||||
|
||||
class RateCardRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def list(self) -> list[RateCard]:
|
||||
stmt = select(RateCardRow).order_by(RateCardRow.unit)
|
||||
return [_rate_card_to_domain(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||
|
||||
async def get_by_unit(self, unit: RateUnit) -> RateCard | None:
|
||||
stmt = select(RateCardRow).where(RateCardRow.unit == unit.value)
|
||||
row = (await self.session.execute(stmt)).scalar_one_or_none()
|
||||
return _rate_card_to_domain(row) if row else None
|
||||
|
||||
async def upsert(self, card: RateCard) -> RateCard:
|
||||
existing = await self.get_by_unit(card.unit)
|
||||
if existing:
|
||||
row = await self.session.get(RateCardRow, existing.id)
|
||||
assert row is not None
|
||||
row.customer_price = card.customer_price
|
||||
row.internal_cost_estimate = card.internal_cost_estimate
|
||||
row.name = card.name
|
||||
row.is_active = card.is_active
|
||||
await self.session.flush()
|
||||
return _rate_card_to_domain(row)
|
||||
return await self.create(card)
|
||||
|
||||
async def create(self, card: RateCard) -> RateCard:
|
||||
row = RateCardRow(
|
||||
id=card.id, name=card.name, unit=card.unit.value,
|
||||
customer_price=card.customer_price,
|
||||
internal_cost_estimate=card.internal_cost_estimate,
|
||||
is_active=card.is_active, created_at=card.created_at,
|
||||
)
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
return _rate_card_to_domain(row)
|
||||
@@ -0,0 +1,496 @@
|
||||
"""Repositories for Audit & Observability and the Usage Ledger."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import case, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.application.ports.audit import UsageAggregate, UsageBreakdown, UsageRecord
|
||||
from app.domain.ai_control.models import RateUnit
|
||||
from app.domain.audit.models import AuditEvent, ErrorEvent, QualityMetric, Span, Trace
|
||||
from app.infrastructure.db.models import (
|
||||
AuditEventRow,
|
||||
ErrorEventRow,
|
||||
QualityMetricRow,
|
||||
SpanRow,
|
||||
TraceRow,
|
||||
UsageEventRow,
|
||||
)
|
||||
|
||||
|
||||
def _audit_to_domain(row: AuditEventRow) -> AuditEvent:
|
||||
return AuditEvent(
|
||||
id=row.id,
|
||||
organization_id=row.organization_id,
|
||||
actor_user_id=row.actor_user_id,
|
||||
action=row.action,
|
||||
target_type=row.target_type,
|
||||
target_id=row.target_id,
|
||||
metadata_json=row.metadata_json or {},
|
||||
ip_address=row.ip_address,
|
||||
user_agent=row.user_agent,
|
||||
created_at=row.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _error_to_domain(row: ErrorEventRow) -> ErrorEvent:
|
||||
return ErrorEvent(
|
||||
id=row.id,
|
||||
organization_id=row.organization_id,
|
||||
run_id=row.run_id,
|
||||
kind=row.kind,
|
||||
message=row.message,
|
||||
stack=row.stack,
|
||||
metadata_json=row.metadata_json or {},
|
||||
source=row.source,
|
||||
severity=row.severity,
|
||||
error_type=row.error_type,
|
||||
fingerprint=row.fingerprint,
|
||||
status_code=row.status_code,
|
||||
method=row.method,
|
||||
path=row.path,
|
||||
user_id=row.user_id,
|
||||
request_id=row.request_id,
|
||||
ip=row.ip,
|
||||
user_agent=row.user_agent,
|
||||
resolved_at=row.resolved_at,
|
||||
resolved_note=row.resolved_note,
|
||||
created_at=row.created_at,
|
||||
)
|
||||
|
||||
|
||||
class AuditRepository:
|
||||
"""Concrete ``AuditPort``. Append-only."""
|
||||
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def record(self, *, event: AuditEvent) -> AuditEvent:
|
||||
row = AuditEventRow(
|
||||
id=event.id,
|
||||
organization_id=event.organization_id,
|
||||
actor_user_id=event.actor_user_id,
|
||||
action=event.action,
|
||||
target_type=event.target_type,
|
||||
target_id=event.target_id,
|
||||
metadata_json=event.metadata_json,
|
||||
ip_address=event.ip_address,
|
||||
user_agent=event.user_agent,
|
||||
created_at=event.created_at,
|
||||
)
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
return _audit_to_domain(row)
|
||||
|
||||
async def list_for_organization(
|
||||
self, *, organization_id: str, limit: int = 100, offset: int = 0
|
||||
) -> list[AuditEvent]:
|
||||
stmt = (
|
||||
select(AuditEventRow)
|
||||
.where(AuditEventRow.organization_id == organization_id)
|
||||
.order_by(AuditEventRow.created_at.desc())
|
||||
.limit(min(limit, 500))
|
||||
.offset(offset)
|
||||
)
|
||||
return [_audit_to_domain(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||
|
||||
async def list_platform(self, *, limit: int = 100, offset: int = 0) -> list[AuditEvent]:
|
||||
stmt = (
|
||||
select(AuditEventRow)
|
||||
.order_by(AuditEventRow.created_at.desc())
|
||||
.limit(min(limit, 500))
|
||||
.offset(offset)
|
||||
)
|
||||
return [_audit_to_domain(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||
|
||||
|
||||
class UsageLedgerRepository:
|
||||
"""Concrete ``UsageLedgerPort``. The ledger is the billing source of truth."""
|
||||
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def record(self, *, usage: UsageRecord) -> None:
|
||||
total = usage.total_price or (usage.quantity * usage.unit_price)
|
||||
row = UsageEventRow(
|
||||
organization_id=usage.organization_id,
|
||||
project_id=usage.project_id,
|
||||
user_id=usage.user_id,
|
||||
employee_id=usage.employee_id,
|
||||
run_id=usage.run_id,
|
||||
unit=usage.unit.value,
|
||||
quantity=usage.quantity,
|
||||
unit_price=usage.unit_price,
|
||||
internal_cost_estimate=usage.internal_cost_estimate,
|
||||
total_price=total,
|
||||
metadata_json=usage.metadata_json,
|
||||
)
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
|
||||
async def breakdown(
|
||||
self,
|
||||
*,
|
||||
organization_id: str,
|
||||
since: datetime | None = None,
|
||||
until: datetime | None = None,
|
||||
) -> UsageBreakdown:
|
||||
filters = [UsageEventRow.organization_id == organization_id]
|
||||
if since is not None:
|
||||
filters.append(UsageEventRow.created_at >= since)
|
||||
if until is not None:
|
||||
filters.append(UsageEventRow.created_at <= until)
|
||||
|
||||
by_unit_stmt = (
|
||||
select(
|
||||
UsageEventRow.unit,
|
||||
func.coalesce(func.sum(UsageEventRow.quantity), 0.0),
|
||||
func.coalesce(func.sum(UsageEventRow.total_price), 0.0),
|
||||
func.coalesce(func.sum(UsageEventRow.internal_cost_estimate), 0.0),
|
||||
)
|
||||
.where(*filters)
|
||||
.group_by(UsageEventRow.unit)
|
||||
)
|
||||
by_unit = [
|
||||
UsageAggregate(
|
||||
unit=RateUnit(unit),
|
||||
quantity=float(qty),
|
||||
total_price=float(price),
|
||||
internal_cost_estimate=float(cost),
|
||||
)
|
||||
for unit, qty, price, cost in (await self.session.execute(by_unit_stmt)).all()
|
||||
]
|
||||
|
||||
emp_stmt = (
|
||||
select(
|
||||
UsageEventRow.employee_id,
|
||||
func.coalesce(func.sum(UsageEventRow.total_price), 0.0),
|
||||
)
|
||||
.where(*filters, UsageEventRow.employee_id.is_not(None))
|
||||
.group_by(UsageEventRow.employee_id)
|
||||
)
|
||||
by_employee = {
|
||||
str(emp): float(total)
|
||||
for emp, total in (await self.session.execute(emp_stmt)).all()
|
||||
}
|
||||
|
||||
proj_stmt = (
|
||||
select(
|
||||
UsageEventRow.project_id,
|
||||
func.coalesce(func.sum(UsageEventRow.total_price), 0.0),
|
||||
)
|
||||
.where(*filters, UsageEventRow.project_id.is_not(None))
|
||||
.group_by(UsageEventRow.project_id)
|
||||
)
|
||||
by_project = {
|
||||
str(proj): float(total)
|
||||
for proj, total in (await self.session.execute(proj_stmt)).all()
|
||||
}
|
||||
|
||||
return UsageBreakdown(
|
||||
by_unit=by_unit,
|
||||
by_employee=by_employee,
|
||||
by_project=by_project,
|
||||
total_price=sum(a.total_price for a in by_unit),
|
||||
total_cost=sum(a.internal_cost_estimate for a in by_unit),
|
||||
)
|
||||
|
||||
async def list_events(
|
||||
self, *, organization_id: str, limit: int = 100, offset: int = 0
|
||||
) -> list[UsageEventRow]:
|
||||
stmt = (
|
||||
select(UsageEventRow)
|
||||
.where(UsageEventRow.organization_id == organization_id)
|
||||
.order_by(UsageEventRow.created_at.desc())
|
||||
.limit(min(limit, 500))
|
||||
.offset(offset)
|
||||
)
|
||||
return list((await self.session.execute(stmt)).scalars())
|
||||
|
||||
async def platform_totals(self) -> list[tuple[str, float, float]]:
|
||||
"""``(organization_id, quantity, total_price)`` across every tenant.
|
||||
|
||||
Platform-owner console only.
|
||||
"""
|
||||
stmt = (
|
||||
select(
|
||||
UsageEventRow.organization_id,
|
||||
func.coalesce(func.sum(UsageEventRow.quantity), 0.0),
|
||||
func.coalesce(func.sum(UsageEventRow.total_price), 0.0),
|
||||
)
|
||||
.group_by(UsageEventRow.organization_id)
|
||||
.order_by(func.sum(UsageEventRow.total_price).desc())
|
||||
)
|
||||
rows = (await self.session.execute(stmt)).all()
|
||||
return [(str(org), float(qty), float(price)) for org, qty, price in rows]
|
||||
|
||||
|
||||
class ObservabilityRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def create_trace(self, trace: Trace) -> Trace:
|
||||
self.session.add(
|
||||
TraceRow(
|
||||
id=trace.id, organization_id=trace.organization_id,
|
||||
run_id=trace.run_id, name=trace.name, created_at=trace.created_at,
|
||||
)
|
||||
)
|
||||
await self.session.flush()
|
||||
return trace
|
||||
|
||||
async def add_span(self, span: Span) -> Span:
|
||||
self.session.add(
|
||||
SpanRow(
|
||||
id=span.id, trace_id=span.trace_id, parent_span_id=span.parent_span_id,
|
||||
name=span.name, kind=span.kind, attributes_json=span.attributes_json,
|
||||
started_at=span.started_at, ended_at=span.ended_at,
|
||||
)
|
||||
)
|
||||
await self.session.flush()
|
||||
return span
|
||||
|
||||
async def record_error(self, event: ErrorEvent) -> None:
|
||||
self.session.add(
|
||||
ErrorEventRow(
|
||||
id=event.id,
|
||||
organization_id=event.organization_id,
|
||||
run_id=event.run_id,
|
||||
kind=event.kind,
|
||||
message=event.message,
|
||||
stack=event.stack,
|
||||
metadata_json=event.metadata_json,
|
||||
source=event.source,
|
||||
severity=event.severity,
|
||||
error_type=event.error_type,
|
||||
fingerprint=event.fingerprint,
|
||||
status_code=event.status_code,
|
||||
method=event.method,
|
||||
path=event.path,
|
||||
user_id=event.user_id,
|
||||
request_id=event.request_id,
|
||||
ip=event.ip,
|
||||
user_agent=event.user_agent,
|
||||
resolved_at=event.resolved_at,
|
||||
resolved_note=event.resolved_note,
|
||||
created_at=event.created_at,
|
||||
)
|
||||
)
|
||||
await self.session.flush()
|
||||
|
||||
async def record_metric(self, metric: QualityMetric) -> None:
|
||||
self.session.add(
|
||||
QualityMetricRow(
|
||||
id=metric.id, organization_id=metric.organization_id, name=metric.name,
|
||||
value=metric.value, dimensions_json=metric.dimensions_json,
|
||||
created_at=metric.created_at,
|
||||
)
|
||||
)
|
||||
await self.session.flush()
|
||||
|
||||
async def list_spans(self, *, trace_id: str) -> list[Span]:
|
||||
stmt = select(SpanRow).where(SpanRow.trace_id == trace_id).order_by(SpanRow.started_at)
|
||||
return [
|
||||
Span(
|
||||
id=r.id, trace_id=r.trace_id, parent_span_id=r.parent_span_id, name=r.name,
|
||||
kind=r.kind, attributes_json=r.attributes_json or {},
|
||||
started_at=r.started_at, ended_at=r.ended_at,
|
||||
)
|
||||
for r in (await self.session.execute(stmt)).scalars()
|
||||
]
|
||||
|
||||
# -- error console --------------------------------------------------------
|
||||
|
||||
def _error_filters(
|
||||
self,
|
||||
*,
|
||||
severity: str | None = None,
|
||||
source: str | None = None,
|
||||
status: str | None = None,
|
||||
kind: str | None = None,
|
||||
organization_id: str | None = None,
|
||||
fingerprint: str | None = None,
|
||||
since: datetime | None = None,
|
||||
search: str | None = None,
|
||||
) -> list:
|
||||
"""Build the WHERE clauses shared by the list and count queries."""
|
||||
clauses: list = []
|
||||
if severity:
|
||||
clauses.append(ErrorEventRow.severity == severity)
|
||||
if source:
|
||||
clauses.append(ErrorEventRow.source == source)
|
||||
if status == "open":
|
||||
clauses.append(ErrorEventRow.resolved_at.is_(None))
|
||||
elif status == "resolved":
|
||||
clauses.append(ErrorEventRow.resolved_at.is_not(None))
|
||||
if kind:
|
||||
clauses.append(ErrorEventRow.kind == kind)
|
||||
if organization_id:
|
||||
clauses.append(ErrorEventRow.organization_id == organization_id)
|
||||
if fingerprint:
|
||||
clauses.append(ErrorEventRow.fingerprint == fingerprint)
|
||||
if since is not None:
|
||||
clauses.append(ErrorEventRow.created_at >= since)
|
||||
if search:
|
||||
clauses.append(ErrorEventRow.message.ilike(f"%{search}%"))
|
||||
return clauses
|
||||
|
||||
async def list_errors(
|
||||
self,
|
||||
*,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
**filters,
|
||||
) -> list[ErrorEvent]:
|
||||
stmt = (
|
||||
select(ErrorEventRow)
|
||||
.where(*self._error_filters(**filters))
|
||||
# Newest first: the failure someone is reacting to is almost always
|
||||
# the most recent one.
|
||||
.order_by(ErrorEventRow.created_at.desc())
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
)
|
||||
return [_error_to_domain(row) for row in (await self.session.execute(stmt)).scalars()]
|
||||
|
||||
async def count_errors(self, **filters) -> int:
|
||||
stmt = select(func.count()).select_from(ErrorEventRow).where(
|
||||
*self._error_filters(**filters)
|
||||
)
|
||||
return int((await self.session.execute(stmt)).scalar_one())
|
||||
|
||||
async def get_error(self, error_id: str) -> ErrorEvent | None:
|
||||
row = (
|
||||
await self.session.execute(
|
||||
select(ErrorEventRow).where(ErrorEventRow.id == error_id)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
return _error_to_domain(row) if row is not None else None
|
||||
|
||||
async def list_error_groups(
|
||||
self,
|
||||
*,
|
||||
limit: int = 50,
|
||||
since: datetime | None = None,
|
||||
status: str | None = None,
|
||||
severity: str | None = None,
|
||||
source: str | None = None,
|
||||
organization_id: str | None = None,
|
||||
) -> list[dict]:
|
||||
"""One row per distinct bug, newest activity first.
|
||||
|
||||
This is the view that makes the console usable: a bug that fired 4 000
|
||||
times is one line with a count, not 4 000 lines. ``open_count`` is what
|
||||
tells you whether anyone has actually dealt with it.
|
||||
"""
|
||||
clauses: list = [ErrorEventRow.fingerprint != ""]
|
||||
if since is not None:
|
||||
clauses.append(ErrorEventRow.created_at >= since)
|
||||
if severity:
|
||||
clauses.append(ErrorEventRow.severity == severity)
|
||||
if source:
|
||||
clauses.append(ErrorEventRow.source == source)
|
||||
if organization_id:
|
||||
clauses.append(ErrorEventRow.organization_id == organization_id)
|
||||
|
||||
open_count = func.sum(
|
||||
case((ErrorEventRow.resolved_at.is_(None), 1), else_=0)
|
||||
).label("open_count")
|
||||
stmt = (
|
||||
select(
|
||||
ErrorEventRow.fingerprint,
|
||||
func.count().label("occurrences"),
|
||||
open_count,
|
||||
func.min(ErrorEventRow.created_at).label("first_seen"),
|
||||
func.max(ErrorEventRow.created_at).label("last_seen"),
|
||||
func.max(ErrorEventRow.kind).label("kind"),
|
||||
func.max(ErrorEventRow.error_type).label("error_type"),
|
||||
func.max(ErrorEventRow.severity).label("severity"),
|
||||
func.max(ErrorEventRow.source).label("source"),
|
||||
func.max(ErrorEventRow.path).label("path"),
|
||||
func.max(ErrorEventRow.message).label("sample_message"),
|
||||
)
|
||||
.where(*clauses)
|
||||
.group_by(ErrorEventRow.fingerprint)
|
||||
.order_by(func.max(ErrorEventRow.created_at).desc())
|
||||
.limit(limit)
|
||||
)
|
||||
rows = (await self.session.execute(stmt)).all()
|
||||
groups = [
|
||||
{
|
||||
"fingerprint": row.fingerprint,
|
||||
"occurrences": int(row.occurrences),
|
||||
"open_count": int(row.open_count or 0),
|
||||
"first_seen": row.first_seen,
|
||||
"last_seen": row.last_seen,
|
||||
"kind": row.kind or "",
|
||||
"error_type": row.error_type or "",
|
||||
"severity": row.severity or "",
|
||||
"source": row.source or "",
|
||||
"path": row.path or "",
|
||||
"sample_message": row.sample_message or "",
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
if status == "open":
|
||||
return [g for g in groups if g["open_count"] > 0]
|
||||
if status == "resolved":
|
||||
return [g for g in groups if g["open_count"] == 0]
|
||||
return groups
|
||||
|
||||
async def resolve_error(
|
||||
self, error_id: str, *, note: str, resolved_at: datetime
|
||||
) -> ErrorEvent | None:
|
||||
row = (
|
||||
await self.session.execute(
|
||||
select(ErrorEventRow).where(ErrorEventRow.id == error_id)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if row is None:
|
||||
return None
|
||||
row.resolved_at = resolved_at
|
||||
row.resolved_note = note
|
||||
await self.session.flush()
|
||||
return _error_to_domain(row)
|
||||
|
||||
async def resolve_error_group(
|
||||
self, fingerprint: str, *, note: str, resolved_at: datetime
|
||||
) -> int:
|
||||
"""Resolve every open occurrence of one bug. Returns how many changed."""
|
||||
stmt = select(ErrorEventRow).where(
|
||||
ErrorEventRow.fingerprint == fingerprint,
|
||||
ErrorEventRow.resolved_at.is_(None),
|
||||
)
|
||||
rows = list((await self.session.execute(stmt)).scalars())
|
||||
for row in rows:
|
||||
row.resolved_at = resolved_at
|
||||
row.resolved_note = note
|
||||
await self.session.flush()
|
||||
return len(rows)
|
||||
|
||||
async def reopen_error_group(self, fingerprint: str) -> int:
|
||||
"""Undo a resolution — for when the bug comes back."""
|
||||
stmt = select(ErrorEventRow).where(
|
||||
ErrorEventRow.fingerprint == fingerprint,
|
||||
ErrorEventRow.resolved_at.is_not(None),
|
||||
)
|
||||
rows = list((await self.session.execute(stmt)).scalars())
|
||||
for row in rows:
|
||||
row.resolved_at = None
|
||||
row.resolved_note = ""
|
||||
await self.session.flush()
|
||||
return len(rows)
|
||||
|
||||
async def count_open_by_severity(self) -> dict[str, int]:
|
||||
"""Unresolved counts per severity, for the console's header tiles."""
|
||||
stmt = (
|
||||
select(ErrorEventRow.severity, func.count())
|
||||
.where(ErrorEventRow.resolved_at.is_(None))
|
||||
.group_by(ErrorEventRow.severity)
|
||||
)
|
||||
return {
|
||||
str(severity): int(count)
|
||||
for severity, count in (await self.session.execute(stmt)).all()
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
"""Repositories for Billing: plans, subscriptions, wallets, invoices, payments.
|
||||
|
||||
The usage ledger lives in ``repositories/audit.py`` because it is shared with the
|
||||
observability layer; billing reads it but never writes to it directly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.domain.billing.models import (
|
||||
CreditWallet,
|
||||
Invoice,
|
||||
InvoiceStatus,
|
||||
Payment,
|
||||
PaymentStatus,
|
||||
Plan,
|
||||
Subscription,
|
||||
SubscriptionStatus,
|
||||
)
|
||||
from app.infrastructure.db.models import (
|
||||
CreditWalletRow,
|
||||
InvoiceRow,
|
||||
PaymentRow,
|
||||
PlanRow,
|
||||
SubscriptionRow,
|
||||
)
|
||||
|
||||
|
||||
def _plan(row: PlanRow) -> Plan:
|
||||
return Plan(
|
||||
id=row.id, name=row.name, monthly_price=row.monthly_price,
|
||||
included_credit=row.included_credit, features_json=dict(row.features_json or {}),
|
||||
is_active=row.is_active, created_at=row.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _subscription(row: SubscriptionRow) -> Subscription:
|
||||
return Subscription(
|
||||
id=row.id, organization_id=row.organization_id, plan_id=row.plan_id,
|
||||
status=SubscriptionStatus(row.status),
|
||||
external_subscription_id=row.external_subscription_id,
|
||||
current_period_end=row.current_period_end, created_at=row.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _wallet(row: CreditWalletRow) -> CreditWallet:
|
||||
return CreditWallet(
|
||||
id=row.id, organization_id=row.organization_id, balance=row.balance,
|
||||
currency=row.currency, low_balance_threshold=row.low_balance_threshold,
|
||||
updated_at=row.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _invoice(row: InvoiceRow) -> Invoice:
|
||||
return Invoice(
|
||||
id=row.id, organization_id=row.organization_id, amount=row.amount,
|
||||
currency=row.currency, status=InvoiceStatus(row.status),
|
||||
external_invoice_id=row.external_invoice_id, period_start=row.period_start,
|
||||
period_end=row.period_end, created_at=row.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _payment(row: PaymentRow) -> Payment:
|
||||
return Payment(
|
||||
id=row.id, organization_id=row.organization_id, amount=row.amount,
|
||||
status=PaymentStatus(row.status), provider=row.provider,
|
||||
external_payment_id=row.external_payment_id, invoice_id=row.invoice_id,
|
||||
created_at=row.created_at,
|
||||
)
|
||||
|
||||
|
||||
class PlanRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def list(self) -> list[Plan]:
|
||||
stmt = select(PlanRow).order_by(PlanRow.monthly_price)
|
||||
return [_plan(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||
|
||||
async def get(self, plan_id: str) -> Plan | None:
|
||||
row = await self.session.get(PlanRow, plan_id)
|
||||
return _plan(row) if row else None
|
||||
|
||||
|
||||
class SubscriptionRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def get_for_org(self, organization_id: str) -> Subscription | None:
|
||||
stmt = select(SubscriptionRow).where(
|
||||
SubscriptionRow.organization_id == organization_id
|
||||
)
|
||||
row = (await self.session.execute(stmt)).scalars().first()
|
||||
return _subscription(row) if row else None
|
||||
|
||||
async def upsert(self, subscription: Subscription) -> Subscription:
|
||||
existing = await self.get_for_org(subscription.organization_id)
|
||||
if existing:
|
||||
row = await self.session.get(SubscriptionRow, existing.id)
|
||||
assert row is not None
|
||||
row.plan_id = subscription.plan_id
|
||||
row.status = str(subscription.status)
|
||||
row.external_subscription_id = subscription.external_subscription_id
|
||||
row.current_period_end = subscription.current_period_end
|
||||
await self.session.flush()
|
||||
return _subscription(row)
|
||||
|
||||
row = SubscriptionRow(
|
||||
id=subscription.id, organization_id=subscription.organization_id,
|
||||
plan_id=subscription.plan_id, status=str(subscription.status),
|
||||
external_subscription_id=subscription.external_subscription_id,
|
||||
current_period_end=subscription.current_period_end,
|
||||
created_at=subscription.created_at,
|
||||
)
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
return _subscription(row)
|
||||
|
||||
|
||||
class CreditWalletRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def get_for_org(self, organization_id: str) -> CreditWallet | None:
|
||||
stmt = select(CreditWalletRow).where(
|
||||
CreditWalletRow.organization_id == organization_id
|
||||
)
|
||||
row = (await self.session.execute(stmt)).scalar_one_or_none()
|
||||
return _wallet(row) if row else None
|
||||
|
||||
async def ensure(self, organization_id: str) -> CreditWallet:
|
||||
"""Return the org's wallet, creating a zero-balance one if absent."""
|
||||
existing = await self.get_for_org(organization_id)
|
||||
if existing:
|
||||
return existing
|
||||
row = CreditWalletRow(organization_id=organization_id, balance=0.0, currency="USD")
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
return _wallet(row)
|
||||
|
||||
async def adjust(self, *, organization_id: str, delta: float) -> CreditWallet:
|
||||
"""Add (or subtract) credit. Callers must validate the delta's sign."""
|
||||
wallet = await self.ensure(organization_id)
|
||||
row = await self.session.get(CreditWalletRow, wallet.id)
|
||||
assert row is not None
|
||||
row.balance = float(row.balance) + float(delta)
|
||||
await self.session.flush()
|
||||
return _wallet(row)
|
||||
|
||||
|
||||
class InvoiceRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def list_for_org(self, organization_id: str) -> list[Invoice]:
|
||||
stmt = (
|
||||
select(InvoiceRow)
|
||||
.where(InvoiceRow.organization_id == organization_id)
|
||||
.order_by(InvoiceRow.created_at.desc())
|
||||
)
|
||||
return [_invoice(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||
|
||||
async def create(self, invoice: Invoice) -> Invoice:
|
||||
row = InvoiceRow(
|
||||
id=invoice.id, organization_id=invoice.organization_id,
|
||||
amount=invoice.amount, currency=invoice.currency,
|
||||
status=str(invoice.status),
|
||||
external_invoice_id=invoice.external_invoice_id,
|
||||
period_start=invoice.period_start, period_end=invoice.period_end,
|
||||
created_at=invoice.created_at,
|
||||
)
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
return _invoice(row)
|
||||
|
||||
|
||||
class PaymentRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def list_for_org(self, organization_id: str) -> list[Payment]:
|
||||
stmt = (
|
||||
select(PaymentRow)
|
||||
.where(PaymentRow.organization_id == organization_id)
|
||||
.order_by(PaymentRow.created_at.desc())
|
||||
)
|
||||
return [_payment(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||
|
||||
async def create(self, payment: Payment) -> Payment:
|
||||
row = PaymentRow(
|
||||
id=payment.id, organization_id=payment.organization_id,
|
||||
amount=payment.amount, status=str(payment.status),
|
||||
provider=payment.provider, external_payment_id=payment.external_payment_id,
|
||||
invoice_id=payment.invoice_id, created_at=payment.created_at,
|
||||
)
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
return _payment(row)
|
||||
|
||||
async def set_status(
|
||||
self, payment_id: str, status: PaymentStatus, *, external_id: str | None = None
|
||||
) -> Payment | None:
|
||||
row = await self.session.get(PaymentRow, payment_id)
|
||||
if row is None:
|
||||
return None
|
||||
row.status = str(status)
|
||||
if external_id is not None:
|
||||
row.external_payment_id = external_id
|
||||
await self.session.flush()
|
||||
return _payment(row)
|
||||
@@ -0,0 +1,269 @@
|
||||
"""Repositories for Identity & Access.
|
||||
|
||||
Each repository maps between the ORM rows and the pure domain dataclasses, so
|
||||
the domain layer never sees SQLAlchemy.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.domain.identity.models import (
|
||||
Invite,
|
||||
Membership,
|
||||
MembershipStatus,
|
||||
Organization,
|
||||
Role,
|
||||
User,
|
||||
)
|
||||
from app.infrastructure.db.models import (
|
||||
InviteRow,
|
||||
MembershipRow,
|
||||
OrganizationRow,
|
||||
UserRow,
|
||||
)
|
||||
|
||||
|
||||
def _user_to_domain(row: UserRow) -> User:
|
||||
return User(
|
||||
id=row.id,
|
||||
email=row.email,
|
||||
password_hash=row.password_hash,
|
||||
name=row.name,
|
||||
avatar_url=row.avatar_url,
|
||||
is_platform_owner=row.is_platform_owner,
|
||||
is_active=row.is_active,
|
||||
created_at=row.created_at,
|
||||
updated_at=row.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _org_to_domain(row: OrganizationRow) -> Organization:
|
||||
return Organization(
|
||||
id=row.id, name=row.name, slug=row.slug,
|
||||
owner_user_id=row.owner_user_id, created_at=row.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _membership_to_domain(row: MembershipRow) -> Membership:
|
||||
return Membership(
|
||||
id=row.id,
|
||||
organization_id=row.organization_id,
|
||||
user_id=row.user_id,
|
||||
role=Role(row.role),
|
||||
status=MembershipStatus(row.status),
|
||||
created_at=row.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _invite_to_domain(row: InviteRow) -> Invite:
|
||||
return Invite(
|
||||
id=row.id,
|
||||
organization_id=row.organization_id,
|
||||
email=row.email,
|
||||
role=Role(row.role),
|
||||
token_hash=row.token_hash,
|
||||
invited_by_user_id=row.invited_by_user_id,
|
||||
expires_at=row.expires_at,
|
||||
accepted_at=row.accepted_at,
|
||||
created_at=row.created_at,
|
||||
)
|
||||
|
||||
|
||||
class UserRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def get(self, user_id: str) -> User | None:
|
||||
row = await self.session.get(UserRow, user_id)
|
||||
return _user_to_domain(row) if row else None
|
||||
|
||||
async def get_by_email(self, email: str) -> User | None:
|
||||
stmt = select(UserRow).where(func.lower(UserRow.email) == email.strip().lower())
|
||||
row = (await self.session.execute(stmt)).scalar_one_or_none()
|
||||
return _user_to_domain(row) if row else None
|
||||
|
||||
async def create(self, user: User) -> User:
|
||||
row = UserRow(
|
||||
id=user.id,
|
||||
email=user.normalized_email(),
|
||||
password_hash=user.password_hash,
|
||||
name=user.name,
|
||||
avatar_url=user.avatar_url,
|
||||
is_platform_owner=user.is_platform_owner,
|
||||
is_active=user.is_active,
|
||||
created_at=user.created_at,
|
||||
updated_at=user.updated_at,
|
||||
)
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
return _user_to_domain(row)
|
||||
|
||||
async def update_password(self, user_id: str, password_hash: str) -> None:
|
||||
row = await self.session.get(UserRow, user_id)
|
||||
if row:
|
||||
row.password_hash = password_hash
|
||||
await self.session.flush()
|
||||
|
||||
async def count(self) -> int:
|
||||
return int((await self.session.execute(select(func.count(UserRow.id)))).scalar_one())
|
||||
|
||||
|
||||
class OrganizationRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def get(self, org_id: str) -> Organization | None:
|
||||
row = await self.session.get(OrganizationRow, org_id)
|
||||
return _org_to_domain(row) if row else None
|
||||
|
||||
async def get_by_slug(self, slug: str) -> Organization | None:
|
||||
stmt = select(OrganizationRow).where(OrganizationRow.slug == slug)
|
||||
row = (await self.session.execute(stmt)).scalar_one_or_none()
|
||||
return _org_to_domain(row) if row else None
|
||||
|
||||
async def list_all_slugs(self) -> set[str]:
|
||||
rows = (await self.session.execute(select(OrganizationRow.slug))).scalars().all()
|
||||
return set(rows)
|
||||
|
||||
async def create(self, org: Organization) -> Organization:
|
||||
row = OrganizationRow(
|
||||
id=org.id, name=org.name, slug=org.slug,
|
||||
owner_user_id=org.owner_user_id, created_at=org.created_at,
|
||||
)
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
return _org_to_domain(row)
|
||||
|
||||
async def list_for_user(self, user_id: str) -> list[tuple[Organization, Role]]:
|
||||
stmt = (
|
||||
select(OrganizationRow, MembershipRow.role)
|
||||
.join(MembershipRow, MembershipRow.organization_id == OrganizationRow.id)
|
||||
.where(MembershipRow.user_id == user_id, MembershipRow.status == "active")
|
||||
.order_by(OrganizationRow.created_at)
|
||||
)
|
||||
rows = (await self.session.execute(stmt)).all()
|
||||
return [(_org_to_domain(o), Role(r)) for o, r in rows]
|
||||
|
||||
async def list_all(self) -> list[Organization]:
|
||||
"""Every organization. Platform-owner console only — never expose this
|
||||
through a tenant-scoped endpoint."""
|
||||
stmt = select(OrganizationRow).order_by(OrganizationRow.created_at)
|
||||
return [_org_to_domain(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||
|
||||
async def delete(self, org_id: str) -> None:
|
||||
row = await self.session.get(OrganizationRow, org_id)
|
||||
if row:
|
||||
await self.session.delete(row)
|
||||
await self.session.flush()
|
||||
|
||||
async def count(self) -> int:
|
||||
return int(
|
||||
(await self.session.execute(select(func.count(OrganizationRow.id)))).scalar_one()
|
||||
)
|
||||
|
||||
|
||||
class MembershipRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def get(self, org_id: str, user_id: str) -> Membership | None:
|
||||
stmt = select(MembershipRow).where(
|
||||
MembershipRow.organization_id == org_id, MembershipRow.user_id == user_id
|
||||
)
|
||||
row = (await self.session.execute(stmt)).scalar_one_or_none()
|
||||
return _membership_to_domain(row) if row else None
|
||||
|
||||
async def get_by_id(self, membership_id: str) -> Membership | None:
|
||||
row = await self.session.get(MembershipRow, membership_id)
|
||||
return _membership_to_domain(row) if row else None
|
||||
|
||||
async def list_for_org(self, org_id: str) -> list[Membership]:
|
||||
stmt = (
|
||||
select(MembershipRow)
|
||||
.where(MembershipRow.organization_id == org_id)
|
||||
.order_by(MembershipRow.created_at)
|
||||
)
|
||||
return [_membership_to_domain(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||
|
||||
async def add(self, membership: Membership) -> Membership:
|
||||
row = MembershipRow(
|
||||
id=membership.id,
|
||||
organization_id=membership.organization_id,
|
||||
user_id=membership.user_id,
|
||||
role=membership.role.value,
|
||||
status=membership.status.value,
|
||||
created_at=membership.created_at,
|
||||
)
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
return _membership_to_domain(row)
|
||||
|
||||
async def set_role(self, membership_id: str, role: Role) -> None:
|
||||
row = await self.session.get(MembershipRow, membership_id)
|
||||
if row:
|
||||
row.role = role.value
|
||||
await self.session.flush()
|
||||
|
||||
async def set_status(self, membership_id: str, status: MembershipStatus) -> None:
|
||||
row = await self.session.get(MembershipRow, membership_id)
|
||||
if row:
|
||||
row.status = status.value
|
||||
await self.session.flush()
|
||||
|
||||
async def remove(self, membership_id: str) -> None:
|
||||
row = await self.session.get(MembershipRow, membership_id)
|
||||
if row:
|
||||
await self.session.delete(row)
|
||||
await self.session.flush()
|
||||
|
||||
async def count_owners(self, org_id: str) -> int:
|
||||
stmt = select(func.count(MembershipRow.id)).where(
|
||||
MembershipRow.organization_id == org_id,
|
||||
MembershipRow.role == Role.OWNER.value,
|
||||
MembershipRow.status == MembershipStatus.ACTIVE.value,
|
||||
)
|
||||
return int((await self.session.execute(stmt)).scalar_one())
|
||||
|
||||
|
||||
class InviteRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def get_by_token_hash(self, token_hash: str) -> Invite | None:
|
||||
stmt = select(InviteRow).where(InviteRow.token_hash == token_hash)
|
||||
row = (await self.session.execute(stmt)).scalar_one_or_none()
|
||||
return _invite_to_domain(row) if row else None
|
||||
|
||||
async def list_pending(self, org_id: str) -> list[Invite]:
|
||||
stmt = (
|
||||
select(InviteRow)
|
||||
.where(InviteRow.organization_id == org_id, InviteRow.accepted_at.is_(None))
|
||||
.order_by(InviteRow.created_at.desc())
|
||||
)
|
||||
return [_invite_to_domain(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||
|
||||
async def create(self, invite: Invite) -> Invite:
|
||||
row = InviteRow(
|
||||
id=invite.id,
|
||||
organization_id=invite.organization_id,
|
||||
email=invite.email.strip().lower(),
|
||||
role=invite.role.value,
|
||||
token_hash=invite.token_hash,
|
||||
invited_by_user_id=invite.invited_by_user_id,
|
||||
expires_at=invite.expires_at,
|
||||
accepted_at=invite.accepted_at,
|
||||
created_at=invite.created_at,
|
||||
)
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
return _invite_to_domain(row)
|
||||
|
||||
async def mark_accepted(self, invite_id: str, when: datetime) -> None:
|
||||
row = await self.session.get(InviteRow, invite_id)
|
||||
if row:
|
||||
row.accepted_at = when
|
||||
await self.session.flush()
|
||||
@@ -0,0 +1,285 @@
|
||||
"""Repositories for the Knowledge Base.
|
||||
|
||||
Retrieval is tenant-scoped by construction: every search joins
|
||||
``document_chunks -> documents -> knowledge_sources`` and filters on
|
||||
``knowledge_sources.organization_id``. There is no search entry point that takes
|
||||
a chunk id directly, so a caller cannot reach across tenants.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.domain.knowledge.models import (
|
||||
Document,
|
||||
DocumentChunk,
|
||||
EmbeddingJob,
|
||||
EmbeddingJobStatus,
|
||||
KnowledgeSource,
|
||||
RetrievalPolicy,
|
||||
SourceKind,
|
||||
)
|
||||
from app.infrastructure.db.models import (
|
||||
DocumentChunkRow,
|
||||
DocumentRow,
|
||||
EmbeddingJobRow,
|
||||
KnowledgeSourceRow,
|
||||
RetrievalPolicyRow,
|
||||
)
|
||||
|
||||
|
||||
def _source(row: KnowledgeSourceRow) -> KnowledgeSource:
|
||||
return KnowledgeSource(
|
||||
id=row.id, organization_id=row.organization_id, project_id=row.project_id,
|
||||
name=row.name, kind=SourceKind(row.kind), uri=row.uri, created_at=row.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _document(row: DocumentRow) -> Document:
|
||||
return Document(
|
||||
id=row.id, source_id=row.source_id, title=row.title, content=row.content,
|
||||
created_at=row.created_at,
|
||||
)
|
||||
|
||||
|
||||
class KnowledgeSourceRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def get(self, source_id: str) -> KnowledgeSource | None:
|
||||
row = await self.session.get(KnowledgeSourceRow, source_id)
|
||||
return _source(row) if row else None
|
||||
|
||||
async def get_for_org(self, organization_id: str, source_id: str) -> KnowledgeSource | None:
|
||||
stmt = select(KnowledgeSourceRow).where(
|
||||
KnowledgeSourceRow.id == source_id,
|
||||
KnowledgeSourceRow.organization_id == organization_id,
|
||||
)
|
||||
row = (await self.session.execute(stmt)).scalar_one_or_none()
|
||||
return _source(row) if row else None
|
||||
|
||||
async def list_for_org(
|
||||
self, organization_id: str, *, limit: int = 100, offset: int = 0
|
||||
) -> list[KnowledgeSource]:
|
||||
stmt = (
|
||||
select(KnowledgeSourceRow)
|
||||
.where(KnowledgeSourceRow.organization_id == organization_id)
|
||||
.order_by(KnowledgeSourceRow.created_at.desc())
|
||||
.limit(min(limit, 500))
|
||||
.offset(offset)
|
||||
)
|
||||
return [_source(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||
|
||||
async def create(self, source: KnowledgeSource) -> KnowledgeSource:
|
||||
row = KnowledgeSourceRow(
|
||||
id=source.id, organization_id=source.organization_id,
|
||||
project_id=source.project_id, name=source.name, kind=str(source.kind),
|
||||
uri=source.uri, created_at=source.created_at,
|
||||
)
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
return _source(row)
|
||||
|
||||
async def delete(self, source_id: str) -> None:
|
||||
row = await self.session.get(KnowledgeSourceRow, source_id)
|
||||
if row:
|
||||
await self.session.delete(row)
|
||||
await self.session.flush()
|
||||
|
||||
|
||||
class DocumentRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def list_for_source(self, source_id: str) -> list[Document]:
|
||||
stmt = (
|
||||
select(DocumentRow)
|
||||
.where(DocumentRow.source_id == source_id)
|
||||
.order_by(DocumentRow.created_at)
|
||||
)
|
||||
return [_document(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||
|
||||
async def create(self, document: Document) -> Document:
|
||||
row = DocumentRow(
|
||||
id=document.id, source_id=document.source_id, title=document.title,
|
||||
content=document.content, created_at=document.created_at,
|
||||
)
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
return _document(row)
|
||||
|
||||
|
||||
class DocumentChunkRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def create_many(self, chunks: list[DocumentChunk]) -> int:
|
||||
for chunk in chunks:
|
||||
self.session.add(
|
||||
DocumentChunkRow(
|
||||
id=chunk.id, document_id=chunk.document_id, index=chunk.index,
|
||||
content=chunk.content, embedding_json=list(chunk.embedding_json),
|
||||
)
|
||||
)
|
||||
await self.session.flush()
|
||||
return len(chunks)
|
||||
|
||||
async def count_for_source(self, source_id: str) -> int:
|
||||
stmt = (
|
||||
select(func.count())
|
||||
.select_from(DocumentChunkRow)
|
||||
.join(DocumentRow, DocumentRow.id == DocumentChunkRow.document_id)
|
||||
.where(DocumentRow.source_id == source_id)
|
||||
)
|
||||
return int((await self.session.execute(stmt)).scalar_one())
|
||||
|
||||
async def count_for_sources(self, source_ids: list[str]) -> dict[str, int]:
|
||||
"""Chunk counts for many sources in one grouped query.
|
||||
|
||||
Listing sources needs a count per row; doing that one source at a time
|
||||
is an N+1. ``GROUP BY`` collapses it into a single round trip, and
|
||||
sources with no chunks are simply absent from the result.
|
||||
"""
|
||||
if not source_ids:
|
||||
return {}
|
||||
stmt = (
|
||||
select(DocumentRow.source_id, func.count(DocumentChunkRow.id))
|
||||
.select_from(DocumentChunkRow)
|
||||
.join(DocumentRow, DocumentRow.id == DocumentChunkRow.document_id)
|
||||
.where(DocumentRow.source_id.in_(source_ids))
|
||||
.group_by(DocumentRow.source_id)
|
||||
)
|
||||
rows = (await self.session.execute(stmt)).all()
|
||||
return {str(source_id): int(count) for source_id, count in rows}
|
||||
|
||||
async def keyword_search(
|
||||
self,
|
||||
*,
|
||||
organization_id: str,
|
||||
query: str,
|
||||
project_id: str | None = None,
|
||||
limit: int = 5,
|
||||
) -> list[dict]:
|
||||
"""MVP retrieval: case-insensitive substring match over chunk text.
|
||||
|
||||
A real deployment swaps this for a vector search through
|
||||
``VectorStorePort``; the tenant scoping stays identical.
|
||||
"""
|
||||
needle = f"%{query.strip().lower()}%"
|
||||
if not query.strip():
|
||||
return []
|
||||
|
||||
stmt = (
|
||||
select(
|
||||
DocumentChunkRow.id,
|
||||
DocumentChunkRow.content,
|
||||
DocumentChunkRow.index,
|
||||
DocumentRow.title,
|
||||
KnowledgeSourceRow.id,
|
||||
KnowledgeSourceRow.name,
|
||||
KnowledgeSourceRow.project_id,
|
||||
)
|
||||
.join(DocumentRow, DocumentRow.id == DocumentChunkRow.document_id)
|
||||
.join(KnowledgeSourceRow, KnowledgeSourceRow.id == DocumentRow.source_id)
|
||||
.where(
|
||||
KnowledgeSourceRow.organization_id == organization_id,
|
||||
func.lower(DocumentChunkRow.content).like(needle),
|
||||
)
|
||||
.order_by(DocumentChunkRow.document_id, DocumentChunkRow.index)
|
||||
.limit(min(limit, 50))
|
||||
)
|
||||
if project_id is not None:
|
||||
# An org-wide source (project_id is NULL) is visible to every project.
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
KnowledgeSourceRow.project_id.is_(None),
|
||||
KnowledgeSourceRow.project_id == project_id,
|
||||
)
|
||||
)
|
||||
|
||||
rows = (await self.session.execute(stmt)).all()
|
||||
return [
|
||||
{
|
||||
"chunk_id": chunk_id,
|
||||
"content": content,
|
||||
"index": index,
|
||||
"document_title": title,
|
||||
"source_id": source_id,
|
||||
"source_name": source_name,
|
||||
"project_id": source_project_id,
|
||||
}
|
||||
for chunk_id, content, index, title, source_id, source_name, source_project_id in rows
|
||||
]
|
||||
|
||||
|
||||
class EmbeddingJobRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def create(self, job: EmbeddingJob) -> EmbeddingJob:
|
||||
row = EmbeddingJobRow(
|
||||
id=job.id, source_id=job.source_id, status=str(job.status),
|
||||
error=job.error, created_at=job.created_at,
|
||||
)
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
return EmbeddingJob(
|
||||
id=row.id, source_id=row.source_id,
|
||||
status=EmbeddingJobStatus(row.status), error=row.error,
|
||||
created_at=row.created_at,
|
||||
)
|
||||
|
||||
async def latest_for_source(self, source_id: str) -> EmbeddingJob | None:
|
||||
stmt = (
|
||||
select(EmbeddingJobRow)
|
||||
.where(EmbeddingJobRow.source_id == source_id)
|
||||
.order_by(EmbeddingJobRow.created_at.desc())
|
||||
)
|
||||
row = (await self.session.execute(stmt)).scalars().first()
|
||||
if row is None:
|
||||
return None
|
||||
return EmbeddingJob(
|
||||
id=row.id, source_id=row.source_id,
|
||||
status=EmbeddingJobStatus(row.status), error=row.error,
|
||||
created_at=row.created_at,
|
||||
)
|
||||
|
||||
async def set_status(
|
||||
self, job_id: str, status: EmbeddingJobStatus, *, error: str | None = None
|
||||
) -> None:
|
||||
row = await self.session.get(EmbeddingJobRow, job_id)
|
||||
if row:
|
||||
row.status = str(status)
|
||||
if error is not None:
|
||||
row.error = error
|
||||
await self.session.flush()
|
||||
|
||||
|
||||
class RetrievalPolicyRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def list_for_org(self, organization_id: str) -> list[RetrievalPolicy]:
|
||||
stmt = select(RetrievalPolicyRow).where(
|
||||
RetrievalPolicyRow.organization_id == organization_id
|
||||
)
|
||||
return [
|
||||
RetrievalPolicy(
|
||||
id=r.id, organization_id=r.organization_id, name=r.name,
|
||||
scope_json=dict(r.scope_json or {}), created_at=r.created_at,
|
||||
)
|
||||
for r in (await self.session.execute(stmt)).scalars()
|
||||
]
|
||||
|
||||
async def create(self, policy: RetrievalPolicy) -> RetrievalPolicy:
|
||||
row = RetrievalPolicyRow(
|
||||
id=policy.id, organization_id=policy.organization_id, name=policy.name,
|
||||
scope_json=dict(policy.scope_json), created_at=policy.created_at,
|
||||
)
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
return RetrievalPolicy(
|
||||
id=row.id, organization_id=row.organization_id, name=row.name,
|
||||
scope_json=dict(row.scope_json or {}), created_at=row.created_at,
|
||||
)
|
||||
@@ -0,0 +1,253 @@
|
||||
"""Repositories for Projects, their Git repositories, credentials and preview
|
||||
domains."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.domain.projects.models import (
|
||||
Project,
|
||||
ProjectGitCredential,
|
||||
ProjectGitRepo,
|
||||
)
|
||||
from app.infrastructure.db.models import (
|
||||
PreviewDomainRow,
|
||||
PreviewPortRow,
|
||||
ProjectGitCredentialRow,
|
||||
ProjectRepositoryRow,
|
||||
ProjectRow,
|
||||
)
|
||||
|
||||
|
||||
def _project_to_domain(row: ProjectRow) -> Project:
|
||||
return Project(
|
||||
id=row.id, organization_id=row.organization_id, name=row.name, slug=row.slug,
|
||||
description=row.description, created_by_user_id=row.created_by_user_id,
|
||||
default_branch=row.default_branch, created_at=row.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _repo_to_domain(row: ProjectRepositoryRow) -> ProjectGitRepo:
|
||||
return ProjectGitRepo(
|
||||
id=row.id, project_id=row.project_id, provider=row.provider, owner=row.owner,
|
||||
repo_name=row.repo_name, clone_url=row.clone_url, web_url=row.web_url,
|
||||
default_branch=row.default_branch, created_at=row.created_at,
|
||||
)
|
||||
|
||||
|
||||
class ProjectGitRepoRepository:
|
||||
"""Persistence for the Git repository attached to a project."""
|
||||
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def get_for_project(self, project_id: str) -> ProjectGitRepo | None:
|
||||
stmt = select(ProjectRepositoryRow).where(
|
||||
ProjectRepositoryRow.project_id == project_id
|
||||
)
|
||||
row = (await self.session.execute(stmt)).scalar_one_or_none()
|
||||
return _repo_to_domain(row) if row else None
|
||||
|
||||
async def upsert(self, repo: ProjectGitRepo) -> ProjectGitRepo:
|
||||
existing = await self.get_for_project(repo.project_id)
|
||||
if existing:
|
||||
row = await self.session.get(ProjectRepositoryRow, existing.id)
|
||||
assert row is not None
|
||||
row.provider = repo.provider
|
||||
row.owner = repo.owner
|
||||
row.repo_name = repo.repo_name
|
||||
row.clone_url = repo.clone_url
|
||||
row.web_url = repo.web_url
|
||||
row.default_branch = repo.default_branch
|
||||
await self.session.flush()
|
||||
return _repo_to_domain(row)
|
||||
|
||||
row = ProjectRepositoryRow(
|
||||
id=repo.id, project_id=repo.project_id, provider=repo.provider,
|
||||
owner=repo.owner, repo_name=repo.repo_name, clone_url=repo.clone_url,
|
||||
web_url=repo.web_url, default_branch=repo.default_branch,
|
||||
created_at=repo.created_at,
|
||||
)
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
return _repo_to_domain(row)
|
||||
|
||||
|
||||
class ProjectGitCredentialRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def get_for_project(self, project_id: str) -> ProjectGitCredential | None:
|
||||
stmt = select(ProjectGitCredentialRow).where(
|
||||
ProjectGitCredentialRow.project_id == project_id
|
||||
)
|
||||
row = (await self.session.execute(stmt)).scalar_one_or_none()
|
||||
if row is None:
|
||||
return None
|
||||
return ProjectGitCredential(
|
||||
id=row.id, project_id=row.project_id, username=row.username,
|
||||
encrypted_password=row.encrypted_password, created_at=row.created_at,
|
||||
)
|
||||
|
||||
async def upsert(self, credential: ProjectGitCredential) -> ProjectGitCredential:
|
||||
existing = await self.get_for_project(credential.project_id)
|
||||
if existing:
|
||||
row = await self.session.get(ProjectGitCredentialRow, existing.id)
|
||||
assert row is not None
|
||||
row.username = credential.username
|
||||
row.encrypted_password = credential.encrypted_password
|
||||
await self.session.flush()
|
||||
return ProjectGitCredential(
|
||||
id=row.id, project_id=row.project_id, username=row.username,
|
||||
encrypted_password=row.encrypted_password, created_at=row.created_at,
|
||||
)
|
||||
|
||||
row = ProjectGitCredentialRow(
|
||||
id=credential.id, project_id=credential.project_id,
|
||||
username=credential.username,
|
||||
encrypted_password=credential.encrypted_password,
|
||||
created_at=credential.created_at,
|
||||
)
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
return credential
|
||||
|
||||
|
||||
class ProjectRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def get(self, project_id: str) -> Project | None:
|
||||
row = await self.session.get(ProjectRow, project_id)
|
||||
return _project_to_domain(row) if row else None
|
||||
|
||||
async def list_for_org(
|
||||
self, organization_id: str, *, limit: int = 100, offset: int = 0
|
||||
) -> list[Project]:
|
||||
stmt = (
|
||||
select(ProjectRow)
|
||||
.where(ProjectRow.organization_id == organization_id)
|
||||
.order_by(ProjectRow.created_at.desc())
|
||||
.limit(min(limit, 500))
|
||||
.offset(offset)
|
||||
)
|
||||
return [_project_to_domain(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||
|
||||
async def list_slugs(self, organization_id: str) -> set[str]:
|
||||
stmt = select(ProjectRow.slug).where(ProjectRow.organization_id == organization_id)
|
||||
return set((await self.session.execute(stmt)).scalars().all())
|
||||
|
||||
async def create(self, project: Project) -> Project:
|
||||
row = ProjectRow(
|
||||
id=project.id, organization_id=project.organization_id, name=project.name,
|
||||
slug=project.slug, description=project.description,
|
||||
created_by_user_id=project.created_by_user_id,
|
||||
default_branch=project.default_branch, created_at=project.created_at,
|
||||
)
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
return _project_to_domain(row)
|
||||
|
||||
async def delete(self, project_id: str) -> None:
|
||||
row = await self.session.get(ProjectRow, project_id)
|
||||
if row:
|
||||
await self.session.delete(row)
|
||||
await self.session.flush()
|
||||
|
||||
|
||||
class PreviewDomainRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
# -- ports ---------------------------------------------------------------
|
||||
|
||||
async def get_port(self, project_id: str, service_name: str) -> int | None:
|
||||
stmt = select(PreviewPortRow.port).where(
|
||||
PreviewPortRow.project_id == project_id,
|
||||
PreviewPortRow.service_name == service_name,
|
||||
)
|
||||
return (await self.session.execute(stmt)).scalar_one_or_none()
|
||||
|
||||
async def list_used_ports(self, *, start: int, end: int) -> set[int]:
|
||||
stmt = select(PreviewPortRow.port).where(
|
||||
PreviewPortRow.port.between(start, end)
|
||||
)
|
||||
return set((await self.session.execute(stmt)).scalars().all())
|
||||
|
||||
async def add_port(
|
||||
self, *, organization_id: str, project_id: str, service_name: str, port: int
|
||||
) -> None:
|
||||
self.session.add(
|
||||
PreviewPortRow(
|
||||
organization_id=organization_id, project_id=project_id,
|
||||
service_name=service_name, port=port, status="active",
|
||||
)
|
||||
)
|
||||
await self.session.flush()
|
||||
|
||||
# -- domains -------------------------------------------------------------
|
||||
|
||||
async def get_by_host(self, host: str) -> dict | None:
|
||||
stmt = select(PreviewDomainRow).where(PreviewDomainRow.host == host)
|
||||
row = (await self.session.execute(stmt)).scalar_one_or_none()
|
||||
return self._row_to_dict(row) if row else None
|
||||
|
||||
async def get(self, domain_id: str) -> dict | None:
|
||||
row = await self.session.get(PreviewDomainRow, domain_id)
|
||||
return self._row_to_dict(row) if row else None
|
||||
|
||||
async def list_for_project(self, project_id: str) -> list[dict]:
|
||||
stmt = (
|
||||
select(PreviewDomainRow)
|
||||
.where(PreviewDomainRow.project_id == project_id)
|
||||
.order_by(PreviewDomainRow.created_at)
|
||||
)
|
||||
return [self._row_to_dict(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||
|
||||
async def count_for_project(self, project_id: str) -> int:
|
||||
return len(await self.list_for_project(project_id))
|
||||
|
||||
async def create(
|
||||
self,
|
||||
*,
|
||||
organization_id: str,
|
||||
project_id: str,
|
||||
host: str,
|
||||
port: int,
|
||||
created_by_user_id: str | None,
|
||||
) -> dict:
|
||||
row = PreviewDomainRow(
|
||||
organization_id=organization_id, project_id=project_id, host=host,
|
||||
port=port, status="active", protected=False,
|
||||
created_by_user_id=created_by_user_id,
|
||||
)
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
return self._row_to_dict(row)
|
||||
|
||||
async def set_status(self, domain_id: str, status: str) -> None:
|
||||
row = await self.session.get(PreviewDomainRow, domain_id)
|
||||
if row:
|
||||
row.status = status
|
||||
await self.session.flush()
|
||||
|
||||
async def delete(self, domain_id: str) -> None:
|
||||
row = await self.session.get(PreviewDomainRow, domain_id)
|
||||
if row:
|
||||
await self.session.delete(row)
|
||||
await self.session.flush()
|
||||
|
||||
@staticmethod
|
||||
def _row_to_dict(row: PreviewDomainRow) -> dict:
|
||||
return {
|
||||
"id": row.id,
|
||||
"organization_id": row.organization_id,
|
||||
"project_id": row.project_id,
|
||||
"host": row.host,
|
||||
"port": row.port,
|
||||
"status": row.status,
|
||||
"protected": row.protected,
|
||||
"created_by_user_id": row.created_by_user_id,
|
||||
"created_at": row.created_at,
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
"""Repositories for the Sandbox Gateway.
|
||||
|
||||
Every tenant-scoped read joins back to ``workspaces.organization_id`` so a
|
||||
workspace or session id from another tenant resolves to ``None``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.domain.base import utcnow
|
||||
from app.domain.sandbox.models import (
|
||||
CommandRun,
|
||||
NetworkPolicy,
|
||||
PreviewUrl,
|
||||
SandboxProviderKind,
|
||||
SandboxResourceLimit,
|
||||
SandboxSession,
|
||||
SandboxTemplate,
|
||||
SessionStatus,
|
||||
Snapshot,
|
||||
Workspace,
|
||||
WorkspaceStatus,
|
||||
)
|
||||
from app.infrastructure.db.models import (
|
||||
CommandRunRow,
|
||||
PreviewUrlRow,
|
||||
SandboxResourceLimitRow,
|
||||
SandboxSessionRow,
|
||||
SandboxTemplateRow,
|
||||
SnapshotRow,
|
||||
WorkspaceRow,
|
||||
)
|
||||
|
||||
|
||||
def _template(row: SandboxTemplateRow) -> SandboxTemplate:
|
||||
return SandboxTemplate(
|
||||
id=row.id, name=row.name, provider=row.provider, image=row.image,
|
||||
description=row.description, default_cpu=row.default_cpu,
|
||||
default_memory_mb=row.default_memory_mb, default_disk_mb=row.default_disk_mb,
|
||||
is_system=row.is_system, created_at=row.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _workspace(row: WorkspaceRow) -> Workspace:
|
||||
return Workspace(
|
||||
id=row.id, organization_id=row.organization_id, project_id=row.project_id,
|
||||
template_id=row.template_id, provider=row.provider,
|
||||
external_workspace_id=row.external_workspace_id,
|
||||
status=WorkspaceStatus(row.status), created_at=row.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _session(row: SandboxSessionRow) -> SandboxSession:
|
||||
return SandboxSession(
|
||||
id=row.id, workspace_id=row.workspace_id,
|
||||
external_session_id=row.external_session_id,
|
||||
status=SessionStatus(row.status), started_at=row.started_at,
|
||||
stopped_at=row.stopped_at, last_active_at=row.last_active_at,
|
||||
)
|
||||
|
||||
|
||||
class SandboxTemplateRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def list(self) -> list[SandboxTemplate]:
|
||||
stmt = select(SandboxTemplateRow).order_by(SandboxTemplateRow.name)
|
||||
return [_template(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||
|
||||
async def get(self, template_id: str) -> SandboxTemplate | None:
|
||||
row = await self.session.get(SandboxTemplateRow, template_id)
|
||||
return _template(row) if row else None
|
||||
|
||||
async def get_by_name(self, name: str) -> SandboxTemplate | None:
|
||||
stmt = select(SandboxTemplateRow).where(SandboxTemplateRow.name == name)
|
||||
row = (await self.session.execute(stmt)).scalar_one_or_none()
|
||||
return _template(row) if row else None
|
||||
|
||||
async def create(self, template: SandboxTemplate) -> SandboxTemplate:
|
||||
row = SandboxTemplateRow(
|
||||
id=template.id, name=template.name, provider=str(template.provider),
|
||||
image=template.image, description=template.description,
|
||||
default_cpu=template.default_cpu,
|
||||
default_memory_mb=template.default_memory_mb,
|
||||
default_disk_mb=template.default_disk_mb, is_system=template.is_system,
|
||||
created_at=template.created_at,
|
||||
)
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
return _template(row)
|
||||
|
||||
|
||||
class WorkspaceRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def get(self, workspace_id: str) -> Workspace | None:
|
||||
"""Unscoped lookup. Platform components only — tenant paths use
|
||||
:meth:`get_for_org` so a foreign id resolves to ``None``."""
|
||||
row = await self.session.get(WorkspaceRow, workspace_id)
|
||||
return _workspace(row) if row else None
|
||||
|
||||
async def get_for_org(self, organization_id: str, workspace_id: str) -> Workspace | None:
|
||||
stmt = select(WorkspaceRow).where(
|
||||
WorkspaceRow.id == workspace_id,
|
||||
WorkspaceRow.organization_id == organization_id,
|
||||
)
|
||||
row = (await self.session.execute(stmt)).scalar_one_or_none()
|
||||
return _workspace(row) if row else None
|
||||
|
||||
async def list_for_project(
|
||||
self, project_id: str, *, limit: int = 100, offset: int = 0
|
||||
) -> list[Workspace]:
|
||||
stmt = (
|
||||
select(WorkspaceRow)
|
||||
.where(WorkspaceRow.project_id == project_id)
|
||||
.order_by(WorkspaceRow.created_at.desc())
|
||||
.limit(min(limit, 500))
|
||||
.offset(offset)
|
||||
)
|
||||
return [_workspace(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||
|
||||
async def create(self, workspace: Workspace) -> Workspace:
|
||||
row = WorkspaceRow(
|
||||
id=workspace.id, organization_id=workspace.organization_id,
|
||||
project_id=workspace.project_id, template_id=workspace.template_id,
|
||||
provider=str(workspace.provider),
|
||||
external_workspace_id=workspace.external_workspace_id,
|
||||
status=str(workspace.status), created_at=workspace.created_at,
|
||||
)
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
return _workspace(row)
|
||||
|
||||
async def set_status(self, workspace_id: str, status: WorkspaceStatus) -> None:
|
||||
row = await self.session.get(WorkspaceRow, workspace_id)
|
||||
if row:
|
||||
row.status = str(status)
|
||||
await self.session.flush()
|
||||
|
||||
|
||||
class SandboxSessionRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def get(self, session_id: str) -> SandboxSession | None:
|
||||
row = await self.session.get(SandboxSessionRow, session_id)
|
||||
return _session(row) if row else None
|
||||
|
||||
async def get_for_org(self, organization_id: str, session_id: str) -> SandboxSession | None:
|
||||
"""Resolve a session through its workspace, which carries the tenant."""
|
||||
stmt = (
|
||||
select(SandboxSessionRow)
|
||||
.join(WorkspaceRow, WorkspaceRow.id == SandboxSessionRow.workspace_id)
|
||||
.where(
|
||||
SandboxSessionRow.id == session_id,
|
||||
WorkspaceRow.organization_id == organization_id,
|
||||
)
|
||||
)
|
||||
row = (await self.session.execute(stmt)).scalar_one_or_none()
|
||||
return _session(row) if row else None
|
||||
|
||||
async def list_for_workspace(self, workspace_id: str) -> list[SandboxSession]:
|
||||
stmt = (
|
||||
select(SandboxSessionRow)
|
||||
.where(SandboxSessionRow.workspace_id == workspace_id)
|
||||
.order_by(SandboxSessionRow.started_at.desc())
|
||||
)
|
||||
return [_session(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||
|
||||
async def list_stale_active(
|
||||
self, *, idle_before: datetime, limit: int = 200
|
||||
) -> list[SandboxSession]:
|
||||
"""Active sessions untouched since ``idle_before``.
|
||||
|
||||
The gateway reaper's query. Only ``starting``/``running``/``paused``
|
||||
count as active — a stopped session is already gone and must not be
|
||||
stopped twice.
|
||||
"""
|
||||
stmt = (
|
||||
select(SandboxSessionRow)
|
||||
.where(
|
||||
SandboxSessionRow.status.in_(
|
||||
[
|
||||
str(SessionStatus.STARTING),
|
||||
str(SessionStatus.RUNNING),
|
||||
str(SessionStatus.PAUSED),
|
||||
]
|
||||
),
|
||||
SandboxSessionRow.last_active_at < idle_before,
|
||||
)
|
||||
.order_by(SandboxSessionRow.last_active_at.asc())
|
||||
.limit(min(limit, 500))
|
||||
)
|
||||
return [_session(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||
|
||||
async def create(self, sandbox_session: SandboxSession) -> SandboxSession:
|
||||
row = SandboxSessionRow(
|
||||
id=sandbox_session.id, workspace_id=sandbox_session.workspace_id,
|
||||
external_session_id=sandbox_session.external_session_id,
|
||||
status=str(sandbox_session.status), started_at=sandbox_session.started_at,
|
||||
stopped_at=sandbox_session.stopped_at,
|
||||
last_active_at=sandbox_session.last_active_at,
|
||||
)
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
return _session(row)
|
||||
|
||||
async def set_status(
|
||||
self, session_id: str, status: SessionStatus, *, stopped: bool = False
|
||||
) -> None:
|
||||
row = await self.session.get(SandboxSessionRow, session_id)
|
||||
if row:
|
||||
row.status = str(status)
|
||||
row.last_active_at = utcnow()
|
||||
if stopped:
|
||||
row.stopped_at = utcnow()
|
||||
await self.session.flush()
|
||||
|
||||
|
||||
class CommandRunRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def list_for_session(self, session_id: str) -> list[CommandRun]:
|
||||
stmt = (
|
||||
select(CommandRunRow)
|
||||
.where(CommandRunRow.session_id == session_id)
|
||||
.order_by(CommandRunRow.created_at)
|
||||
)
|
||||
return [
|
||||
CommandRun(
|
||||
id=r.id, session_id=r.session_id, command=r.command,
|
||||
exit_code=r.exit_code, stdout_ref=r.stdout_ref, stderr_ref=r.stderr_ref,
|
||||
duration_ms=r.duration_ms, truncated=r.truncated, created_at=r.created_at,
|
||||
)
|
||||
for r in (await self.session.execute(stmt)).scalars()
|
||||
]
|
||||
|
||||
async def create(self, run: CommandRun) -> CommandRun:
|
||||
row = CommandRunRow(
|
||||
id=run.id, session_id=run.session_id, command=run.command,
|
||||
exit_code=run.exit_code, stdout_ref=run.stdout_ref, stderr_ref=run.stderr_ref,
|
||||
duration_ms=run.duration_ms, truncated=run.truncated, created_at=run.created_at,
|
||||
)
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
return run
|
||||
|
||||
|
||||
class SnapshotRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def list_for_workspace(self, workspace_id: str) -> list[Snapshot]:
|
||||
stmt = (
|
||||
select(SnapshotRow)
|
||||
.where(SnapshotRow.workspace_id == workspace_id)
|
||||
.order_by(SnapshotRow.created_at.desc())
|
||||
)
|
||||
return [
|
||||
Snapshot(
|
||||
id=r.id, workspace_id=r.workspace_id, name=r.name,
|
||||
external_snapshot_id=r.external_snapshot_id, created_at=r.created_at,
|
||||
)
|
||||
for r in (await self.session.execute(stmt)).scalars()
|
||||
]
|
||||
|
||||
async def create(self, snapshot: Snapshot) -> Snapshot:
|
||||
row = SnapshotRow(
|
||||
id=snapshot.id, workspace_id=snapshot.workspace_id, name=snapshot.name,
|
||||
external_snapshot_id=snapshot.external_snapshot_id,
|
||||
created_at=snapshot.created_at,
|
||||
)
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
return snapshot
|
||||
|
||||
|
||||
class PreviewUrlRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def list_for_workspace(self, workspace_id: str) -> list[PreviewUrl]:
|
||||
stmt = select(PreviewUrlRow).where(PreviewUrlRow.workspace_id == workspace_id)
|
||||
return [
|
||||
PreviewUrl(
|
||||
id=r.id, workspace_id=r.workspace_id, port=r.port, url=r.url,
|
||||
created_at=r.created_at,
|
||||
)
|
||||
for r in (await self.session.execute(stmt)).scalars()
|
||||
]
|
||||
|
||||
async def create(self, preview: PreviewUrl) -> PreviewUrl:
|
||||
row = PreviewUrlRow(
|
||||
id=preview.id, workspace_id=preview.workspace_id, port=preview.port,
|
||||
url=preview.url, created_at=preview.created_at,
|
||||
)
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
return preview
|
||||
|
||||
|
||||
class ResourceLimitRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def get_for_scope(self, scope: str, scope_id: str | None) -> SandboxResourceLimit | None:
|
||||
stmt = select(SandboxResourceLimitRow).where(
|
||||
SandboxResourceLimitRow.scope == scope,
|
||||
SandboxResourceLimitRow.scope_id == scope_id,
|
||||
)
|
||||
row = (await self.session.execute(stmt)).scalar_one_or_none()
|
||||
if row is None:
|
||||
return None
|
||||
return SandboxResourceLimit(
|
||||
id=row.id, scope=row.scope, scope_id=row.scope_id, cpu=row.cpu,
|
||||
memory_mb=row.memory_mb, disk_mb=row.disk_mb,
|
||||
timeout_seconds=row.timeout_seconds, max_output_bytes=row.max_output_bytes,
|
||||
network_policy=NetworkPolicy(row.network_policy),
|
||||
)
|
||||
|
||||
async def upsert(self, limit: SandboxResourceLimit) -> SandboxResourceLimit:
|
||||
stmt = select(SandboxResourceLimitRow).where(
|
||||
SandboxResourceLimitRow.scope == limit.scope,
|
||||
SandboxResourceLimitRow.scope_id == limit.scope_id,
|
||||
)
|
||||
row = (await self.session.execute(stmt)).scalar_one_or_none()
|
||||
if row is None:
|
||||
row = SandboxResourceLimitRow(
|
||||
id=limit.id, scope=limit.scope, scope_id=limit.scope_id, cpu=limit.cpu,
|
||||
memory_mb=limit.memory_mb, disk_mb=limit.disk_mb,
|
||||
timeout_seconds=limit.timeout_seconds,
|
||||
max_output_bytes=limit.max_output_bytes,
|
||||
network_policy=str(limit.network_policy),
|
||||
)
|
||||
self.session.add(row)
|
||||
else:
|
||||
row.cpu = limit.cpu
|
||||
row.memory_mb = limit.memory_mb
|
||||
row.disk_mb = limit.disk_mb
|
||||
row.timeout_seconds = limit.timeout_seconds
|
||||
row.max_output_bytes = limit.max_output_bytes
|
||||
row.network_policy = str(limit.network_policy)
|
||||
await self.session.flush()
|
||||
return limit
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CommandRunRepository",
|
||||
"PreviewUrlRepository",
|
||||
"ResourceLimitRepository",
|
||||
"SandboxProviderKind",
|
||||
"SandboxSessionRepository",
|
||||
"SandboxTemplateRepository",
|
||||
"SnapshotRepository",
|
||||
"WorkspaceRepository",
|
||||
]
|
||||
@@ -0,0 +1,684 @@
|
||||
"""Repositories for the AI Workforce bounded context.
|
||||
|
||||
Covers agent templates, employees, prompt versions, tool permissions, and the
|
||||
whole Manager-Chat chain: threads -> messages -> tasks -> runs -> steps ->
|
||||
tool calls -> artifacts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.domain.workforce.models import (
|
||||
AgentTemplate,
|
||||
Artifact,
|
||||
Employee,
|
||||
EmployeeStatus,
|
||||
EmployeeToolPermission,
|
||||
Message,
|
||||
MessageRole,
|
||||
PromptVersion,
|
||||
RoleKey,
|
||||
Run,
|
||||
RunStatus,
|
||||
RunStep,
|
||||
Task,
|
||||
TaskStatus,
|
||||
Thread,
|
||||
ToolCall,
|
||||
ToolCallStatus,
|
||||
)
|
||||
from app.infrastructure.db.models import (
|
||||
AgentTemplateRow,
|
||||
ArtifactRow,
|
||||
EmployeeRow,
|
||||
EmployeeToolPermissionRow,
|
||||
MessageRow,
|
||||
PromptVersionRow,
|
||||
RunRow,
|
||||
RunStepRow,
|
||||
TaskRow,
|
||||
ThreadRow,
|
||||
ToolCallRow,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mappers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _template(row: AgentTemplateRow) -> AgentTemplate:
|
||||
return AgentTemplate(
|
||||
id=row.id, name=row.name, role_key=RoleKey(row.role_key),
|
||||
description=row.description, default_prompt=row.default_prompt,
|
||||
default_tools_json=list(row.default_tools_json or []),
|
||||
default_routing_policy_id=row.default_routing_policy_id,
|
||||
is_system=row.is_system, created_at=row.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _employee(row: EmployeeRow) -> Employee:
|
||||
return Employee(
|
||||
id=row.id, organization_id=row.organization_id, project_id=row.project_id,
|
||||
template_id=row.template_id, name=row.name, role_key=RoleKey(row.role_key),
|
||||
status=EmployeeStatus(row.status), routing_policy_id=row.routing_policy_id,
|
||||
budget_limit_json=dict(row.budget_limit_json or {}), created_at=row.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _thread(row: ThreadRow) -> Thread:
|
||||
return Thread(
|
||||
id=row.id, organization_id=row.organization_id, project_id=row.project_id,
|
||||
title=row.title, created_by_user_id=row.created_by_user_id,
|
||||
created_at=row.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _message(row: MessageRow) -> Message:
|
||||
return Message(
|
||||
id=row.id, thread_id=row.thread_id, role=MessageRole(row.role),
|
||||
content=row.content, employee_id=row.employee_id, created_at=row.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _task(row: TaskRow) -> Task:
|
||||
return Task(
|
||||
id=row.id, organization_id=row.organization_id, project_id=row.project_id,
|
||||
thread_id=row.thread_id, title=row.title, description=row.description,
|
||||
status=TaskStatus(row.status),
|
||||
assigned_employee_id=row.assigned_employee_id,
|
||||
created_by_user_id=row.created_by_user_id,
|
||||
created_at=row.created_at, updated_at=row.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _run(row: RunRow) -> Run:
|
||||
return Run(
|
||||
id=row.id, task_id=row.task_id, employee_id=row.employee_id,
|
||||
status=RunStatus(row.status), error=row.error, started_at=row.started_at,
|
||||
finished_at=row.finished_at, created_at=row.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _run_step(row: RunStepRow) -> RunStep:
|
||||
return RunStep(
|
||||
id=row.id, run_id=row.run_id, index=row.index, kind=row.kind,
|
||||
title=row.title, payload_json=dict(row.payload_json or {}),
|
||||
created_at=row.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _tool_call(row: ToolCallRow) -> ToolCall:
|
||||
return ToolCall(
|
||||
id=row.id, run_id=row.run_id, tool_key=row.tool_key,
|
||||
arguments_json=dict(row.arguments_json or {}),
|
||||
result_json=dict(row.result_json or {}),
|
||||
status=ToolCallStatus(row.status), requires_approval=row.requires_approval,
|
||||
approved_by_user_id=row.approved_by_user_id, created_at=row.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _artifact(row: ArtifactRow) -> Artifact:
|
||||
return Artifact(
|
||||
id=row.id, organization_id=row.organization_id, project_id=row.project_id,
|
||||
run_id=row.run_id, name=row.name, kind=row.kind, content_ref=row.content_ref,
|
||||
size_bytes=row.size_bytes, created_at=row.created_at,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Templates & employees
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class AgentTemplateRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def list(self) -> list[AgentTemplate]:
|
||||
stmt = select(AgentTemplateRow).order_by(AgentTemplateRow.role_key)
|
||||
return [_template(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||
|
||||
async def get(self, template_id: str) -> AgentTemplate | None:
|
||||
row = await self.session.get(AgentTemplateRow, template_id)
|
||||
return _template(row) if row else None
|
||||
|
||||
async def get_by_name(self, name: str) -> AgentTemplate | None:
|
||||
stmt = select(AgentTemplateRow).where(AgentTemplateRow.name == name)
|
||||
row = (await self.session.execute(stmt)).scalar_one_or_none()
|
||||
return _template(row) if row else None
|
||||
|
||||
async def create(self, template: AgentTemplate) -> AgentTemplate:
|
||||
row = AgentTemplateRow(
|
||||
id=template.id, name=template.name, role_key=str(template.role_key),
|
||||
description=template.description, default_prompt=template.default_prompt,
|
||||
default_tools_json=list(template.default_tools_json),
|
||||
default_routing_policy_id=template.default_routing_policy_id,
|
||||
is_system=template.is_system, created_at=template.created_at,
|
||||
)
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
return _template(row)
|
||||
|
||||
|
||||
class EmployeeRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def get(self, employee_id: str) -> Employee | None:
|
||||
row = await self.session.get(EmployeeRow, employee_id)
|
||||
return _employee(row) if row else None
|
||||
|
||||
async def get_for_org(self, organization_id: str, employee_id: str) -> Employee | None:
|
||||
"""Tenant-scoped lookup: an id from another org resolves to ``None``."""
|
||||
stmt = select(EmployeeRow).where(
|
||||
EmployeeRow.id == employee_id,
|
||||
EmployeeRow.organization_id == organization_id,
|
||||
)
|
||||
row = (await self.session.execute(stmt)).scalar_one_or_none()
|
||||
return _employee(row) if row else None
|
||||
|
||||
async def list_for_org(
|
||||
self, organization_id: str, *, limit: int = 100, offset: int = 0
|
||||
) -> list[Employee]:
|
||||
stmt = (
|
||||
select(EmployeeRow)
|
||||
.where(EmployeeRow.organization_id == organization_id)
|
||||
.order_by(EmployeeRow.created_at.desc())
|
||||
.limit(min(limit, 500))
|
||||
.offset(offset)
|
||||
)
|
||||
return [_employee(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||
|
||||
async def list_for_project(self, project_id: str) -> list[Employee]:
|
||||
stmt = (
|
||||
select(EmployeeRow)
|
||||
.where(EmployeeRow.project_id == project_id)
|
||||
.order_by(EmployeeRow.created_at.desc())
|
||||
)
|
||||
return [_employee(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||
|
||||
async def count_for_org(self, organization_id: str) -> int:
|
||||
stmt = select(func.count()).select_from(EmployeeRow).where(
|
||||
EmployeeRow.organization_id == organization_id
|
||||
)
|
||||
return int((await self.session.execute(stmt)).scalar_one())
|
||||
|
||||
async def create(self, employee: Employee) -> Employee:
|
||||
row = EmployeeRow(
|
||||
id=employee.id, organization_id=employee.organization_id,
|
||||
project_id=employee.project_id, template_id=employee.template_id,
|
||||
name=employee.name, role_key=str(employee.role_key),
|
||||
status=str(employee.status),
|
||||
routing_policy_id=employee.routing_policy_id,
|
||||
budget_limit_json=dict(employee.budget_limit_json),
|
||||
created_at=employee.created_at,
|
||||
)
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
return _employee(row)
|
||||
|
||||
async def update(self, employee: Employee) -> Employee:
|
||||
row = await self.session.get(EmployeeRow, employee.id)
|
||||
if row is None:
|
||||
raise LookupError("employee not found")
|
||||
row.name = employee.name
|
||||
row.role_key = str(employee.role_key)
|
||||
row.status = str(employee.status)
|
||||
row.project_id = employee.project_id
|
||||
row.routing_policy_id = employee.routing_policy_id
|
||||
row.budget_limit_json = dict(employee.budget_limit_json)
|
||||
await self.session.flush()
|
||||
return _employee(row)
|
||||
|
||||
|
||||
class PromptVersionRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def list_for_employee(self, employee_id: str) -> list[PromptVersion]:
|
||||
stmt = (
|
||||
select(PromptVersionRow)
|
||||
.where(PromptVersionRow.employee_id == employee_id)
|
||||
.order_by(PromptVersionRow.version.desc())
|
||||
)
|
||||
return [
|
||||
PromptVersion(
|
||||
id=r.id, employee_id=r.employee_id, version=r.version,
|
||||
content=r.content, created_by_user_id=r.created_by_user_id,
|
||||
created_at=r.created_at,
|
||||
)
|
||||
for r in (await self.session.execute(stmt)).scalars()
|
||||
]
|
||||
|
||||
async def latest(self, employee_id: str) -> PromptVersion | None:
|
||||
versions = await self.list_for_employee(employee_id)
|
||||
return versions[0] if versions else None
|
||||
|
||||
async def next_version_number(self, employee_id: str) -> int:
|
||||
stmt = select(func.max(PromptVersionRow.version)).where(
|
||||
PromptVersionRow.employee_id == employee_id
|
||||
)
|
||||
current = (await self.session.execute(stmt)).scalar_one_or_none()
|
||||
return int(current or 0) + 1
|
||||
|
||||
async def create(self, version: PromptVersion) -> PromptVersion:
|
||||
row = PromptVersionRow(
|
||||
id=version.id, employee_id=version.employee_id, version=version.version,
|
||||
content=version.content, created_by_user_id=version.created_by_user_id,
|
||||
created_at=version.created_at,
|
||||
)
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
return PromptVersion(
|
||||
id=row.id, employee_id=row.employee_id, version=row.version,
|
||||
content=row.content, created_by_user_id=row.created_by_user_id,
|
||||
created_at=row.created_at,
|
||||
)
|
||||
|
||||
|
||||
class EmployeeToolPermissionRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def list_for_employee(self, employee_id: str) -> list[EmployeeToolPermission]:
|
||||
stmt = select(EmployeeToolPermissionRow).where(
|
||||
EmployeeToolPermissionRow.employee_id == employee_id
|
||||
)
|
||||
return [
|
||||
EmployeeToolPermission(
|
||||
id=r.id, employee_id=r.employee_id, tool_key=r.tool_key,
|
||||
allowed=r.allowed, policy_json=dict(r.policy_json or {}),
|
||||
)
|
||||
for r in (await self.session.execute(stmt)).scalars()
|
||||
]
|
||||
|
||||
async def is_allowed(self, employee_id: str, tool_key: str) -> bool:
|
||||
stmt = select(EmployeeToolPermissionRow.allowed).where(
|
||||
EmployeeToolPermissionRow.employee_id == employee_id,
|
||||
EmployeeToolPermissionRow.tool_key == tool_key,
|
||||
)
|
||||
value = (await self.session.execute(stmt)).scalar_one_or_none()
|
||||
# Deny by default: an absent row means "not permitted".
|
||||
return bool(value)
|
||||
|
||||
async def set_permission(
|
||||
self, *, employee_id: str, tool_key: str, allowed: bool, policy: dict | None = None
|
||||
) -> EmployeeToolPermission:
|
||||
stmt = select(EmployeeToolPermissionRow).where(
|
||||
EmployeeToolPermissionRow.employee_id == employee_id,
|
||||
EmployeeToolPermissionRow.tool_key == tool_key,
|
||||
)
|
||||
row = (await self.session.execute(stmt)).scalar_one_or_none()
|
||||
if row is None:
|
||||
row = EmployeeToolPermissionRow(
|
||||
employee_id=employee_id, tool_key=tool_key, allowed=allowed,
|
||||
policy_json=dict(policy or {}),
|
||||
)
|
||||
self.session.add(row)
|
||||
else:
|
||||
row.allowed = allowed
|
||||
row.policy_json = dict(policy or {})
|
||||
await self.session.flush()
|
||||
return EmployeeToolPermission(
|
||||
id=row.id, employee_id=row.employee_id, tool_key=row.tool_key,
|
||||
allowed=row.allowed, policy_json=dict(row.policy_json or {}),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Chat: threads & messages
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ThreadRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def get(self, thread_id: str) -> Thread | None:
|
||||
row = await self.session.get(ThreadRow, thread_id)
|
||||
return _thread(row) if row else None
|
||||
|
||||
async def get_for_org(self, organization_id: str, thread_id: str) -> Thread | None:
|
||||
stmt = select(ThreadRow).where(
|
||||
ThreadRow.id == thread_id, ThreadRow.organization_id == organization_id
|
||||
)
|
||||
row = (await self.session.execute(stmt)).scalar_one_or_none()
|
||||
return _thread(row) if row else None
|
||||
|
||||
async def list_for_org(
|
||||
self, organization_id: str, *, limit: int = 100, offset: int = 0
|
||||
) -> list[Thread]:
|
||||
stmt = (
|
||||
select(ThreadRow)
|
||||
.where(ThreadRow.organization_id == organization_id)
|
||||
.order_by(ThreadRow.created_at.desc())
|
||||
.limit(min(limit, 500))
|
||||
.offset(offset)
|
||||
)
|
||||
return [_thread(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||
|
||||
async def create(self, thread: Thread) -> Thread:
|
||||
row = ThreadRow(
|
||||
id=thread.id, organization_id=thread.organization_id,
|
||||
project_id=thread.project_id, title=thread.title,
|
||||
created_by_user_id=thread.created_by_user_id, created_at=thread.created_at,
|
||||
)
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
return _thread(row)
|
||||
|
||||
|
||||
class MessageRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def list_for_thread(self, thread_id: str) -> list[Message]:
|
||||
stmt = (
|
||||
select(MessageRow)
|
||||
.where(MessageRow.thread_id == thread_id)
|
||||
.order_by(MessageRow.created_at)
|
||||
)
|
||||
return [_message(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||
|
||||
async def count_for_thread(self, thread_id: str) -> int:
|
||||
stmt = select(func.count()).select_from(MessageRow).where(
|
||||
MessageRow.thread_id == thread_id
|
||||
)
|
||||
return int((await self.session.execute(stmt)).scalar_one())
|
||||
|
||||
async def create(self, message: Message) -> Message:
|
||||
row = MessageRow(
|
||||
id=message.id, thread_id=message.thread_id, role=str(message.role),
|
||||
content=message.content, employee_id=message.employee_id,
|
||||
created_at=message.created_at,
|
||||
)
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
return _message(row)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Work: tasks, runs, steps, tool calls, artifacts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TaskRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def get(self, task_id: str) -> Task | None:
|
||||
row = await self.session.get(TaskRow, task_id)
|
||||
return _task(row) if row else None
|
||||
|
||||
async def get_for_org(self, organization_id: str, task_id: str) -> Task | None:
|
||||
stmt = select(TaskRow).where(
|
||||
TaskRow.id == task_id, TaskRow.organization_id == organization_id
|
||||
)
|
||||
row = (await self.session.execute(stmt)).scalar_one_or_none()
|
||||
return _task(row) if row else None
|
||||
|
||||
async def list_for_org(
|
||||
self,
|
||||
organization_id: str,
|
||||
*,
|
||||
status: TaskStatus | None = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
) -> list[Task]:
|
||||
stmt = select(TaskRow).where(TaskRow.organization_id == organization_id)
|
||||
if status is not None:
|
||||
stmt = stmt.where(TaskRow.status == str(status))
|
||||
stmt = stmt.order_by(TaskRow.created_at.desc()).limit(min(limit, 500)).offset(offset)
|
||||
return [_task(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||
|
||||
async def list_for_employee(self, employee_id: str) -> list[Task]:
|
||||
stmt = (
|
||||
select(TaskRow)
|
||||
.where(TaskRow.assigned_employee_id == employee_id)
|
||||
.order_by(TaskRow.created_at.desc())
|
||||
)
|
||||
return [_task(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||
|
||||
async def list_runnable(self, *, limit: int = 10) -> list[Task]:
|
||||
"""Tasks the runtime worker should pick up, oldest first.
|
||||
|
||||
Cross-tenant by design: the worker is a platform component, not a
|
||||
tenant. Only ``new``/``triaged`` qualify — anything else is either
|
||||
already executing or closed, and re-running it would double-bill.
|
||||
"""
|
||||
stmt = (
|
||||
select(TaskRow)
|
||||
.where(TaskRow.status.in_([str(TaskStatus.NEW), str(TaskStatus.TRIAGED)]))
|
||||
.order_by(TaskRow.created_at.asc())
|
||||
.limit(min(limit, 100))
|
||||
)
|
||||
return [_task(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||
|
||||
async def count_open_for_employee(self, employee_id: str) -> int:
|
||||
stmt = (
|
||||
select(func.count())
|
||||
.select_from(TaskRow)
|
||||
.where(
|
||||
TaskRow.assigned_employee_id == employee_id,
|
||||
TaskRow.status.notin_(
|
||||
[str(TaskStatus.DONE), str(TaskStatus.FAILED), str(TaskStatus.CANCELLED)]
|
||||
),
|
||||
)
|
||||
)
|
||||
return int((await self.session.execute(stmt)).scalar_one())
|
||||
|
||||
async def create(self, task: Task) -> Task:
|
||||
row = TaskRow(
|
||||
id=task.id, organization_id=task.organization_id,
|
||||
project_id=task.project_id, thread_id=task.thread_id,
|
||||
title=task.title, description=task.description, status=str(task.status),
|
||||
assigned_employee_id=task.assigned_employee_id,
|
||||
created_by_user_id=task.created_by_user_id,
|
||||
created_at=task.created_at, updated_at=task.updated_at,
|
||||
)
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
return _task(row)
|
||||
|
||||
async def set_status(self, task_id: str, status: TaskStatus) -> Task | None:
|
||||
row = await self.session.get(TaskRow, task_id)
|
||||
if row is None:
|
||||
return None
|
||||
row.status = str(status)
|
||||
await self.session.flush()
|
||||
return _task(row)
|
||||
|
||||
|
||||
class RunRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def get(self, run_id: str) -> Run | None:
|
||||
row = await self.session.get(RunRow, run_id)
|
||||
return _run(row) if row else None
|
||||
|
||||
async def list_for_task(self, task_id: str) -> list[Run]:
|
||||
stmt = (
|
||||
select(RunRow)
|
||||
.where(RunRow.task_id == task_id)
|
||||
.order_by(RunRow.created_at.desc())
|
||||
)
|
||||
return [_run(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||
|
||||
async def create(self, run: Run) -> Run:
|
||||
row = RunRow(
|
||||
id=run.id, task_id=run.task_id, employee_id=run.employee_id,
|
||||
status=str(run.status), error=run.error, started_at=run.started_at,
|
||||
finished_at=run.finished_at, created_at=run.created_at,
|
||||
)
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
return _run(row)
|
||||
|
||||
async def set_status(
|
||||
self,
|
||||
run_id: str,
|
||||
status: RunStatus,
|
||||
*,
|
||||
error: str | None = None,
|
||||
started_at=None,
|
||||
finished_at=None,
|
||||
) -> Run | None:
|
||||
row = await self.session.get(RunRow, run_id)
|
||||
if row is None:
|
||||
return None
|
||||
row.status = str(status)
|
||||
if error is not None:
|
||||
row.error = error
|
||||
if started_at is not None:
|
||||
row.started_at = started_at
|
||||
if finished_at is not None:
|
||||
row.finished_at = finished_at
|
||||
await self.session.flush()
|
||||
return _run(row)
|
||||
|
||||
|
||||
class RunStepRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def list_for_run(self, run_id: str) -> list[RunStep]:
|
||||
stmt = (
|
||||
select(RunStepRow)
|
||||
.where(RunStepRow.run_id == run_id)
|
||||
.order_by(RunStepRow.index)
|
||||
)
|
||||
return [_run_step(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||
|
||||
async def next_index(self, run_id: str) -> int:
|
||||
stmt = select(func.max(RunStepRow.index)).where(RunStepRow.run_id == run_id)
|
||||
current = (await self.session.execute(stmt)).scalar_one_or_none()
|
||||
return int(current if current is not None else -1) + 1
|
||||
|
||||
async def append(
|
||||
self, *, run_id: str, kind: str, title: str = "", payload: dict | None = None
|
||||
) -> RunStep:
|
||||
step = RunStep(
|
||||
run_id=run_id, index=await self.next_index(run_id), kind=kind, title=title,
|
||||
payload_json=dict(payload or {}),
|
||||
)
|
||||
row = RunStepRow(
|
||||
id=step.id, run_id=step.run_id, index=step.index, kind=step.kind,
|
||||
title=step.title, payload_json=step.payload_json, created_at=step.created_at,
|
||||
)
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
return _run_step(row)
|
||||
|
||||
|
||||
class ToolCallRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def get(self, call_id: str) -> ToolCall | None:
|
||||
row = await self.session.get(ToolCallRow, call_id)
|
||||
return _tool_call(row) if row else None
|
||||
|
||||
async def get_for_org(self, organization_id: str, call_id: str) -> ToolCall | None:
|
||||
"""Tenant-scoped lookup, resolved through run -> task -> organization."""
|
||||
stmt = (
|
||||
select(ToolCallRow)
|
||||
.join(RunRow, RunRow.id == ToolCallRow.run_id)
|
||||
.join(TaskRow, TaskRow.id == RunRow.task_id)
|
||||
.where(ToolCallRow.id == call_id, TaskRow.organization_id == organization_id)
|
||||
)
|
||||
row = (await self.session.execute(stmt)).scalar_one_or_none()
|
||||
return _tool_call(row) if row else None
|
||||
|
||||
async def list_for_run(self, run_id: str) -> list[ToolCall]:
|
||||
stmt = (
|
||||
select(ToolCallRow)
|
||||
.where(ToolCallRow.run_id == run_id)
|
||||
.order_by(ToolCallRow.created_at)
|
||||
)
|
||||
return [_tool_call(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||
|
||||
async def list_pending_approvals_for_org(
|
||||
self, organization_id: str
|
||||
) -> list[ToolCall]:
|
||||
"""Tool calls awaiting approval, scoped through the owning run -> task -> org."""
|
||||
stmt = (
|
||||
select(ToolCallRow)
|
||||
.join(RunRow, RunRow.id == ToolCallRow.run_id)
|
||||
.join(TaskRow, TaskRow.id == RunRow.task_id)
|
||||
.where(
|
||||
TaskRow.organization_id == organization_id,
|
||||
ToolCallRow.status == str(ToolCallStatus.AWAITING_APPROVAL),
|
||||
)
|
||||
.order_by(ToolCallRow.created_at)
|
||||
)
|
||||
return [_tool_call(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||
|
||||
async def create(self, call: ToolCall) -> ToolCall:
|
||||
row = ToolCallRow(
|
||||
id=call.id, run_id=call.run_id, tool_key=call.tool_key,
|
||||
arguments_json=dict(call.arguments_json),
|
||||
result_json=dict(call.result_json), status=str(call.status),
|
||||
requires_approval=call.requires_approval,
|
||||
approved_by_user_id=call.approved_by_user_id, created_at=call.created_at,
|
||||
)
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
return _tool_call(row)
|
||||
|
||||
async def set_status(
|
||||
self,
|
||||
call_id: str,
|
||||
status: ToolCallStatus,
|
||||
*,
|
||||
result: dict | None = None,
|
||||
approved_by_user_id: str | None = None,
|
||||
) -> ToolCall | None:
|
||||
row = await self.session.get(ToolCallRow, call_id)
|
||||
if row is None:
|
||||
return None
|
||||
row.status = str(status)
|
||||
if result is not None:
|
||||
row.result_json = dict(result)
|
||||
if approved_by_user_id is not None:
|
||||
row.approved_by_user_id = approved_by_user_id
|
||||
await self.session.flush()
|
||||
return _tool_call(row)
|
||||
|
||||
|
||||
class ArtifactRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def list_for_org(
|
||||
self, organization_id: str, *, limit: int = 100, offset: int = 0
|
||||
) -> list[Artifact]:
|
||||
stmt = (
|
||||
select(ArtifactRow)
|
||||
.where(ArtifactRow.organization_id == organization_id)
|
||||
.order_by(ArtifactRow.created_at.desc())
|
||||
.limit(min(limit, 500))
|
||||
.offset(offset)
|
||||
)
|
||||
return [_artifact(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||
|
||||
async def list_for_run(self, run_id: str) -> list[Artifact]:
|
||||
stmt = (
|
||||
select(ArtifactRow)
|
||||
.where(ArtifactRow.run_id == run_id)
|
||||
.order_by(ArtifactRow.created_at)
|
||||
)
|
||||
return [_artifact(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||
|
||||
async def create(self, artifact: Artifact) -> Artifact:
|
||||
row = ArtifactRow(
|
||||
id=artifact.id, organization_id=artifact.organization_id,
|
||||
project_id=artifact.project_id, run_id=artifact.run_id,
|
||||
name=artifact.name, kind=artifact.kind, content_ref=artifact.content_ref,
|
||||
size_bytes=artifact.size_bytes, created_at=artifact.created_at,
|
||||
)
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
return _artifact(row)
|
||||
Reference in New Issue
Block a user