نسخهٔ وب مستقل — پلتفرم نیروی کار هوش مصنوعی

مخزن اختصاصی برنامهٔ وب؛ محتوای پوشهٔ web در ریشهٔ مخزن قرار گرفت.
تاریخچهٔ پروژهٔ ربات (agy-bot) در این مخزن وجود ندارد و هیچ وابستگی runtime به آن نیست.
This commit is contained in:
mjalmoudy94
2026-10-13 00:39:28 +03:30
commit 717e53a7ce
185 changed files with 39910 additions and 0 deletions
@@ -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()
+895
View File
@@ -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()
+281
View File
@@ -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)