نسخهٔ وب مستقل — پلتفرم نیروی کار هوش مصنوعی
مخزن اختصاصی برنامهٔ وب؛ محتوای پوشهٔ web در ریشهٔ مخزن قرار گرفت. تاریخچهٔ پروژهٔ ربات (agy-bot) در این مخزن وجود ندارد و هیچ وابستگی runtime به آن نیست.
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
"""AGY Web backend — standalone multi-tenant SaaS.
|
||||
|
||||
Independent of the Telegram bot runtime: nothing outside this package is imported.
|
||||
"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,165 @@
|
||||
"""Composition root for the application services.
|
||||
|
||||
This module builds every application service from a single ``AsyncSession``.
|
||||
It exists so that the HTTP layer and the standalone workers share **one**
|
||||
wiring: if the API and a worker ever disagreed about which adapter backs a
|
||||
port, that would be a bug, and having two copies of the wiring is exactly how
|
||||
such a bug appears.
|
||||
|
||||
Deliberately free of FastAPI: the HTTP dependency functions in
|
||||
``app.interfaces.http.deps`` delegate here, and so do the workers in
|
||||
``app.workers``. Nothing in the inner layers imports this module.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.application.services.billing_service import BillingService
|
||||
from app.application.services.knowledge_service import KnowledgeService
|
||||
from app.application.services.project_service import ProjectService
|
||||
from app.application.services.sandbox_service import SandboxService
|
||||
from app.application.services.workforce_service import WorkforceService
|
||||
from app.infrastructure.adapters.registry import (
|
||||
get_domain_router,
|
||||
get_git_provider,
|
||||
get_model_provider,
|
||||
get_payment_provider,
|
||||
get_sandbox_provider,
|
||||
get_vector_store,
|
||||
)
|
||||
from app.infrastructure.repositories.ai_control import (
|
||||
AiModelRepository,
|
||||
ModelCredentialRepository,
|
||||
RateCardRepository,
|
||||
RoutingPolicyRepository,
|
||||
)
|
||||
from app.infrastructure.repositories.audit import UsageLedgerRepository
|
||||
from app.infrastructure.repositories.billing import (
|
||||
CreditWalletRepository,
|
||||
InvoiceRepository,
|
||||
PaymentRepository,
|
||||
PlanRepository,
|
||||
SubscriptionRepository,
|
||||
)
|
||||
from app.infrastructure.repositories.knowledge import (
|
||||
DocumentChunkRepository,
|
||||
DocumentRepository,
|
||||
EmbeddingJobRepository,
|
||||
KnowledgeSourceRepository,
|
||||
RetrievalPolicyRepository,
|
||||
)
|
||||
from app.infrastructure.repositories.projects import (
|
||||
PreviewDomainRepository,
|
||||
ProjectGitCredentialRepository,
|
||||
ProjectGitRepoRepository,
|
||||
ProjectRepository,
|
||||
)
|
||||
from app.infrastructure.repositories.sandbox import (
|
||||
CommandRunRepository,
|
||||
PreviewUrlRepository,
|
||||
ResourceLimitRepository,
|
||||
SandboxSessionRepository,
|
||||
SandboxTemplateRepository,
|
||||
SnapshotRepository,
|
||||
WorkspaceRepository,
|
||||
)
|
||||
from app.infrastructure.repositories.workforce import (
|
||||
AgentTemplateRepository,
|
||||
ArtifactRepository,
|
||||
EmployeeRepository,
|
||||
EmployeeToolPermissionRepository,
|
||||
MessageRepository,
|
||||
PromptVersionRepository,
|
||||
RunRepository,
|
||||
RunStepRepository,
|
||||
TaskRepository,
|
||||
ThreadRepository,
|
||||
ToolCallRepository,
|
||||
)
|
||||
|
||||
|
||||
def build_project_service(session: AsyncSession) -> ProjectService:
|
||||
"""Project + Git repo + preview domain, with the configured adapters."""
|
||||
return ProjectService(
|
||||
projects=ProjectRepository(session),
|
||||
repos=ProjectGitRepoRepository(session),
|
||||
credentials=ProjectGitCredentialRepository(session),
|
||||
domains=PreviewDomainRepository(session),
|
||||
git=get_git_provider(),
|
||||
router=get_domain_router(),
|
||||
)
|
||||
|
||||
|
||||
def build_workforce_service(session: AsyncSession) -> WorkforceService:
|
||||
"""Employees, threads, tasks and the run engine."""
|
||||
return WorkforceService(
|
||||
employees=EmployeeRepository(session),
|
||||
templates=AgentTemplateRepository(session),
|
||||
prompts=PromptVersionRepository(session),
|
||||
permissions=EmployeeToolPermissionRepository(session),
|
||||
threads=ThreadRepository(session),
|
||||
messages=MessageRepository(session),
|
||||
tasks=TaskRepository(session),
|
||||
runs=RunRepository(session),
|
||||
run_steps=RunStepRepository(session),
|
||||
tool_calls=ToolCallRepository(session),
|
||||
artifacts=ArtifactRepository(session),
|
||||
models=AiModelRepository(session),
|
||||
credentials=ModelCredentialRepository(session),
|
||||
routing_policies=RoutingPolicyRepository(session),
|
||||
rate_cards=RateCardRepository(session),
|
||||
ledger=UsageLedgerRepository(session),
|
||||
provider=get_model_provider(),
|
||||
)
|
||||
|
||||
|
||||
def build_billing_service(session: AsyncSession) -> BillingService:
|
||||
"""Wallets, invoices, payments and the usage ledger."""
|
||||
return BillingService(
|
||||
wallets=CreditWalletRepository(session),
|
||||
invoices=InvoiceRepository(session),
|
||||
payments=PaymentRepository(session),
|
||||
plans=PlanRepository(session),
|
||||
subscriptions=SubscriptionRepository(session),
|
||||
ledger=UsageLedgerRepository(session),
|
||||
payment_provider=get_payment_provider(),
|
||||
)
|
||||
|
||||
|
||||
def build_knowledge_service(session: AsyncSession) -> KnowledgeService:
|
||||
"""Knowledge sources, documents, chunks and the vector store."""
|
||||
return KnowledgeService(
|
||||
sources=KnowledgeSourceRepository(session),
|
||||
documents=DocumentRepository(session),
|
||||
chunks=DocumentChunkRepository(session),
|
||||
jobs=EmbeddingJobRepository(session),
|
||||
policies=RetrievalPolicyRepository(session),
|
||||
vector_store=get_vector_store(),
|
||||
)
|
||||
|
||||
|
||||
def build_sandbox_service(session: AsyncSession) -> SandboxService:
|
||||
"""The sandbox gateway: the only sanctioned path for running code."""
|
||||
return SandboxService(
|
||||
templates=SandboxTemplateRepository(session),
|
||||
workspaces=WorkspaceRepository(session),
|
||||
sessions=SandboxSessionRepository(session),
|
||||
commands=CommandRunRepository(session),
|
||||
snapshots=SnapshotRepository(session),
|
||||
previews=PreviewUrlRepository(session),
|
||||
limits=ResourceLimitRepository(session),
|
||||
rate_cards=RateCardRepository(session),
|
||||
ledger=UsageLedgerRepository(session),
|
||||
projects=ProjectRepository(session),
|
||||
provider=get_sandbox_provider(),
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"build_billing_service",
|
||||
"build_knowledge_service",
|
||||
"build_project_service",
|
||||
"build_sandbox_service",
|
||||
"build_workforce_service",
|
||||
]
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Application-layer ports (driven interfaces).
|
||||
|
||||
Every port has at least one *mock* adapter so the whole stack runs with zero
|
||||
external credentials. Concrete adapters live in ``app.infrastructure.adapters``
|
||||
and are selected by the ``*_PROVIDER`` environment switches.
|
||||
"""
|
||||
|
||||
from app.application.ports.audit import (
|
||||
AuditPort,
|
||||
UsageAggregate,
|
||||
UsageBreakdown,
|
||||
UsageLedgerPort,
|
||||
UsageRecord,
|
||||
)
|
||||
from app.application.ports.domain_router import (
|
||||
DomainRouterPort,
|
||||
PreviewDomainInfo,
|
||||
ReverseProxyResult,
|
||||
)
|
||||
from app.application.ports.git_provider import (
|
||||
BranchInfo,
|
||||
CommitInfo,
|
||||
GitCredentials,
|
||||
GitProviderPort,
|
||||
MergeResult,
|
||||
PullRequestInfo,
|
||||
RepoInfo,
|
||||
RepoStatus,
|
||||
)
|
||||
from app.application.ports.model_provider import (
|
||||
ChatMessage,
|
||||
ModelHealth,
|
||||
ModelProviderPort,
|
||||
ModelResponse,
|
||||
)
|
||||
from app.application.ports.payment_provider import (
|
||||
CheckoutSession,
|
||||
PaymentProviderPort,
|
||||
PaymentResult,
|
||||
)
|
||||
from app.application.ports.sandbox_provider import (
|
||||
CommandResult,
|
||||
SandboxProviderPort,
|
||||
SessionInfo,
|
||||
SnapshotInfo,
|
||||
WorkspaceInfo,
|
||||
)
|
||||
from app.application.ports.storage import (
|
||||
Notification,
|
||||
NotificationPort,
|
||||
StoragePort,
|
||||
StoredObject,
|
||||
VectorHit,
|
||||
VectorStorePort,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AuditPort",
|
||||
"BranchInfo",
|
||||
"ChatMessage",
|
||||
"CheckoutSession",
|
||||
"CommandResult",
|
||||
"CommitInfo",
|
||||
"DomainRouterPort",
|
||||
"GitCredentials",
|
||||
"GitProviderPort",
|
||||
"MergeResult",
|
||||
"ModelHealth",
|
||||
"ModelProviderPort",
|
||||
"ModelResponse",
|
||||
"Notification",
|
||||
"NotificationPort",
|
||||
"PaymentProviderPort",
|
||||
"PaymentResult",
|
||||
"PreviewDomainInfo",
|
||||
"PullRequestInfo",
|
||||
"RepoInfo",
|
||||
"RepoStatus",
|
||||
"ReverseProxyResult",
|
||||
"SandboxProviderPort",
|
||||
"SessionInfo",
|
||||
"SnapshotInfo",
|
||||
"StoragePort",
|
||||
"StoredObject",
|
||||
"UsageAggregate",
|
||||
"UsageBreakdown",
|
||||
"UsageLedgerPort",
|
||||
"UsageRecord",
|
||||
"VectorHit",
|
||||
"VectorStorePort",
|
||||
"WorkspaceInfo",
|
||||
]
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Audit and usage-ledger ports.
|
||||
|
||||
``AuditPort`` writes the immutable trail; ``UsageLedgerPort`` is the billing
|
||||
source of truth. Both are append-only by contract.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
from app.domain.ai_control.models import RateUnit
|
||||
from app.domain.audit.models import AuditEvent
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class UsageRecord:
|
||||
organization_id: str
|
||||
unit: RateUnit
|
||||
quantity: float
|
||||
unit_price: float = 0.0
|
||||
internal_cost_estimate: float = 0.0
|
||||
total_price: float = 0.0
|
||||
project_id: str | None = None
|
||||
user_id: str | None = None
|
||||
employee_id: str | None = None
|
||||
run_id: str | None = None
|
||||
metadata_json: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class UsageAggregate:
|
||||
unit: RateUnit
|
||||
quantity: float = 0.0
|
||||
total_price: float = 0.0
|
||||
internal_cost_estimate: float = 0.0
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class UsageBreakdown:
|
||||
"""Grouped totals for the billing dashboard."""
|
||||
|
||||
by_unit: list[UsageAggregate] = field(default_factory=list)
|
||||
by_employee: dict[str, float] = field(default_factory=dict)
|
||||
by_project: dict[str, float] = field(default_factory=dict)
|
||||
total_price: float = 0.0
|
||||
total_cost: float = 0.0
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class AuditPort(Protocol):
|
||||
async def record(self, *, event: AuditEvent) -> AuditEvent: ...
|
||||
|
||||
async def list_for_organization(
|
||||
self, *, organization_id: str, limit: int = 100, offset: int = 0
|
||||
) -> list[AuditEvent]: ...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class UsageLedgerPort(Protocol):
|
||||
async def record(self, *, usage: UsageRecord) -> None:
|
||||
"""Append one usage event. Never raises for business reasons — a failed
|
||||
ledger write is an infrastructure error and must surface loudly."""
|
||||
...
|
||||
|
||||
async def breakdown(
|
||||
self,
|
||||
*,
|
||||
organization_id: str,
|
||||
since: datetime | None = None,
|
||||
until: datetime | None = None,
|
||||
) -> UsageBreakdown: ...
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Domain router port — preview subdomains and the port ledger.
|
||||
|
||||
Security rules carried over from the current platform (see
|
||||
``docs/current-infra-notes.md``) are enforced by the *adapters* as well as the
|
||||
application layer:
|
||||
|
||||
* single-label hosts under ``PREVIEW_ROOT_DOMAIN`` only
|
||||
* label charset ``[a-z0-9-]``, 3-40 chars
|
||||
* proxy target must be ``localhost`` / ``127.0.0.1`` with a numeric port
|
||||
* protected platform ports are never allocatable or proxied to
|
||||
* the Caddy config is validated before it is reloaded, and rolled back on failure
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class PreviewDomainInfo:
|
||||
host: str
|
||||
port: int
|
||||
url: str = ""
|
||||
protected: bool = False
|
||||
status: str = "active"
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class ReverseProxyResult:
|
||||
ok: bool
|
||||
message: str = ""
|
||||
url: str = ""
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class DomainRouterPort(Protocol):
|
||||
async def allocate_port(
|
||||
self, *, organization_id: str, project_id: str, service_name: str
|
||||
) -> int: ...
|
||||
|
||||
async def claim_preview_domain(
|
||||
self,
|
||||
*,
|
||||
organization_id: str,
|
||||
project_id: str,
|
||||
requested_subdomain: str,
|
||||
port: int,
|
||||
) -> PreviewDomainInfo: ...
|
||||
|
||||
async def configure_reverse_proxy(self, *, host: str, port: int) -> ReverseProxyResult: ...
|
||||
|
||||
async def remove_preview_domain(self, *, domain_id: str) -> None: ...
|
||||
|
||||
async def list_project_domains(self, *, project_id: str) -> list[PreviewDomainInfo]: ...
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Git provider port — repository lifecycle without leaking the admin token."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class RepoInfo:
|
||||
owner: str
|
||||
repo_name: str
|
||||
clone_url: str = ""
|
||||
web_url: str = ""
|
||||
default_branch: str = "main"
|
||||
private: bool = True
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class BranchInfo:
|
||||
name: str
|
||||
sha: str = ""
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class CommitInfo:
|
||||
sha: str
|
||||
branch: str
|
||||
message: str = ""
|
||||
files_changed: int = 0
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class PullRequestInfo:
|
||||
number: int
|
||||
url: str
|
||||
state: str = "open"
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class MergeResult:
|
||||
merged: bool
|
||||
message: str = ""
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class RepoStatus:
|
||||
exists: bool
|
||||
private: bool = True
|
||||
default_branch: str = "main"
|
||||
branches: list[str] = field(default_factory=list)
|
||||
open_pull_requests: int = 0
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class GitCredentials:
|
||||
"""Returned only to the application layer. Never serialised to a response."""
|
||||
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class GitProviderPort(Protocol):
|
||||
"""Repository lifecycle.
|
||||
|
||||
Identity is passed in, never guessed. A real forge (Gitea) addresses
|
||||
repositories as ``owner/repo``; the platform stores that pair in
|
||||
``ProjectRepository``. Passing it down keeps the adapter stateless — it holds
|
||||
no ``project_id`` mapping that would be lost on restart and would drift from
|
||||
the database.
|
||||
|
||||
The mock adapter ignores ``owner``/``repo_name`` because it keys everything
|
||||
by ``project_id``; the defaults keep that adapter valid.
|
||||
"""
|
||||
|
||||
async def ensure_project_repo(
|
||||
self, *, project_id: str, repo_name: str, description: str = ""
|
||||
) -> RepoInfo: ...
|
||||
|
||||
async def ensure_credentials(
|
||||
self, *, project_id: str, owner: str = "", repo_name: str = ""
|
||||
) -> GitCredentials: ...
|
||||
|
||||
async def create_branch(
|
||||
self,
|
||||
*,
|
||||
project_id: str,
|
||||
branch_name: str,
|
||||
from_branch: str,
|
||||
owner: str = "",
|
||||
repo_name: str = "",
|
||||
) -> BranchInfo: ...
|
||||
|
||||
async def commit_files(
|
||||
self,
|
||||
*,
|
||||
project_id: str,
|
||||
branch_name: str,
|
||||
files: dict[str, str],
|
||||
message: str,
|
||||
owner: str = "",
|
||||
repo_name: str = "",
|
||||
) -> CommitInfo: ...
|
||||
|
||||
async def open_pull_request(
|
||||
self,
|
||||
*,
|
||||
project_id: str,
|
||||
head: str,
|
||||
base: str,
|
||||
title: str,
|
||||
body: str = "",
|
||||
owner: str = "",
|
||||
repo_name: str = "",
|
||||
) -> PullRequestInfo: ...
|
||||
|
||||
async def merge_pull_request(
|
||||
self, *, project_id: str, pr_number: int, owner: str = "", repo_name: str = ""
|
||||
) -> MergeResult: ...
|
||||
|
||||
async def get_repo_status(
|
||||
self, *, project_id: str, owner: str = "", repo_name: str = ""
|
||||
) -> RepoStatus: ...
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Model provider port — the only sanctioned way to reach an LLM.
|
||||
|
||||
The adapter decides which concrete provider/model is used; the caller passes an
|
||||
``AiModel`` record and never a provider API key. That keeps model identity
|
||||
inside the AI Control Plane.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
from app.domain.ai_control.models import AiModel, ModelCredential
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class ChatMessage:
|
||||
role: str # system | user | assistant | tool
|
||||
content: str
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class ModelResponse:
|
||||
content: str
|
||||
model_internal_name: str = ""
|
||||
input_tokens: int = 0
|
||||
output_tokens: int = 0
|
||||
latency_ms: int = 0
|
||||
raw: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class ModelHealth:
|
||||
ok: bool
|
||||
detail: str = ""
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ModelProviderPort(Protocol):
|
||||
"""Driven port. Implemented by ``MockModelProviderAdapter`` and
|
||||
``OpenAICompatibleModelAdapter``."""
|
||||
|
||||
async def complete(
|
||||
self,
|
||||
*,
|
||||
model: AiModel,
|
||||
credential: ModelCredential | None,
|
||||
messages: list[ChatMessage],
|
||||
system: str = "",
|
||||
temperature: float = 0.2,
|
||||
max_tokens: int = 2048,
|
||||
) -> ModelResponse:
|
||||
"""Run one completion. Must raise ``ProviderUnavailableError`` on failure."""
|
||||
...
|
||||
|
||||
async def health(self) -> ModelHealth:
|
||||
...
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Payment provider port — an adapter over Stripe (or mock).
|
||||
|
||||
The internal usage ledger is the source of truth; this port only moves money.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class CheckoutSession:
|
||||
external_id: str
|
||||
url: str
|
||||
amount: float = 0.0
|
||||
currency: str = "USD"
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class PaymentResult:
|
||||
ok: bool
|
||||
external_id: str = ""
|
||||
message: str = ""
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class PaymentProviderPort(Protocol):
|
||||
async def create_checkout(
|
||||
self,
|
||||
*,
|
||||
organization_id: str,
|
||||
amount: float,
|
||||
currency: str,
|
||||
success_url: str,
|
||||
cancel_url: str,
|
||||
) -> CheckoutSession: ...
|
||||
|
||||
async def refund(
|
||||
self, *, external_payment_id: str, amount: float | None = None
|
||||
) -> PaymentResult: ...
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Sandbox provider port — the only path that may execute tenant code.
|
||||
|
||||
Implementations: ``LocalMockSandboxAdapter`` (default, executes nothing) and
|
||||
``DaytonaSandboxAdapter`` / ``E2BSandboxAdapter`` behind ``SANDBOX_PROVIDER``.
|
||||
|
||||
Every method that runs something takes an explicit timeout. Callers must never
|
||||
be able to request "no limit".
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class WorkspaceInfo:
|
||||
external_id: str
|
||||
status: str = "ready"
|
||||
raw: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class SessionInfo:
|
||||
external_id: str
|
||||
status: str = "running"
|
||||
raw: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class CommandResult:
|
||||
exit_code: int
|
||||
stdout: str = ""
|
||||
stderr: str = ""
|
||||
duration_ms: int = 0
|
||||
truncated: bool = False
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class SnapshotInfo:
|
||||
external_id: str
|
||||
name: str = ""
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class SandboxProviderPort(Protocol):
|
||||
async def create_workspace(
|
||||
self, *, organization_id: str, project_id: str, template_id: str, image: str
|
||||
) -> WorkspaceInfo: ...
|
||||
|
||||
async def start_session(self, *, workspace_external_id: str) -> SessionInfo: ...
|
||||
|
||||
async def run_command(
|
||||
self,
|
||||
*,
|
||||
session_external_id: str,
|
||||
command: str,
|
||||
timeout_seconds: int,
|
||||
max_output_bytes: int,
|
||||
env: dict[str, str] | None = None,
|
||||
) -> CommandResult: ...
|
||||
|
||||
async def read_file(self, *, session_external_id: str, path: str) -> str: ...
|
||||
|
||||
async def write_file(self, *, session_external_id: str, path: str, content: str) -> None: ...
|
||||
|
||||
async def snapshot(self, *, session_external_id: str, name: str) -> SnapshotInfo: ...
|
||||
|
||||
async def pause(self, *, session_external_id: str) -> None: ...
|
||||
|
||||
async def resume(self, *, session_external_id: str) -> SessionInfo: ...
|
||||
|
||||
async def destroy(self, *, session_external_id: str) -> None: ...
|
||||
|
||||
async def get_preview_url(self, *, session_external_id: str, port: int) -> str: ...
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Storage, vector store and notification ports."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class StoredObject:
|
||||
key: str
|
||||
size_bytes: int = 0
|
||||
url: str = ""
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class StoragePort(Protocol):
|
||||
async def put(self, *, key: str, data: bytes, content_type: str = "") -> StoredObject: ...
|
||||
|
||||
async def get(self, *, key: str) -> bytes: ...
|
||||
|
||||
async def delete(self, *, key: str) -> None: ...
|
||||
|
||||
async def signed_url(self, *, key: str, expires_seconds: int = 900) -> str: ...
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class VectorHit:
|
||||
chunk_id: str
|
||||
document_id: str
|
||||
content: str
|
||||
score: float = 0.0
|
||||
metadata: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class VectorStorePort(Protocol):
|
||||
async def upsert(
|
||||
self,
|
||||
*,
|
||||
organization_id: str,
|
||||
chunks: list[tuple[str, str, list[float]]],
|
||||
metadata: dict | None = None,
|
||||
) -> int: ...
|
||||
|
||||
async def search(
|
||||
self,
|
||||
*,
|
||||
organization_id: str,
|
||||
query: str,
|
||||
limit: int = 5,
|
||||
project_id: str | None = None,
|
||||
) -> list[VectorHit]: ...
|
||||
|
||||
async def delete_document(self, *, document_id: str) -> None: ...
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class Notification:
|
||||
kind: str
|
||||
subject: str
|
||||
body: str
|
||||
to: str = ""
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class NotificationPort(Protocol):
|
||||
async def send(self, *, notification: Notification) -> bool: ...
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Audit helper used by the HTTP layer.
|
||||
|
||||
Every sensitive operation calls this. It is deliberately cheap (one INSERT) so
|
||||
there is no excuse to skip it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.domain.audit.models import AuditEvent
|
||||
from app.infrastructure.repositories.audit import AuditRepository
|
||||
|
||||
|
||||
async def record_audit(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
action: str,
|
||||
organization_id: str | None = None,
|
||||
actor_user_id: str | None = None,
|
||||
target_type: str = "",
|
||||
target_id: str = "",
|
||||
metadata: dict | None = None,
|
||||
ip: str = "",
|
||||
user_agent: str = "",
|
||||
) -> AuditEvent:
|
||||
event = AuditEvent(
|
||||
action=action,
|
||||
organization_id=organization_id,
|
||||
actor_user_id=actor_user_id,
|
||||
target_type=target_type,
|
||||
target_id=target_id,
|
||||
metadata_json=metadata or {},
|
||||
ip_address=ip,
|
||||
user_agent=user_agent,
|
||||
)
|
||||
return await AuditRepository(session).record(event=event)
|
||||
@@ -0,0 +1,184 @@
|
||||
"""Billing use cases.
|
||||
|
||||
Design rule, stated once and enforced here: **the internal usage ledger is the
|
||||
source of truth.** A payment provider only moves money; it never decides what a
|
||||
tenant owes. That is why ``summary`` computes totals from ``UsageLedgerRepository``
|
||||
and not from any external invoice.
|
||||
|
||||
Credit is spent, not checked-then-spent: the debit is a single adjustment, so two
|
||||
concurrent runs cannot both pass a stale affordability check.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from app.application.ports import PaymentProviderPort
|
||||
from app.core.errors import NotFoundError, PermissionDeniedError, ValidationError
|
||||
from app.domain.billing.models import (
|
||||
CreditWallet,
|
||||
Invoice,
|
||||
Payment,
|
||||
PaymentStatus,
|
||||
Subscription,
|
||||
)
|
||||
from app.infrastructure.repositories.audit import UsageLedgerRepository
|
||||
from app.infrastructure.repositories.billing import (
|
||||
CreditWalletRepository,
|
||||
InvoiceRepository,
|
||||
PaymentRepository,
|
||||
PlanRepository,
|
||||
SubscriptionRepository,
|
||||
)
|
||||
|
||||
#: A tenant may not buy an arbitrary amount of credit in one action.
|
||||
MIN_TOPUP = 1.0
|
||||
MAX_TOPUP = 10_000.0
|
||||
|
||||
|
||||
class BillingService:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
wallets: CreditWalletRepository,
|
||||
invoices: InvoiceRepository,
|
||||
payments: PaymentRepository,
|
||||
plans: PlanRepository,
|
||||
subscriptions: SubscriptionRepository,
|
||||
ledger: UsageLedgerRepository,
|
||||
payment_provider: PaymentProviderPort,
|
||||
) -> None:
|
||||
self.wallets = wallets
|
||||
self.invoices = invoices
|
||||
self.payments = payments
|
||||
self.plans = plans
|
||||
self.subscriptions = subscriptions
|
||||
self.ledger = ledger
|
||||
self.payment_provider = payment_provider
|
||||
|
||||
# -- reads ---------------------------------------------------------------
|
||||
|
||||
async def wallet_for(self, organization_id: str) -> CreditWallet:
|
||||
return await self.wallets.ensure(organization_id)
|
||||
|
||||
async def summary(
|
||||
self,
|
||||
*,
|
||||
organization_id: str,
|
||||
since: datetime | None = None,
|
||||
until: datetime | None = None,
|
||||
) -> dict:
|
||||
wallet = await self.wallets.ensure(organization_id)
|
||||
breakdown = await self.ledger.breakdown(
|
||||
organization_id=organization_id, since=since, until=until
|
||||
)
|
||||
return {
|
||||
"balance": wallet.balance,
|
||||
"currency": wallet.currency,
|
||||
"low_balance": wallet.is_low,
|
||||
"low_balance_threshold": wallet.low_balance_threshold,
|
||||
"total_spend": breakdown.total_price,
|
||||
"by_unit": breakdown.by_unit,
|
||||
"by_employee": breakdown.by_employee,
|
||||
"by_project": breakdown.by_project,
|
||||
}
|
||||
|
||||
async def list_usage_events(
|
||||
self, *, organization_id: str, limit: int = 100, offset: int = 0
|
||||
) -> list:
|
||||
return await self.ledger.list_events(
|
||||
organization_id=organization_id, limit=limit, offset=offset
|
||||
)
|
||||
|
||||
async def list_invoices(self, organization_id: str) -> list[Invoice]:
|
||||
return await self.invoices.list_for_org(organization_id)
|
||||
|
||||
async def list_payments(self, organization_id: str) -> list[Payment]:
|
||||
return await self.payments.list_for_org(organization_id)
|
||||
|
||||
async def current_subscription(self, organization_id: str) -> Subscription | None:
|
||||
return await self.subscriptions.get_for_org(organization_id)
|
||||
|
||||
# -- writes --------------------------------------------------------------
|
||||
|
||||
async def add_credit(
|
||||
self, *, organization_id: str, amount: float, allow_negative: bool = False
|
||||
) -> CreditWallet:
|
||||
"""Grant (or, for a platform owner, claw back) credit.
|
||||
|
||||
``allow_negative`` is reserved for the platform owner console; an org
|
||||
admin may only ever add credit.
|
||||
"""
|
||||
if amount == 0:
|
||||
raise ValidationError("مقدار اعتبار نمیتواند صفر باشد.")
|
||||
if amount < 0 and not allow_negative:
|
||||
raise PermissionDeniedError("کسر اعتبار فقط توسط مالک سامانه انجام میشود.")
|
||||
if not allow_negative and amount > MAX_TOPUP:
|
||||
raise ValidationError(f"حداکثر افزایش اعتبار در هر عملیات {MAX_TOPUP} است.")
|
||||
|
||||
wallet = await self.wallets.adjust(organization_id=organization_id, delta=amount)
|
||||
if wallet.balance < 0:
|
||||
# Refuse to leave a wallet in debt; undo and surface the conflict.
|
||||
await self.wallets.adjust(organization_id=organization_id, delta=-amount)
|
||||
raise ValidationError("اعتبار کافی برای این کسر وجود ندارد.")
|
||||
return wallet
|
||||
|
||||
async def start_checkout(
|
||||
self,
|
||||
*,
|
||||
organization_id: str,
|
||||
amount: float,
|
||||
success_url: str,
|
||||
cancel_url: str,
|
||||
) -> tuple[Payment, str]:
|
||||
"""Create a pending payment plus the provider's hosted checkout URL."""
|
||||
if amount < MIN_TOPUP:
|
||||
raise ValidationError(f"حداقل مبلغ شارژ {MIN_TOPUP} است.")
|
||||
if amount > MAX_TOPUP:
|
||||
raise ValidationError(f"حداکثر مبلغ شارژ {MAX_TOPUP} است.")
|
||||
|
||||
wallet = await self.wallets.ensure(organization_id)
|
||||
payment = await self.payments.create(
|
||||
Payment(
|
||||
organization_id=organization_id,
|
||||
amount=amount,
|
||||
status=PaymentStatus.PENDING,
|
||||
provider="mock",
|
||||
)
|
||||
)
|
||||
session = await self.payment_provider.create_checkout(
|
||||
organization_id=organization_id,
|
||||
amount=amount,
|
||||
currency=wallet.currency,
|
||||
success_url=success_url,
|
||||
cancel_url=cancel_url,
|
||||
)
|
||||
updated = await self.payments.set_status(
|
||||
payment.id, PaymentStatus.PENDING, external_id=session.external_id
|
||||
)
|
||||
return (updated or payment), session.url
|
||||
|
||||
async def settle_payment(
|
||||
self, *, organization_id: str, payment_id: str, succeeded: bool
|
||||
) -> Payment:
|
||||
"""Mark a payment final and credit the wallet when it succeeded.
|
||||
|
||||
This is the only path that turns money into credit, so it is idempotent
|
||||
with respect to status: a payment that is already final is returned
|
||||
untouched rather than credited twice.
|
||||
"""
|
||||
payment = next(
|
||||
(p for p in await self.payments.list_for_org(organization_id) if p.id == payment_id),
|
||||
None,
|
||||
)
|
||||
if payment is None:
|
||||
raise NotFoundError("پرداخت یافت نشد.")
|
||||
if payment.status != PaymentStatus.PENDING:
|
||||
return payment
|
||||
|
||||
if succeeded:
|
||||
await self.wallets.adjust(organization_id=organization_id, delta=payment.amount)
|
||||
final = await self.payments.set_status(payment_id, PaymentStatus.SUCCEEDED)
|
||||
else:
|
||||
final = await self.payments.set_status(payment_id, PaymentStatus.FAILED)
|
||||
return final or payment
|
||||
@@ -0,0 +1,202 @@
|
||||
"""Knowledge Base use cases: ingest text, chunk it, and retrieve it safely.
|
||||
|
||||
Two things matter here:
|
||||
|
||||
* **Chunking is deterministic.** The same text always produces the same chunks,
|
||||
which makes ingestion idempotent-ish and the tests meaningful.
|
||||
* **Retrieval is permission-aware.** Search is always scoped to one organization,
|
||||
and optionally narrowed to one project. An org-wide source is visible to every
|
||||
project; a project-scoped source is not visible from another project.
|
||||
|
||||
Embeddings are computed through ``VectorStorePort`` when a real adapter is
|
||||
configured. With the mock adapter no vectors are produced and retrieval falls
|
||||
back to keyword matching — the same fallback the plan calls for at MVP.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.application.ports import VectorStorePort
|
||||
from app.core.errors import NotFoundError, ValidationError
|
||||
from app.domain.knowledge.models import (
|
||||
Document,
|
||||
DocumentChunk,
|
||||
EmbeddingJob,
|
||||
EmbeddingJobStatus,
|
||||
KnowledgeSource,
|
||||
RetrievalPolicy,
|
||||
SourceKind,
|
||||
)
|
||||
from app.infrastructure.repositories.knowledge import (
|
||||
DocumentChunkRepository,
|
||||
DocumentRepository,
|
||||
EmbeddingJobRepository,
|
||||
KnowledgeSourceRepository,
|
||||
RetrievalPolicyRepository,
|
||||
)
|
||||
|
||||
#: Chunk size in words. Small enough to keep a retrieved passage focused.
|
||||
CHUNK_WORDS = 180
|
||||
CHUNK_OVERLAP_WORDS = 30
|
||||
MAX_INGEST_CHARS = 400_000
|
||||
|
||||
|
||||
def chunk_text(
|
||||
text: str, *, size: int = CHUNK_WORDS, overlap: int = CHUNK_OVERLAP_WORDS
|
||||
) -> list[str]:
|
||||
"""Split text into overlapping word windows.
|
||||
|
||||
Pure and deterministic — no regex surprises, no dependence on punctuation.
|
||||
"""
|
||||
words = text.split()
|
||||
if not words:
|
||||
return []
|
||||
if size <= 0:
|
||||
raise ValueError("chunk size must be positive")
|
||||
step = max(1, size - max(0, overlap))
|
||||
|
||||
chunks: list[str] = []
|
||||
for start in range(0, len(words), step):
|
||||
window = words[start : start + size]
|
||||
if not window:
|
||||
break
|
||||
chunks.append(" ".join(window))
|
||||
if start + size >= len(words):
|
||||
break
|
||||
return chunks
|
||||
|
||||
|
||||
class KnowledgeService:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
sources: KnowledgeSourceRepository,
|
||||
documents: DocumentRepository,
|
||||
chunks: DocumentChunkRepository,
|
||||
jobs: EmbeddingJobRepository,
|
||||
policies: RetrievalPolicyRepository,
|
||||
vector_store: VectorStorePort,
|
||||
) -> None:
|
||||
self.sources = sources
|
||||
self.documents = documents
|
||||
self.chunks = chunks
|
||||
self.jobs = jobs
|
||||
self.policies = policies
|
||||
self.vector_store = vector_store
|
||||
|
||||
# -- ingestion -----------------------------------------------------------
|
||||
|
||||
async def create_text_source(
|
||||
self,
|
||||
*,
|
||||
organization_id: str,
|
||||
name: str,
|
||||
content: str,
|
||||
project_id: str | None = None,
|
||||
title: str = "",
|
||||
) -> tuple[KnowledgeSource, int]:
|
||||
if not name.strip():
|
||||
raise ValidationError("نام منبع دانش نمیتواند خالی باشد.")
|
||||
if not content.strip():
|
||||
raise ValidationError("محتوای منبع دانش نمیتواند خالی باشد.")
|
||||
if len(content) > MAX_INGEST_CHARS:
|
||||
raise ValidationError("حجم محتوا بیش از حد مجاز است.")
|
||||
|
||||
source = await self.sources.create(
|
||||
KnowledgeSource(
|
||||
organization_id=organization_id,
|
||||
name=name.strip(),
|
||||
kind=SourceKind.TEXT,
|
||||
project_id=project_id,
|
||||
)
|
||||
)
|
||||
document = await self.documents.create(
|
||||
Document(source_id=source.id, title=title.strip() or source.name, content=content)
|
||||
)
|
||||
|
||||
pieces = chunk_text(content)
|
||||
chunk_rows = [
|
||||
DocumentChunk(document_id=document.id, index=index, content=piece)
|
||||
for index, piece in enumerate(pieces)
|
||||
]
|
||||
created = await self.chunks.create_many(chunk_rows)
|
||||
|
||||
job = await self.jobs.create(EmbeddingJob(source_id=source.id))
|
||||
try:
|
||||
# The mock vector store is a no-op that accepts and discards; a real
|
||||
# adapter embeds here. Keyword search below works either way.
|
||||
await self.vector_store.upsert(
|
||||
organization_id=organization_id,
|
||||
chunks=[(chunk.id, chunk.content, []) for chunk in chunk_rows],
|
||||
metadata={"source_id": source.id, "project_id": project_id},
|
||||
)
|
||||
await self.jobs.set_status(job.id, EmbeddingJobStatus.DONE)
|
||||
except Exception as exc: # noqa: BLE001 - embedding is best-effort at MVP
|
||||
# Ingestion still succeeded; only the vector index is degraded, and
|
||||
# keyword search keeps working. Record why rather than failing hard.
|
||||
await self.jobs.set_status(job.id, EmbeddingJobStatus.FAILED, error=str(exc))
|
||||
|
||||
return source, created
|
||||
|
||||
# -- reads ---------------------------------------------------------------
|
||||
|
||||
async def list_sources(
|
||||
self, organization_id: str, *, limit: int = 100, offset: int = 0
|
||||
) -> list[KnowledgeSource]:
|
||||
return await self.sources.list_for_org(organization_id, limit=limit, offset=offset)
|
||||
|
||||
async def get_source_or_404(
|
||||
self, *, organization_id: str, source_id: str
|
||||
) -> KnowledgeSource:
|
||||
source = await self.sources.get_for_org(organization_id, source_id)
|
||||
if source is None:
|
||||
raise NotFoundError("منبع دانش یافت نشد.")
|
||||
return source
|
||||
|
||||
async def source_detail(self, *, source_id: str) -> dict:
|
||||
documents = await self.documents.list_for_source(source_id)
|
||||
return {
|
||||
"documents": documents,
|
||||
"chunk_count": await self.chunks.count_for_source(source_id),
|
||||
"job": await self.jobs.latest_for_source(source_id),
|
||||
}
|
||||
|
||||
async def chunk_counts(self, source_ids: list[str]) -> dict[str, int]:
|
||||
"""Chunk counts for many sources at once — see ``count_for_sources``."""
|
||||
return await self.chunks.count_for_sources(source_ids)
|
||||
|
||||
async def search(
|
||||
self,
|
||||
*,
|
||||
organization_id: str,
|
||||
query: str,
|
||||
project_id: str | None = None,
|
||||
limit: int = 5,
|
||||
) -> list[dict]:
|
||||
return await self.chunks.keyword_search(
|
||||
organization_id=organization_id,
|
||||
query=query,
|
||||
project_id=project_id,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
async def delete_source(self, *, organization_id: str, source_id: str) -> None:
|
||||
source = await self.get_source_or_404(
|
||||
organization_id=organization_id, source_id=source_id
|
||||
)
|
||||
await self.sources.delete(source.id)
|
||||
|
||||
# -- retrieval policies --------------------------------------------------
|
||||
|
||||
async def list_policies(self, organization_id: str) -> list[RetrievalPolicy]:
|
||||
return await self.policies.list_for_org(organization_id)
|
||||
|
||||
async def create_policy(
|
||||
self, *, organization_id: str, name: str, scope: dict | None = None
|
||||
) -> RetrievalPolicy:
|
||||
if not name.strip():
|
||||
raise ValidationError("نام سیاست بازیابی نمیتواند خالی باشد.")
|
||||
return await self.policies.create(
|
||||
RetrievalPolicy(
|
||||
organization_id=organization_id, name=name.strip(), scope_json=dict(scope or {})
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,243 @@
|
||||
"""Project and preview-domain use cases.
|
||||
|
||||
This is the application layer: it orchestrates domain rules, repositories and the
|
||||
driven ports (Git, domain router). It knows nothing about HTTP.
|
||||
|
||||
Security properties enforced here (not in the router, not only in the adapter):
|
||||
|
||||
* a project repository is always created **private**
|
||||
* the repository name is collision-free across tenants
|
||||
* the per-project Git password is encrypted before it is stored and never
|
||||
returned to a caller
|
||||
* a preview host must be a single label under the configured root domain
|
||||
* a preview host is unique platform-wide
|
||||
* the proxy target port must be allocatable and must not be a protected
|
||||
platform port
|
||||
* a project may hold at most ``max_subdomains_per_project`` hosts
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
|
||||
from app.application.ports import (
|
||||
DomainRouterPort,
|
||||
GitCredentials,
|
||||
GitProviderPort,
|
||||
RepoInfo,
|
||||
RepoStatus,
|
||||
)
|
||||
from app.core.config import settings
|
||||
from app.core.crypto import decrypt, encrypt
|
||||
from app.core.errors import ConflictError, NotFoundError, PermissionDeniedError, ValidationError
|
||||
from app.domain.base import ensure_unique_slug, slugify
|
||||
from app.domain.platform.rules import (
|
||||
assert_not_protected_port,
|
||||
assert_port_allocatable,
|
||||
build_host,
|
||||
validate_subdomain_label,
|
||||
)
|
||||
from app.domain.projects.models import (
|
||||
Project,
|
||||
ProjectGitCredential,
|
||||
ProjectGitRepo,
|
||||
)
|
||||
from app.infrastructure.repositories.projects import (
|
||||
PreviewDomainRepository,
|
||||
ProjectGitCredentialRepository,
|
||||
ProjectGitRepoRepository,
|
||||
ProjectRepository,
|
||||
)
|
||||
|
||||
|
||||
class ProjectService:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
projects: ProjectRepository,
|
||||
repos: ProjectGitRepoRepository,
|
||||
credentials: ProjectGitCredentialRepository,
|
||||
domains: PreviewDomainRepository,
|
||||
git: GitProviderPort,
|
||||
router: DomainRouterPort,
|
||||
) -> None:
|
||||
self.projects = projects
|
||||
self.repos = repos
|
||||
self.credentials = credentials
|
||||
self.domains = domains
|
||||
self.git = git
|
||||
self.router = router
|
||||
|
||||
# -- projects ------------------------------------------------------------
|
||||
|
||||
async def create_project(
|
||||
self,
|
||||
*,
|
||||
organization_id: str,
|
||||
organization_slug: str,
|
||||
created_by_user_id: str,
|
||||
name: str,
|
||||
description: str = "",
|
||||
) -> Project:
|
||||
slug = ensure_unique_slug(slugify(name), await self.projects.list_slugs(organization_id))
|
||||
return await self.projects.create(
|
||||
Project(
|
||||
organization_id=organization_id,
|
||||
name=name.strip(),
|
||||
slug=slug,
|
||||
description=description,
|
||||
created_by_user_id=created_by_user_id,
|
||||
)
|
||||
)
|
||||
|
||||
async def get_or_404(self, *, organization_id: str, project_id: str) -> Project:
|
||||
project = await self.projects.get(project_id)
|
||||
# Returning 404 (not 403) keeps the existence of other tenants' projects
|
||||
# out of the response.
|
||||
if project is None or project.organization_id != organization_id:
|
||||
raise NotFoundError("پروژه یافت نشد.")
|
||||
return project
|
||||
|
||||
@staticmethod
|
||||
def repo_name_for(*, organization_slug: str, project: Project) -> str:
|
||||
"""``orgslug-projectslug-shortid`` — stable and collision-free."""
|
||||
digest = hashlib.sha1(f"{organization_slug}:{project.id}".encode()).hexdigest()[:6]
|
||||
raw = f"{organization_slug}-{project.slug}-{digest}"
|
||||
return raw[:100].strip("-")
|
||||
|
||||
# -- git -----------------------------------------------------------------
|
||||
|
||||
async def ensure_repository(
|
||||
self, *, organization_slug: str, project: Project
|
||||
) -> ProjectGitRepo:
|
||||
repo_name = self.repo_name_for(organization_slug=organization_slug, project=project)
|
||||
info: RepoInfo = await self.git.ensure_project_repo(
|
||||
project_id=project.id,
|
||||
repo_name=repo_name,
|
||||
description=f"مخزن پروژهٔ {project.name}",
|
||||
)
|
||||
|
||||
credentials: GitCredentials = await self.git.ensure_credentials(
|
||||
project_id=project.id, owner=info.owner, repo_name=info.repo_name
|
||||
)
|
||||
await self.credentials.upsert(
|
||||
ProjectGitCredential(
|
||||
project_id=project.id,
|
||||
username=credentials.username,
|
||||
# Encrypted at rest. The plaintext only exists inside this method.
|
||||
encrypted_password=encrypt(credentials.password),
|
||||
)
|
||||
)
|
||||
|
||||
return await self.repos.upsert(
|
||||
ProjectGitRepo(
|
||||
project_id=project.id,
|
||||
provider=settings.git_provider,
|
||||
owner=info.owner,
|
||||
repo_name=info.repo_name,
|
||||
clone_url=info.clone_url,
|
||||
web_url=info.web_url,
|
||||
default_branch=info.default_branch,
|
||||
)
|
||||
)
|
||||
|
||||
async def repo_status(self, *, project: Project) -> RepoStatus:
|
||||
record = await self.repos.get_for_project(project.id)
|
||||
if record is None:
|
||||
return RepoStatus(exists=False)
|
||||
# Identity comes from our own record so the adapter stays stateless.
|
||||
return await self.git.get_repo_status(
|
||||
project_id=project.id, owner=record.owner, repo_name=record.repo_name
|
||||
)
|
||||
|
||||
async def clone_credentials(self, *, project: Project) -> tuple[str, str]:
|
||||
"""``(username, password)`` for a one-off clone.
|
||||
|
||||
Only the application layer may call this, and the router must never
|
||||
serialise the result.
|
||||
"""
|
||||
record = await self.credentials.get_for_project(project.id)
|
||||
if record is None:
|
||||
raise NotFoundError("اطلاعات دسترسی مخزن ثبت نشده است.")
|
||||
return record.username, decrypt(record.encrypted_password)
|
||||
|
||||
# -- preview domains -----------------------------------------------------
|
||||
|
||||
async def allocate_port(self, *, project: Project, service_name: str = "web") -> int:
|
||||
existing = await self.domains.get_port(project.id, service_name)
|
||||
if existing is not None:
|
||||
return existing
|
||||
|
||||
start, end = settings.preview_port_start, settings.preview_port_end
|
||||
used = await self.domains.list_used_ports(start=start, end=end)
|
||||
for port in range(start, end + 1):
|
||||
if port in used:
|
||||
continue
|
||||
try:
|
||||
assert_port_allocatable(port, start=start, end=end)
|
||||
except ValueError:
|
||||
continue
|
||||
await self.domains.add_port(
|
||||
organization_id=project.organization_id,
|
||||
project_id=project.id,
|
||||
service_name=service_name,
|
||||
port=port,
|
||||
)
|
||||
return port
|
||||
raise ConflictError("پورت آزادی در بازهٔ تعیینشده وجود ندارد.")
|
||||
|
||||
async def create_preview_domain(
|
||||
self,
|
||||
*,
|
||||
project: Project,
|
||||
requested_subdomain: str,
|
||||
created_by_user_id: str,
|
||||
port: int | None = None,
|
||||
) -> dict:
|
||||
label = validate_subdomain_label(requested_subdomain)
|
||||
host = build_host(label, settings.preview_root_domain)
|
||||
|
||||
if await self.domains.get_by_host(host) is not None:
|
||||
raise ConflictError(f"دامنهٔ {host} قبلاً ثبت شده است.")
|
||||
|
||||
count = await self.domains.count_for_project(project.id)
|
||||
if count >= settings.max_subdomains_per_project:
|
||||
raise ConflictError(
|
||||
f"هر پروژه حداکثر {settings.max_subdomains_per_project} دامنه میتواند داشته باشد."
|
||||
)
|
||||
|
||||
target_port = port if port is not None else await self.allocate_port(project=project)
|
||||
# A tenant must never be able to expose an internal platform service.
|
||||
assert_not_protected_port(target_port)
|
||||
|
||||
result = await self.router.configure_reverse_proxy(host=host, port=target_port)
|
||||
if not result.ok:
|
||||
raise ValidationError(result.message or "پیکربندی دامنه ناموفق بود.")
|
||||
|
||||
record = await self.domains.create(
|
||||
organization_id=project.organization_id,
|
||||
project_id=project.id,
|
||||
host=host,
|
||||
port=target_port,
|
||||
created_by_user_id=created_by_user_id,
|
||||
)
|
||||
record["url"] = result.url or f"https://{host}"
|
||||
return record
|
||||
|
||||
async def remove_preview_domain(self, *, project: Project, domain_id: str) -> None:
|
||||
record = await self.domains.get(domain_id)
|
||||
if record is None or record["project_id"] != project.id:
|
||||
raise NotFoundError("دامنه یافت نشد.")
|
||||
if record["protected"]:
|
||||
raise PermissionDeniedError(
|
||||
"این دامنه محافظتشده است و فقط مالک سامانه میتواند آن را تغییر دهد."
|
||||
)
|
||||
|
||||
await self.router.remove_preview_domain(domain_id=record["host"])
|
||||
await self.domains.delete(domain_id)
|
||||
|
||||
async def list_preview_domains(self, *, project: Project) -> list[dict]:
|
||||
records = await self.domains.list_for_project(project.id)
|
||||
for record in records:
|
||||
record["url"] = f"https://{record['host']}"
|
||||
return records
|
||||
@@ -0,0 +1,454 @@
|
||||
"""Sandbox Gateway use cases.
|
||||
|
||||
The security posture is the whole point of this module:
|
||||
|
||||
* **No host execution path.** The only way to run anything is through
|
||||
``SandboxProviderPort``. There is no fallback that shells out locally.
|
||||
* **Limits come from policy, never from the caller.** A request cannot ask for a
|
||||
longer timeout, more memory, or a bigger output cap than its scope allows.
|
||||
* **Egress is deny-by-default.** The stored ``network_policy`` defaults to
|
||||
``deny_all``; nothing here opens it implicitly.
|
||||
* **Every command is metered.** CPU seconds are written to the usage ledger, so
|
||||
sandbox time is billable and auditable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
from datetime import datetime
|
||||
|
||||
from app.application.ports import CommandResult, SandboxProviderPort, UsageRecord
|
||||
from app.core.config import settings
|
||||
from app.core.errors import ConflictError, NotFoundError, ProviderUnavailableError, ValidationError
|
||||
from app.domain.ai_control.models import RateUnit
|
||||
from app.domain.base import utcnow
|
||||
from app.domain.sandbox.models import (
|
||||
CommandRun,
|
||||
NetworkPolicy,
|
||||
PreviewUrl,
|
||||
SandboxProviderKind,
|
||||
SandboxResourceLimit,
|
||||
SandboxSession,
|
||||
SessionStatus,
|
||||
Snapshot,
|
||||
Workspace,
|
||||
WorkspaceStatus,
|
||||
)
|
||||
from app.infrastructure.repositories.ai_control import RateCardRepository
|
||||
from app.infrastructure.repositories.audit import UsageLedgerRepository
|
||||
from app.infrastructure.repositories.projects import ProjectRepository
|
||||
from app.infrastructure.repositories.sandbox import (
|
||||
CommandRunRepository,
|
||||
PreviewUrlRepository,
|
||||
ResourceLimitRepository,
|
||||
SandboxSessionRepository,
|
||||
SandboxTemplateRepository,
|
||||
SnapshotRepository,
|
||||
WorkspaceRepository,
|
||||
)
|
||||
|
||||
#: Hard ceiling, independent of policy. A policy may be lower, never higher.
|
||||
ABSOLUTE_MAX_TIMEOUT_SECONDS = 900
|
||||
ABSOLUTE_MAX_OUTPUT_BYTES = 2_000_000
|
||||
MAX_COMMAND_LENGTH = 4_000
|
||||
|
||||
|
||||
class SandboxService:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
templates: SandboxTemplateRepository,
|
||||
workspaces: WorkspaceRepository,
|
||||
sessions: SandboxSessionRepository,
|
||||
commands: CommandRunRepository,
|
||||
snapshots: SnapshotRepository,
|
||||
previews: PreviewUrlRepository,
|
||||
limits: ResourceLimitRepository,
|
||||
rate_cards: RateCardRepository,
|
||||
ledger: UsageLedgerRepository,
|
||||
projects: ProjectRepository,
|
||||
provider: SandboxProviderPort,
|
||||
) -> None:
|
||||
self.templates = templates
|
||||
self.workspaces = workspaces
|
||||
self.sessions = sessions
|
||||
self.commands = commands
|
||||
self.snapshots = snapshots
|
||||
self.previews = previews
|
||||
self.limits = limits
|
||||
self.rate_cards = rate_cards
|
||||
self.ledger = ledger
|
||||
self.projects = projects
|
||||
self.provider = provider
|
||||
|
||||
# -- limits --------------------------------------------------------------
|
||||
|
||||
async def effective_limits(
|
||||
self, *, organization_id: str, project_id: str | None = None
|
||||
) -> SandboxResourceLimit:
|
||||
"""Resolve the strictest applicable policy.
|
||||
|
||||
Project scope wins over organization scope; the absolute ceiling always
|
||||
wins over both.
|
||||
"""
|
||||
if project_id:
|
||||
scoped = await self.limits.get_for_scope("project", project_id)
|
||||
if scoped is not None:
|
||||
return self._clamp(scoped)
|
||||
|
||||
org_scoped = await self.limits.get_for_scope("organization", organization_id)
|
||||
if org_scoped is not None:
|
||||
return self._clamp(org_scoped)
|
||||
|
||||
return self._clamp(
|
||||
SandboxResourceLimit(
|
||||
scope="platform",
|
||||
scope_id=None,
|
||||
timeout_seconds=settings.sandbox_command_timeout_seconds,
|
||||
max_output_bytes=settings.sandbox_max_output_bytes,
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _clamp(limit: SandboxResourceLimit) -> SandboxResourceLimit:
|
||||
limit.timeout_seconds = max(
|
||||
1, min(int(limit.timeout_seconds), ABSOLUTE_MAX_TIMEOUT_SECONDS)
|
||||
)
|
||||
limit.max_output_bytes = max(
|
||||
1024, min(int(limit.max_output_bytes), ABSOLUTE_MAX_OUTPUT_BYTES)
|
||||
)
|
||||
limit.memory_mb = max(128, int(limit.memory_mb))
|
||||
limit.disk_mb = max(256, int(limit.disk_mb))
|
||||
return limit
|
||||
|
||||
# -- templates -----------------------------------------------------------
|
||||
|
||||
async def list_templates(self):
|
||||
return await self.templates.list()
|
||||
|
||||
# -- workspaces ----------------------------------------------------------
|
||||
|
||||
async def create_workspace(
|
||||
self, *, organization_id: str, project_id: str, template_id: str
|
||||
) -> Workspace:
|
||||
template = await self.templates.get(template_id)
|
||||
if template is None:
|
||||
raise NotFoundError("قالب سندباکس یافت نشد.")
|
||||
|
||||
provider_kind = str(template.provider) or SandboxProviderKind.MOCK
|
||||
try:
|
||||
info = await self.provider.create_workspace(
|
||||
organization_id=organization_id,
|
||||
project_id=project_id,
|
||||
template_id=template_id,
|
||||
image=template.image,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 - surface as a provider outage
|
||||
raise ProviderUnavailableError(
|
||||
"ساخت محیط اجرای ایزوله ناموفق بود."
|
||||
) from exc
|
||||
|
||||
workspace = await self.workspaces.create(
|
||||
Workspace(
|
||||
organization_id=organization_id,
|
||||
project_id=project_id,
|
||||
template_id=template_id,
|
||||
provider=provider_kind,
|
||||
external_workspace_id=info.external_id,
|
||||
status=WorkspaceStatus.READY,
|
||||
)
|
||||
)
|
||||
return workspace
|
||||
|
||||
async def list_workspaces(
|
||||
self,
|
||||
*,
|
||||
organization_id: str,
|
||||
project_id: str,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
) -> list[Workspace]:
|
||||
"""Workspaces for a project the caller's tenant actually owns."""
|
||||
project = await self.projects.get(project_id)
|
||||
# 404 rather than an empty list, so a foreign project id is
|
||||
# indistinguishable from a non-existent one.
|
||||
if project is None or project.organization_id != organization_id:
|
||||
raise NotFoundError("پروژه یافت نشد.")
|
||||
return await self.workspaces.list_for_project(
|
||||
project_id, limit=limit, offset=offset
|
||||
)
|
||||
|
||||
async def get_workspace_or_404(
|
||||
self, *, organization_id: str, workspace_id: str
|
||||
) -> Workspace:
|
||||
workspace = await self.workspaces.get_for_org(organization_id, workspace_id)
|
||||
if workspace is None:
|
||||
raise NotFoundError("محیط اجرا یافت نشد.")
|
||||
return workspace
|
||||
|
||||
# -- sessions ------------------------------------------------------------
|
||||
|
||||
async def start_session(
|
||||
self, *, organization_id: str, workspace_id: str
|
||||
) -> SandboxSession:
|
||||
workspace = await self.get_workspace_or_404(
|
||||
organization_id=organization_id, workspace_id=workspace_id
|
||||
)
|
||||
if workspace.status == WorkspaceStatus.DESTROYED:
|
||||
raise ConflictError("این محیط اجرا از بین رفته است.")
|
||||
if not workspace.external_workspace_id:
|
||||
raise ProviderUnavailableError("محیط اجرا در ارائهدهنده ثبت نشده است.")
|
||||
|
||||
info = await self.provider.start_session(
|
||||
workspace_external_id=workspace.external_workspace_id
|
||||
)
|
||||
session = await self.sessions.create(
|
||||
SandboxSession(
|
||||
workspace_id=workspace.id,
|
||||
external_session_id=info.external_id,
|
||||
status=SessionStatus.RUNNING,
|
||||
)
|
||||
)
|
||||
await self.workspaces.set_status(workspace.id, WorkspaceStatus.READY)
|
||||
return session
|
||||
|
||||
async def get_session_or_404(
|
||||
self, *, organization_id: str, session_id: str
|
||||
) -> SandboxSession:
|
||||
session = await self.sessions.get_for_org(organization_id, session_id)
|
||||
if session is None:
|
||||
raise NotFoundError("نشست اجرا یافت نشد.")
|
||||
return session
|
||||
|
||||
async def stop_session(self, *, organization_id: str, session_id: str) -> SandboxSession:
|
||||
session = await self.get_session_or_404(
|
||||
organization_id=organization_id, session_id=session_id
|
||||
)
|
||||
if not session.external_session_id:
|
||||
raise ProviderUnavailableError("نشست در ارائهدهنده ثبت نشده است.")
|
||||
await self.provider.destroy(session_external_id=session.external_session_id)
|
||||
await self.sessions.set_status(session.id, SessionStatus.STOPPED, stopped=True)
|
||||
refreshed = await self.sessions.get(session.id)
|
||||
return refreshed or session
|
||||
|
||||
# -- commands ------------------------------------------------------------
|
||||
|
||||
async def run_command(
|
||||
self,
|
||||
*,
|
||||
organization_id: str,
|
||||
session_id: str,
|
||||
command: str,
|
||||
user_id: str | None = None,
|
||||
) -> tuple[CommandRun, CommandResult]:
|
||||
session = await self.get_session_or_404(
|
||||
organization_id=organization_id, session_id=session_id
|
||||
)
|
||||
if not session.external_session_id:
|
||||
raise ProviderUnavailableError("نشست در ارائهدهنده ثبت نشده است.")
|
||||
if session.status != SessionStatus.RUNNING:
|
||||
raise ConflictError("نشست در وضعیت اجرا نیست.")
|
||||
|
||||
cleaned = command.strip()
|
||||
if not cleaned:
|
||||
raise ValidationError("دستور نمیتواند خالی باشد.")
|
||||
if len(cleaned) > MAX_COMMAND_LENGTH:
|
||||
raise ValidationError("دستور بیش از حد طولانی است.")
|
||||
|
||||
workspace = await self.workspaces.get_for_org(
|
||||
organization_id, session.workspace_id
|
||||
)
|
||||
limits = await self.effective_limits(
|
||||
organization_id=organization_id,
|
||||
project_id=workspace.project_id if workspace else None,
|
||||
)
|
||||
|
||||
result = await self.provider.run_command(
|
||||
session_external_id=session.external_session_id,
|
||||
command=cleaned,
|
||||
# Timeout and output cap are decided here, not by the caller.
|
||||
timeout_seconds=limits.timeout_seconds,
|
||||
max_output_bytes=limits.max_output_bytes,
|
||||
env={},
|
||||
)
|
||||
|
||||
run = await self.commands.create(
|
||||
CommandRun(
|
||||
session_id=session.id,
|
||||
command=cleaned,
|
||||
exit_code=result.exit_code,
|
||||
stdout_ref=result.stdout[: limits.max_output_bytes],
|
||||
stderr_ref=result.stderr[: limits.max_output_bytes],
|
||||
duration_ms=result.duration_ms,
|
||||
truncated=result.truncated,
|
||||
)
|
||||
)
|
||||
await self.sessions.set_status(session.id, SessionStatus.RUNNING)
|
||||
|
||||
# CPU seconds are billed. 1 second of wall time ~= 1 CPU second at MVP.
|
||||
cpu_seconds = max(0.0, result.duration_ms / 1000.0)
|
||||
if cpu_seconds > 0:
|
||||
card = await self.rate_cards.get_by_unit(RateUnit.SANDBOX_CPU_SECOND)
|
||||
unit_price = card.customer_price if card else 0.0
|
||||
internal_cost = card.internal_cost_estimate if card else 0.0
|
||||
await self.ledger.record(
|
||||
usage=UsageRecord(
|
||||
organization_id=organization_id,
|
||||
project_id=workspace.project_id if workspace else None,
|
||||
user_id=user_id,
|
||||
unit=RateUnit.SANDBOX_CPU_SECOND,
|
||||
quantity=cpu_seconds,
|
||||
unit_price=unit_price,
|
||||
internal_cost_estimate=cpu_seconds * internal_cost,
|
||||
total_price=cpu_seconds * unit_price,
|
||||
metadata_json={"session_id": session.id, "exit_code": result.exit_code},
|
||||
)
|
||||
)
|
||||
|
||||
return run, result
|
||||
|
||||
async def list_commands(self, *, session_id: str) -> list[CommandRun]:
|
||||
return await self.commands.list_for_session(session_id)
|
||||
|
||||
# -- files ---------------------------------------------------------------
|
||||
|
||||
async def read_file(
|
||||
self, *, organization_id: str, session_id: str, path: str
|
||||
) -> str:
|
||||
session = await self.get_session_or_404(
|
||||
organization_id=organization_id, session_id=session_id
|
||||
)
|
||||
if not session.external_session_id:
|
||||
raise ProviderUnavailableError("نشست در ارائهدهنده ثبت نشده است.")
|
||||
return await self.provider.read_file(
|
||||
session_external_id=session.external_session_id, path=path
|
||||
)
|
||||
|
||||
async def write_file(
|
||||
self, *, organization_id: str, session_id: str, path: str, content: str
|
||||
) -> None:
|
||||
session = await self.get_session_or_404(
|
||||
organization_id=organization_id, session_id=session_id
|
||||
)
|
||||
if not session.external_session_id:
|
||||
raise ProviderUnavailableError("نشست در ارائهدهنده ثبت نشده است.")
|
||||
await self.provider.write_file(
|
||||
session_external_id=session.external_session_id, path=path, content=content
|
||||
)
|
||||
|
||||
# -- snapshots & previews ------------------------------------------------
|
||||
|
||||
async def snapshot(
|
||||
self, *, organization_id: str, session_id: str, name: str
|
||||
) -> Snapshot:
|
||||
session = await self.get_session_or_404(
|
||||
organization_id=organization_id, session_id=session_id
|
||||
)
|
||||
if not session.external_session_id:
|
||||
raise ProviderUnavailableError("نشست در ارائهدهنده ثبت نشده است.")
|
||||
info = await self.provider.snapshot(
|
||||
session_external_id=session.external_session_id, name=name
|
||||
)
|
||||
return await self.snapshots.create(
|
||||
Snapshot(
|
||||
workspace_id=session.workspace_id, name=name,
|
||||
external_snapshot_id=info.external_id,
|
||||
)
|
||||
)
|
||||
|
||||
async def get_preview_url(
|
||||
self, *, organization_id: str, session_id: str, port: int
|
||||
) -> PreviewUrl:
|
||||
session = await self.get_session_or_404(
|
||||
organization_id=organization_id, session_id=session_id
|
||||
)
|
||||
if not session.external_session_id:
|
||||
raise ProviderUnavailableError("نشست در ارائهدهنده ثبت نشده است.")
|
||||
url = await self.provider.get_preview_url(
|
||||
session_external_id=session.external_session_id, port=port
|
||||
)
|
||||
return await self.previews.create(
|
||||
PreviewUrl(workspace_id=session.workspace_id, port=port, url=url)
|
||||
)
|
||||
|
||||
# -- policy administration ----------------------------------------------
|
||||
|
||||
async def set_limits(
|
||||
self,
|
||||
*,
|
||||
scope: str,
|
||||
scope_id: str | None,
|
||||
cpu: float,
|
||||
memory_mb: int,
|
||||
disk_mb: int,
|
||||
timeout_seconds: int,
|
||||
max_output_bytes: int,
|
||||
network_policy: NetworkPolicy,
|
||||
) -> SandboxResourceLimit:
|
||||
if scope not in ("organization", "project", "platform"):
|
||||
raise ValidationError("دامنهٔ محدودیت نامعتبر است.")
|
||||
return await self.limits.upsert(
|
||||
self._clamp(
|
||||
SandboxResourceLimit(
|
||||
scope=scope, scope_id=scope_id, cpu=cpu, memory_mb=memory_mb,
|
||||
disk_mb=disk_mb, timeout_seconds=timeout_seconds,
|
||||
max_output_bytes=max_output_bytes, network_policy=network_policy,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
async def destroy_workspace(
|
||||
self, *, organization_id: str, workspace_id: str
|
||||
) -> Workspace:
|
||||
workspace = await self.get_workspace_or_404(
|
||||
organization_id=organization_id, workspace_id=workspace_id
|
||||
)
|
||||
for session in await self.sessions.list_for_workspace(workspace.id):
|
||||
if session.external_session_id:
|
||||
# Teardown is best-effort: a remote sandbox that is already gone
|
||||
# must not block us from marking the local row stopped.
|
||||
with contextlib.suppress(Exception):
|
||||
await self.provider.destroy(
|
||||
session_external_id=session.external_session_id
|
||||
)
|
||||
await self.sessions.set_status(
|
||||
session.id, SessionStatus.STOPPED, stopped=True
|
||||
)
|
||||
await self.workspaces.set_status(workspace.id, WorkspaceStatus.DESTROYED)
|
||||
refreshed = await self.workspaces.get_for_org(organization_id, workspace_id)
|
||||
return refreshed or workspace
|
||||
|
||||
@staticmethod
|
||||
def now():
|
||||
return utcnow()
|
||||
|
||||
# -- platform maintenance ------------------------------------------------
|
||||
|
||||
async def reap_idle_sessions(
|
||||
self, *, idle_before: datetime, limit: int = 200
|
||||
) -> list[SandboxSession]:
|
||||
"""Stop sandbox sessions untouched since ``idle_before``.
|
||||
|
||||
**Deliberately not tenant-scoped.** This is the gateway's own housekeeping
|
||||
and spans every organization, so it is never reachable from a
|
||||
tenant-scoped route — only from the sandbox-gateway worker. Callers are
|
||||
responsible for the audit trail.
|
||||
|
||||
A provider that has already forgotten the session must not keep it
|
||||
"active" in our own record forever, so the remote teardown is
|
||||
best-effort and the local row is always marked stopped.
|
||||
"""
|
||||
stopped: list[SandboxSession] = []
|
||||
stale = await self.sessions.list_stale_active(
|
||||
idle_before=idle_before, limit=limit
|
||||
)
|
||||
for session in stale:
|
||||
if session.external_session_id:
|
||||
with contextlib.suppress(Exception):
|
||||
await self.provider.destroy(
|
||||
session_external_id=session.external_session_id
|
||||
)
|
||||
await self.sessions.set_status(session.id, SessionStatus.STOPPED, stopped=True)
|
||||
refreshed = await self.sessions.get(session.id)
|
||||
stopped.append(refreshed or session)
|
||||
return stopped
|
||||
@@ -0,0 +1,794 @@
|
||||
"""AI Workforce use cases: employees, Manager Chat, tasks and the run engine.
|
||||
|
||||
This module is the application layer. It owns three invariants that the rest of
|
||||
the system depends on:
|
||||
|
||||
1. **A tenant never sees model identity.** The service resolves a routing policy
|
||||
to a concrete ``AiModel`` internally and passes it to the provider port. No
|
||||
router may serialise that name back to a customer.
|
||||
2. **Every model call writes a usage event.** Billing reads the internal ledger,
|
||||
never the provider's invoice.
|
||||
3. **Every run leaves a timeline.** ``run_steps`` is the observable record of
|
||||
what happened, and it is what the UI renders.
|
||||
|
||||
The MVP "engine" is deliberately synchronous and deterministic: it drives a run
|
||||
through its real state machine (``RUN_TRANSITIONS``) and records each transition
|
||||
as a step. Swapping in a durable worker only changes *where* the transitions are
|
||||
executed, not the transitions themselves.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.application.ports import (
|
||||
ChatMessage,
|
||||
ModelProviderPort,
|
||||
UsageRecord,
|
||||
)
|
||||
from app.core.errors import (
|
||||
ConflictError,
|
||||
NotFoundError,
|
||||
PermissionDeniedError,
|
||||
ProviderUnavailableError,
|
||||
ValidationError,
|
||||
)
|
||||
from app.domain.ai_control.models import RateUnit, RoutingMode
|
||||
from app.domain.workforce.models import (
|
||||
TERMINAL_RUN_STATUSES,
|
||||
AgentTemplate,
|
||||
Artifact,
|
||||
Employee,
|
||||
EmployeeStatus,
|
||||
EmployeeToolPermission,
|
||||
Message,
|
||||
MessageRole,
|
||||
PromptVersion,
|
||||
RoleKey,
|
||||
Run,
|
||||
RunStatus,
|
||||
RunStep,
|
||||
Task,
|
||||
TaskStatus,
|
||||
Thread,
|
||||
ToolCall,
|
||||
ToolCallStatus,
|
||||
can_transition_run,
|
||||
)
|
||||
from app.domain.workforce.tools import (
|
||||
TOOL_CATALOG,
|
||||
ToolSpec,
|
||||
is_known_tool,
|
||||
tool_requires_approval,
|
||||
)
|
||||
from app.infrastructure.repositories.ai_control import (
|
||||
AiModelRepository,
|
||||
ModelCredentialRepository,
|
||||
RateCardRepository,
|
||||
RoutingPolicyRepository,
|
||||
)
|
||||
from app.infrastructure.repositories.audit import UsageLedgerRepository
|
||||
from app.infrastructure.repositories.workforce import (
|
||||
AgentTemplateRepository,
|
||||
ArtifactRepository,
|
||||
EmployeeRepository,
|
||||
EmployeeToolPermissionRepository,
|
||||
MessageRepository,
|
||||
PromptVersionRepository,
|
||||
RunRepository,
|
||||
RunStepRepository,
|
||||
TaskRepository,
|
||||
ThreadRepository,
|
||||
ToolCallRepository,
|
||||
)
|
||||
|
||||
#: Fallback price when the platform owner has not configured a rate card yet.
|
||||
#: Charging zero silently would hide a misconfiguration, so we use a tiny
|
||||
#: non-zero placeholder and mark it in the usage metadata.
|
||||
_UNPRICED = 0.0000001
|
||||
|
||||
_SYSTEM_PROMPT = (
|
||||
"تو دستیار هوش مصنوعی سازمان هستی. پاسخها را کوتاه، دقیق و عملی بده. "
|
||||
"هیچگاه نام مدل، ارائهدهنده یا جزئیات زیرساخت را فاش نکن."
|
||||
)
|
||||
|
||||
|
||||
class WorkforceService:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
employees: EmployeeRepository,
|
||||
templates: AgentTemplateRepository,
|
||||
prompts: PromptVersionRepository,
|
||||
permissions: EmployeeToolPermissionRepository,
|
||||
threads: ThreadRepository,
|
||||
messages: MessageRepository,
|
||||
tasks: TaskRepository,
|
||||
runs: RunRepository,
|
||||
run_steps: RunStepRepository,
|
||||
tool_calls: ToolCallRepository,
|
||||
artifacts: ArtifactRepository,
|
||||
models: AiModelRepository,
|
||||
credentials: ModelCredentialRepository,
|
||||
routing_policies: RoutingPolicyRepository,
|
||||
rate_cards: RateCardRepository,
|
||||
ledger: UsageLedgerRepository,
|
||||
provider: ModelProviderPort,
|
||||
) -> None:
|
||||
self.employees = employees
|
||||
self.templates = templates
|
||||
self.prompts = prompts
|
||||
self.permissions = permissions
|
||||
self.threads = threads
|
||||
self.messages = messages
|
||||
self.tasks = tasks
|
||||
self.runs = runs
|
||||
self.run_steps = run_steps
|
||||
self.tool_calls = tool_calls
|
||||
self.artifacts = artifacts
|
||||
self.models = models
|
||||
self.credentials = credentials
|
||||
self.routing_policies = routing_policies
|
||||
self.rate_cards = rate_cards
|
||||
self.ledger = ledger
|
||||
self.provider = provider
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Templates #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
async def list_templates(self) -> list[AgentTemplate]:
|
||||
return await self.templates.list()
|
||||
|
||||
async def list_routing_choices(self) -> list:
|
||||
"""Active routing policies, for customer-facing selection.
|
||||
|
||||
Callers must render only ``name``/``description`` — ``mode`` is a routing
|
||||
hint that should not reach a tenant.
|
||||
"""
|
||||
return [p for p in await self.routing_policies.list() if p.is_active]
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Employees #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
async def create_employee(
|
||||
self,
|
||||
*,
|
||||
organization_id: str,
|
||||
created_by_user_id: str,
|
||||
name: str,
|
||||
role_key: RoleKey,
|
||||
template_id: str | None = None,
|
||||
project_id: str | None = None,
|
||||
routing_policy_id: str | None = None,
|
||||
) -> Employee:
|
||||
template: AgentTemplate | None = None
|
||||
if template_id:
|
||||
template = await self.templates.get(template_id)
|
||||
if template is None:
|
||||
raise NotFoundError("قالب کارمند یافت نشد.")
|
||||
|
||||
employee = await self.employees.create(
|
||||
Employee(
|
||||
organization_id=organization_id,
|
||||
name=name.strip(),
|
||||
role_key=role_key,
|
||||
template_id=template_id,
|
||||
project_id=project_id,
|
||||
routing_policy_id=routing_policy_id
|
||||
or (template.default_routing_policy_id if template else None),
|
||||
)
|
||||
)
|
||||
|
||||
# Seed version 1 of the prompt from the template, so the employee is
|
||||
# usable immediately and every later edit is a tracked revision.
|
||||
if template is not None and template.default_prompt:
|
||||
await self.prompts.create(
|
||||
PromptVersion(
|
||||
employee_id=employee.id,
|
||||
version=1,
|
||||
content=template.default_prompt,
|
||||
created_by_user_id=created_by_user_id,
|
||||
)
|
||||
)
|
||||
|
||||
if template is not None:
|
||||
for tool_key in template.default_tools_json:
|
||||
await self.permissions.set_permission(
|
||||
employee_id=employee.id, tool_key=tool_key, allowed=True
|
||||
)
|
||||
|
||||
return employee
|
||||
|
||||
async def list_employees(
|
||||
self, organization_id: str, *, limit: int = 100, offset: int = 0
|
||||
) -> list[Employee]:
|
||||
return await self.employees.list_for_org(organization_id, limit=limit, offset=offset)
|
||||
|
||||
async def get_employee_or_404(
|
||||
self, *, organization_id: str, employee_id: str
|
||||
) -> Employee:
|
||||
employee = await self.employees.get_for_org(organization_id, employee_id)
|
||||
# 404 rather than 403: another tenant's id must be indistinguishable
|
||||
# from a non-existent one.
|
||||
if employee is None:
|
||||
raise NotFoundError("کارمند یافت نشد.")
|
||||
return employee
|
||||
|
||||
async def update_employee(
|
||||
self,
|
||||
*,
|
||||
organization_id: str,
|
||||
employee_id: str,
|
||||
name: str | None = None,
|
||||
status: EmployeeStatus | None = None,
|
||||
project_id: str | None = None,
|
||||
routing_policy_id: str | None = None,
|
||||
) -> Employee:
|
||||
employee = await self.get_employee_or_404(
|
||||
organization_id=organization_id, employee_id=employee_id
|
||||
)
|
||||
if name is not None:
|
||||
employee.name = name.strip()
|
||||
if status is not None:
|
||||
employee.status = status
|
||||
if project_id is not None:
|
||||
employee.project_id = project_id or None
|
||||
if routing_policy_id is not None:
|
||||
employee.routing_policy_id = routing_policy_id or None
|
||||
return await self.employees.update(employee)
|
||||
|
||||
async def list_prompt_versions(self, employee_id: str) -> list[PromptVersion]:
|
||||
return await self.prompts.list_for_employee(employee_id)
|
||||
|
||||
async def add_prompt_version(
|
||||
self, *, employee_id: str, content: str, created_by_user_id: str
|
||||
) -> PromptVersion:
|
||||
if not content.strip():
|
||||
raise ValidationError("متن پرامپت نمیتواند خالی باشد.")
|
||||
return await self.prompts.create(
|
||||
PromptVersion(
|
||||
employee_id=employee_id,
|
||||
version=await self.prompts.next_version_number(employee_id),
|
||||
content=content,
|
||||
created_by_user_id=created_by_user_id,
|
||||
)
|
||||
)
|
||||
|
||||
async def list_tool_permissions(self, employee_id: str) -> list[EmployeeToolPermission]:
|
||||
return await self.permissions.list_for_employee(employee_id)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Threads & messages #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
async def create_thread(
|
||||
self,
|
||||
*,
|
||||
organization_id: str,
|
||||
created_by_user_id: str,
|
||||
title: str = "",
|
||||
project_id: str | None = None,
|
||||
) -> Thread:
|
||||
return await self.threads.create(
|
||||
Thread(
|
||||
organization_id=organization_id,
|
||||
title=title.strip(),
|
||||
project_id=project_id,
|
||||
created_by_user_id=created_by_user_id,
|
||||
)
|
||||
)
|
||||
|
||||
async def list_threads(
|
||||
self, organization_id: str, *, limit: int = 100, offset: int = 0
|
||||
) -> list[Thread]:
|
||||
return await self.threads.list_for_org(organization_id, limit=limit, offset=offset)
|
||||
|
||||
async def get_thread_or_404(self, *, organization_id: str, thread_id: str) -> Thread:
|
||||
thread = await self.threads.get_for_org(organization_id, thread_id)
|
||||
if thread is None:
|
||||
raise NotFoundError("گفتگو یافت نشد.")
|
||||
return thread
|
||||
|
||||
async def list_messages(self, thread_id: str) -> list[Message]:
|
||||
return await self.messages.list_for_thread(thread_id)
|
||||
|
||||
async def post_user_message(
|
||||
self,
|
||||
*,
|
||||
organization_id: str,
|
||||
thread_id: str,
|
||||
content: str,
|
||||
user_id: str,
|
||||
employee_id: str | None = None,
|
||||
) -> tuple[Message, Message]:
|
||||
"""Append the user's message, then produce the assistant reply.
|
||||
|
||||
Returns ``(user_message, assistant_message)`` so the caller can render
|
||||
both without a second round-trip.
|
||||
"""
|
||||
thread = await self.get_thread_or_404(
|
||||
organization_id=organization_id, thread_id=thread_id
|
||||
)
|
||||
if not content.strip():
|
||||
raise ValidationError("متن پیام نمیتواند خالی باشد.")
|
||||
|
||||
user_message = await self.messages.create(
|
||||
Message(thread_id=thread.id, role=MessageRole.USER, content=content)
|
||||
)
|
||||
|
||||
employee: Employee | None = None
|
||||
if employee_id:
|
||||
employee = await self.get_employee_or_404(
|
||||
organization_id=organization_id, employee_id=employee_id
|
||||
)
|
||||
else:
|
||||
employee = await self._default_manager(organization_id)
|
||||
|
||||
history = await self.messages.list_for_thread(thread.id)
|
||||
system_prompt = await self._system_prompt_for(employee)
|
||||
reply = await self._complete(
|
||||
organization_id=organization_id,
|
||||
employee=employee,
|
||||
project_id=thread.project_id,
|
||||
user_id=user_id,
|
||||
system=system_prompt,
|
||||
messages=[
|
||||
ChatMessage(role=m.role.value, content=m.content)
|
||||
for m in history
|
||||
if m.role in (MessageRole.USER, MessageRole.ASSISTANT)
|
||||
],
|
||||
)
|
||||
|
||||
assistant_message = await self.messages.create(
|
||||
Message(
|
||||
thread_id=thread.id,
|
||||
role=MessageRole.ASSISTANT,
|
||||
content=reply,
|
||||
employee_id=employee.id if employee else None,
|
||||
)
|
||||
)
|
||||
return user_message, assistant_message
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Tasks #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
async def create_task(
|
||||
self,
|
||||
*,
|
||||
organization_id: str,
|
||||
created_by_user_id: str,
|
||||
title: str,
|
||||
description: str = "",
|
||||
project_id: str | None = None,
|
||||
thread_id: str | None = None,
|
||||
assigned_employee_id: str | None = None,
|
||||
) -> Task:
|
||||
if not title.strip():
|
||||
raise ValidationError("عنوان کار نمیتواند خالی باشد.")
|
||||
if assigned_employee_id:
|
||||
# Validates tenant ownership before we ever store the reference.
|
||||
await self.get_employee_or_404(
|
||||
organization_id=organization_id, employee_id=assigned_employee_id
|
||||
)
|
||||
return await self.tasks.create(
|
||||
Task(
|
||||
organization_id=organization_id,
|
||||
title=title.strip(),
|
||||
description=description,
|
||||
project_id=project_id,
|
||||
thread_id=thread_id,
|
||||
assigned_employee_id=assigned_employee_id,
|
||||
created_by_user_id=created_by_user_id,
|
||||
)
|
||||
)
|
||||
|
||||
async def list_tasks(
|
||||
self,
|
||||
organization_id: str,
|
||||
*,
|
||||
status: TaskStatus | None = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
) -> list[Task]:
|
||||
return await self.tasks.list_for_org(
|
||||
organization_id, status=status, limit=limit, offset=offset
|
||||
)
|
||||
|
||||
async def get_task_or_404(self, *, organization_id: str, task_id: str) -> Task:
|
||||
task = await self.tasks.get_for_org(organization_id, task_id)
|
||||
if task is None:
|
||||
raise NotFoundError("کار یافت نشد.")
|
||||
return task
|
||||
|
||||
async def list_task_runs(self, task_id: str) -> list[Run]:
|
||||
return await self.runs.list_for_task(task_id)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Runs #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
async def start_run(
|
||||
self,
|
||||
*,
|
||||
organization_id: str,
|
||||
task_id: str,
|
||||
user_id: str,
|
||||
employee_id: str | None = None,
|
||||
) -> Run:
|
||||
task = await self.get_task_or_404(organization_id=organization_id, task_id=task_id)
|
||||
if task.status in (TaskStatus.DONE, TaskStatus.CANCELLED):
|
||||
raise ConflictError("این کار قبلاً بسته شده است.")
|
||||
|
||||
employee: Employee | None = None
|
||||
if employee_id or task.assigned_employee_id:
|
||||
employee = await self.get_employee_or_404(
|
||||
organization_id=organization_id,
|
||||
employee_id=employee_id or task.assigned_employee_id or "",
|
||||
)
|
||||
if not employee.is_available:
|
||||
raise ConflictError("این کارمند در حالت فعال نیست.")
|
||||
else:
|
||||
employee = await self._default_manager(organization_id)
|
||||
|
||||
run = await self.runs.create(
|
||||
Run(task_id=task.id, employee_id=employee.id if employee else None)
|
||||
)
|
||||
await self.tasks.set_status(task.id, TaskStatus.RUNNING)
|
||||
|
||||
await self.run_steps.append(
|
||||
run_id=run.id, kind="queued", title="کار در صف اجرا قرار گرفت.",
|
||||
payload={"task_id": task.id},
|
||||
)
|
||||
|
||||
try:
|
||||
run = await self._advance(run, RunStatus.PLANNING)
|
||||
await self.run_steps.append(
|
||||
run_id=run.id, kind="planning", title="برنامهریزی مرحلهٔ اجرا."
|
||||
)
|
||||
|
||||
run = await self._advance(run, RunStatus.RUNNING)
|
||||
await self.run_steps.append(
|
||||
run_id=run.id, kind="running", title="اجرای کار آغاز شد."
|
||||
)
|
||||
|
||||
reply = await self._complete(
|
||||
organization_id=organization_id,
|
||||
employee=employee,
|
||||
project_id=task.project_id,
|
||||
user_id=user_id,
|
||||
run_id=run.id,
|
||||
system=await self._system_prompt_for(employee),
|
||||
messages=[
|
||||
ChatMessage(role="user", content=self._task_brief(task)),
|
||||
],
|
||||
)
|
||||
await self.run_steps.append(
|
||||
run_id=run.id, kind="output", title="خروجی آماده شد.",
|
||||
payload={"chars": len(reply)},
|
||||
)
|
||||
|
||||
artifact = await self.artifacts.create(
|
||||
Artifact(
|
||||
organization_id=organization_id,
|
||||
project_id=task.project_id,
|
||||
run_id=run.id,
|
||||
name=f"خروجی {task.title}"[:200],
|
||||
kind="text",
|
||||
content_ref=reply[:8000],
|
||||
size_bytes=len(reply.encode("utf-8")),
|
||||
)
|
||||
)
|
||||
await self.run_steps.append(
|
||||
run_id=run.id, kind="artifact", title="سند خروجی ساخته شد.",
|
||||
payload={"artifact_id": artifact.id},
|
||||
)
|
||||
|
||||
run = await self._finish(run, RunStatus.COMPLETED)
|
||||
await self.tasks.set_status(task.id, TaskStatus.DONE)
|
||||
except ProviderUnavailableError as exc:
|
||||
run = await self._finish(run, RunStatus.FAILED, error=str(exc))
|
||||
await self.tasks.set_status(task.id, TaskStatus.FAILED)
|
||||
await self.run_steps.append(
|
||||
run_id=run.id, kind="error", title="اجرا ناموفق بود.",
|
||||
payload={"error": str(exc)},
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 - the run must always terminate
|
||||
run = await self._finish(run, RunStatus.FAILED, error=str(exc))
|
||||
await self.tasks.set_status(task.id, TaskStatus.FAILED)
|
||||
await self.run_steps.append(
|
||||
run_id=run.id, kind="error", title="خطای غیرمنتظره در اجرا.",
|
||||
payload={"error": str(exc)},
|
||||
)
|
||||
|
||||
return run
|
||||
|
||||
async def get_run_or_404(
|
||||
self, *, organization_id: str, run_id: str
|
||||
) -> tuple[Run, list[RunStep], list[ToolCall], list[Artifact]]:
|
||||
run = await self.runs.get(run_id)
|
||||
if run is None:
|
||||
raise NotFoundError("اجرا یافت نشد.")
|
||||
task = await self.tasks.get(run.task_id)
|
||||
# Re-scope through the task, which is the object that carries the tenant.
|
||||
if task is None or task.organization_id != organization_id:
|
||||
raise NotFoundError("اجرا یافت نشد.")
|
||||
return (
|
||||
run,
|
||||
await self.run_steps.list_for_run(run.id),
|
||||
await self.tool_calls.list_for_run(run.id),
|
||||
await self.artifacts.list_for_run(run.id),
|
||||
)
|
||||
|
||||
async def cancel_run(self, *, organization_id: str, run_id: str) -> Run:
|
||||
run, _, _, _ = await self.get_run_or_404(
|
||||
organization_id=organization_id, run_id=run_id
|
||||
)
|
||||
if run.status in TERMINAL_RUN_STATUSES:
|
||||
raise ConflictError("این اجرا در وضعیت پایانی است و لغو نمیشود.")
|
||||
if not can_transition_run(run.status, RunStatus.CANCELLED):
|
||||
raise ConflictError("از وضعیت فعلی امکان لغو وجود ندارد.")
|
||||
|
||||
run = await self._finish(run, RunStatus.CANCELLED)
|
||||
await self.run_steps.append(
|
||||
run_id=run.id, kind="cancelled", title="اجرا توسط کاربر لغو شد."
|
||||
)
|
||||
task = await self.tasks.get(run.task_id)
|
||||
if task is not None and task.status not in (TaskStatus.DONE, TaskStatus.CANCELLED):
|
||||
await self.tasks.set_status(task.id, TaskStatus.CANCELLED)
|
||||
return run
|
||||
|
||||
async def list_pending_approvals(self, organization_id: str) -> list[ToolCall]:
|
||||
return await self.tool_calls.list_pending_approvals_for_org(organization_id)
|
||||
|
||||
async def list_artifacts(self, organization_id: str) -> list[Artifact]:
|
||||
return await self.artifacts.list_for_org(organization_id)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Tools & approvals #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@staticmethod
|
||||
def tool_catalog() -> tuple[ToolSpec, ...]:
|
||||
return TOOL_CATALOG
|
||||
|
||||
async def set_tool_permission(
|
||||
self,
|
||||
*,
|
||||
organization_id: str,
|
||||
employee_id: str,
|
||||
tool_key: str,
|
||||
allowed: bool,
|
||||
) -> EmployeeToolPermission:
|
||||
"""Grant or revoke one tool for one employee.
|
||||
|
||||
An unknown tool key is rejected rather than stored: a permission row for
|
||||
a tool that does not exist is a silent no-op that hides a typo.
|
||||
"""
|
||||
await self.get_employee_or_404(
|
||||
organization_id=organization_id, employee_id=employee_id
|
||||
)
|
||||
if not is_known_tool(tool_key):
|
||||
raise ValidationError(f"ابزار ناشناخته: {tool_key}")
|
||||
return await self.permissions.set_permission(
|
||||
employee_id=employee_id, tool_key=tool_key, allowed=allowed
|
||||
)
|
||||
|
||||
async def request_tool_call(
|
||||
self,
|
||||
*,
|
||||
organization_id: str,
|
||||
run_id: str,
|
||||
employee_id: str | None,
|
||||
tool_key: str,
|
||||
arguments: dict | None = None,
|
||||
) -> ToolCall:
|
||||
"""Record an intended tool call, gating it on permission and approval.
|
||||
|
||||
The call is persisted in every case so the audit trail shows what was
|
||||
attempted, not only what succeeded.
|
||||
"""
|
||||
run, _, _, _ = await self.get_run_or_404(
|
||||
organization_id=organization_id, run_id=run_id
|
||||
)
|
||||
|
||||
if not is_known_tool(tool_key):
|
||||
raise ValidationError(f"ابزار ناشناخته: {tool_key}")
|
||||
|
||||
# Deny by default. An employee with no permission row cannot call a tool.
|
||||
if employee_id is None or not await self.permissions.is_allowed(
|
||||
employee_id, tool_key
|
||||
):
|
||||
raise PermissionDeniedError(
|
||||
f"کارمند مجاز به استفاده از «{tool_key}» نیست."
|
||||
)
|
||||
|
||||
needs_approval = tool_requires_approval(tool_key)
|
||||
call = await self.tool_calls.create(
|
||||
ToolCall(
|
||||
run_id=run.id,
|
||||
tool_key=tool_key,
|
||||
arguments_json=dict(arguments or {}),
|
||||
status=(
|
||||
ToolCallStatus.AWAITING_APPROVAL
|
||||
if needs_approval
|
||||
else ToolCallStatus.PENDING
|
||||
),
|
||||
requires_approval=needs_approval,
|
||||
)
|
||||
)
|
||||
await self.run_steps.append(
|
||||
run_id=run.id,
|
||||
kind="tool_call",
|
||||
title=(
|
||||
f"درخواست تأیید برای «{tool_key}»"
|
||||
if needs_approval
|
||||
else f"فراخوانی «{tool_key}»"
|
||||
),
|
||||
payload={"tool_call_id": call.id, "requires_approval": needs_approval},
|
||||
)
|
||||
return call
|
||||
|
||||
async def decide_approval(
|
||||
self,
|
||||
*,
|
||||
organization_id: str,
|
||||
tool_call_id: str,
|
||||
approve: bool,
|
||||
decided_by_user_id: str,
|
||||
note: str = "",
|
||||
) -> ToolCall:
|
||||
"""Approve or reject a pending tool call. Only a human may call this."""
|
||||
call = await self.tool_calls.get_for_org(organization_id, tool_call_id)
|
||||
if call is None:
|
||||
raise NotFoundError("درخواست تأیید یافت نشد.")
|
||||
if call.status != ToolCallStatus.AWAITING_APPROVAL:
|
||||
raise ConflictError("این درخواست در وضعیت انتظار تأیید نیست.")
|
||||
|
||||
result = dict(call.result_json)
|
||||
if note:
|
||||
result["note"] = note
|
||||
|
||||
updated = await self.tool_calls.set_status(
|
||||
call.id,
|
||||
ToolCallStatus.APPROVED if approve else ToolCallStatus.REJECTED,
|
||||
result=result,
|
||||
approved_by_user_id=decided_by_user_id,
|
||||
)
|
||||
await self.run_steps.append(
|
||||
run_id=call.run_id,
|
||||
kind="approval",
|
||||
title=("درخواست تأیید شد." if approve else "درخواست رد شد."),
|
||||
payload={"tool_call_id": call.id, "tool_key": call.tool_key},
|
||||
)
|
||||
return updated or call
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Internals #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
async def _default_manager(self, organization_id: str) -> Employee | None:
|
||||
"""The employee that answers when the caller did not pick one."""
|
||||
for employee in await self.employees.list_for_org(organization_id):
|
||||
if employee.role_key == RoleKey.MANAGER and employee.is_available:
|
||||
return employee
|
||||
return None
|
||||
|
||||
async def _system_prompt_for(self, employee: Employee | None) -> str:
|
||||
if employee is None:
|
||||
return _SYSTEM_PROMPT
|
||||
latest = await self.prompts.latest(employee.id)
|
||||
if latest is None:
|
||||
return _SYSTEM_PROMPT
|
||||
return f"{_SYSTEM_PROMPT}\n\n{latest.content}"
|
||||
|
||||
async def _resolve_model(self, employee: Employee | None):
|
||||
"""Map the employee's routing policy to a concrete model. Never exposed."""
|
||||
mode = RoutingMode.BALANCED
|
||||
policy_id = employee.routing_policy_id if employee else None
|
||||
policy = (
|
||||
await self.routing_policies.get(policy_id)
|
||||
if policy_id
|
||||
else await self.routing_policies.get_default()
|
||||
)
|
||||
if policy is not None:
|
||||
mode = policy.mode
|
||||
|
||||
model = await self.models.pick_for_mode(mode)
|
||||
if model is None:
|
||||
raise ProviderUnavailableError(
|
||||
"هیچ مدل فعالی برای مسیریابی پیکربندی نشده است."
|
||||
)
|
||||
return model
|
||||
|
||||
async def _complete(
|
||||
self,
|
||||
*,
|
||||
organization_id: str,
|
||||
employee: Employee | None,
|
||||
project_id: str | None,
|
||||
user_id: str | None,
|
||||
system: str,
|
||||
messages: list[ChatMessage],
|
||||
run_id: str | None = None,
|
||||
) -> str:
|
||||
"""Run one completion and record its usage. The single model entry point."""
|
||||
model = await self._resolve_model(employee)
|
||||
credential = await self.credentials.get_active_for_provider(model.provider_id)
|
||||
|
||||
response = await self.provider.complete(
|
||||
model=model, credential=credential, messages=messages, system=system
|
||||
)
|
||||
|
||||
await self._record_usage(
|
||||
organization_id=organization_id,
|
||||
project_id=project_id,
|
||||
user_id=user_id,
|
||||
employee_id=employee.id if employee else None,
|
||||
run_id=run_id,
|
||||
unit=RateUnit.MODEL_INPUT_TOKENS,
|
||||
quantity=float(response.input_tokens),
|
||||
)
|
||||
await self._record_usage(
|
||||
organization_id=organization_id,
|
||||
project_id=project_id,
|
||||
user_id=user_id,
|
||||
employee_id=employee.id if employee else None,
|
||||
run_id=run_id,
|
||||
unit=RateUnit.MODEL_OUTPUT_TOKENS,
|
||||
quantity=float(response.output_tokens),
|
||||
)
|
||||
return response.content
|
||||
|
||||
async def _record_usage(
|
||||
self,
|
||||
*,
|
||||
organization_id: str,
|
||||
unit: RateUnit,
|
||||
quantity: float,
|
||||
project_id: str | None = None,
|
||||
user_id: str | None = None,
|
||||
employee_id: str | None = None,
|
||||
run_id: str | None = None,
|
||||
) -> None:
|
||||
card = await self.rate_cards.get_by_unit(unit)
|
||||
unit_price = card.customer_price if card else _UNPRICED
|
||||
internal_cost = card.internal_cost_estimate if card else 0.0
|
||||
await self.ledger.record(
|
||||
usage=UsageRecord(
|
||||
organization_id=organization_id,
|
||||
project_id=project_id,
|
||||
user_id=user_id,
|
||||
employee_id=employee_id,
|
||||
run_id=run_id,
|
||||
unit=unit,
|
||||
quantity=quantity,
|
||||
unit_price=unit_price,
|
||||
internal_cost_estimate=quantity * internal_cost,
|
||||
total_price=quantity * unit_price,
|
||||
metadata_json={"priced": card is not None},
|
||||
)
|
||||
)
|
||||
|
||||
async def _advance(self, run: Run, target: RunStatus) -> Run:
|
||||
if not can_transition_run(run.status, target):
|
||||
raise ConflictError(f"گذار نامعتبر از {run.status} به {target}.")
|
||||
return await self.runs.set_status(run.id, target) or run
|
||||
|
||||
async def _finish(self, run: Run, target: RunStatus, *, error: str | None = None) -> Run:
|
||||
from app.domain.base import utcnow
|
||||
|
||||
if not can_transition_run(run.status, target):
|
||||
# Already terminal — return the current row untouched.
|
||||
return await self.runs.get(run.id) or run
|
||||
updated = await self.runs.set_status(
|
||||
run.id, target, error=error, finished_at=utcnow()
|
||||
)
|
||||
return updated or run
|
||||
|
||||
@staticmethod
|
||||
def _task_brief(task: Task) -> str:
|
||||
parts = [f"عنوان کار: {task.title}"]
|
||||
if task.description:
|
||||
parts.append(f"توضیحات: {task.description}")
|
||||
return "\n".join(parts)
|
||||
@@ -0,0 +1 @@
|
||||
"""Cross-cutting concerns: configuration, database, security, crypto, errors."""
|
||||
@@ -0,0 +1,152 @@
|
||||
"""Application configuration.
|
||||
|
||||
All external providers default to ``mock`` so the whole stack runs with zero
|
||||
credentials. Real adapters stay behind the ``*_PROVIDER`` switches.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import model_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
# web/apps/api/app/core/config.py -> web/
|
||||
WEB_ROOT = Path(__file__).resolve().parents[4]
|
||||
|
||||
#: Values that are obviously placeholders. Never acceptable in production.
|
||||
_INSECURE_SECRETS = frozenset(
|
||||
{"", "change-me", "changeme", "secret", "test", "dev", "password"}
|
||||
)
|
||||
#: HS256 signs with a 256-bit key; anything shorter weakens the signature.
|
||||
MIN_SECRET_LENGTH = 32
|
||||
|
||||
#: The development CORS allowlist. ``localhost`` and ``127.0.0.1`` are distinct
|
||||
#: browser origins, so both spellings are listed; leaving one out makes the dev
|
||||
#: UI fail with an opaque "Disallowed CORS origin".
|
||||
DEVELOPMENT_CORS_ORIGINS = "http://localhost:3000,http://127.0.0.1:3000"
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=(WEB_ROOT / ".env", WEB_ROOT / ".env.local"),
|
||||
env_file_encoding="utf-8",
|
||||
extra="ignore",
|
||||
case_sensitive=False,
|
||||
)
|
||||
|
||||
# --- app -----------------------------------------------------------------
|
||||
app_env: str = "development"
|
||||
app_name: str = "AGY Web"
|
||||
app_url: str = "http://localhost:3000"
|
||||
api_url: str = "http://localhost:8000"
|
||||
#: Both loopback spellings, on purpose. ``localhost`` and ``127.0.0.1`` are
|
||||
#: *different origins* to a browser, so listing only one produces a bare
|
||||
#: "Disallowed CORS origin" with nothing to hint at the cause — the classic
|
||||
#: failure being a dev server started with ``-H 127.0.0.1``. Override with
|
||||
#: ``CORS_ORIGINS``; production must set it explicitly (a wildcard is
|
||||
#: refused outright by the validator below).
|
||||
cors_origins: str = DEVELOPMENT_CORS_ORIGINS
|
||||
|
||||
# --- data stores ---------------------------------------------------------
|
||||
database_url: str = "sqlite+aiosqlite:///./agy_web.db"
|
||||
redis_url: str = "redis://localhost:6379/0"
|
||||
|
||||
# --- security ------------------------------------------------------------
|
||||
jwt_secret: str = "change-me"
|
||||
jwt_algorithm: str = "HS256"
|
||||
access_token_ttl_minutes: int = 60 * 24
|
||||
encryption_key: str = ""
|
||||
|
||||
# --- AI models (hidden from customers) -----------------------------------
|
||||
ai_default_provider: str = "mock"
|
||||
openai_compatible_base_url: str = ""
|
||||
openai_compatible_api_key: str = ""
|
||||
|
||||
# --- git -----------------------------------------------------------------
|
||||
git_provider: str = "mock"
|
||||
gitea_url: str = ""
|
||||
gitea_internal_url: str = ""
|
||||
gitea_admin_user: str = "root"
|
||||
gitea_admin_token: str = ""
|
||||
|
||||
# --- preview domain routing ----------------------------------------------
|
||||
domain_router: str = "mock"
|
||||
preview_root_domain: str = "msa.artacloud.ir"
|
||||
preview_port_start: int = 8080
|
||||
preview_port_end: int = 8999
|
||||
caddyfile_path: str = "/etc/caddy/Caddyfile"
|
||||
max_subdomains_per_project: int = 5
|
||||
|
||||
# --- sandbox -------------------------------------------------------------
|
||||
sandbox_provider: str = "mock"
|
||||
daytona_api_key: str = ""
|
||||
daytona_api_url: str = ""
|
||||
e2b_api_key: str = ""
|
||||
sandbox_command_timeout_seconds: int = 120
|
||||
sandbox_max_output_bytes: int = 200_000
|
||||
|
||||
# --- payments ------------------------------------------------------------
|
||||
payment_provider: str = "mock"
|
||||
stripe_secret_key: str = ""
|
||||
stripe_webhook_secret: str = ""
|
||||
|
||||
# --- error logging -------------------------------------------------------
|
||||
#: Master switch. When off, failures are still logged to stderr but nothing
|
||||
#: is written to ``error_events``.
|
||||
error_log_enabled: bool = True
|
||||
#: Whether 4xx responses are persisted too. Off by default: a public API
|
||||
#: takes a constant stream of 404s from scanners, and recording them all
|
||||
#: buries the genuine bugs. Turn it on to debug a misbehaving client.
|
||||
#: 5xx responses are recorded regardless of this setting.
|
||||
error_log_client_errors: bool = False
|
||||
|
||||
@property
|
||||
def cors_origin_list(self) -> list[str]:
|
||||
return [o.strip() for o in self.cors_origins.split(",") if o.strip()]
|
||||
|
||||
@property
|
||||
def is_production(self) -> bool:
|
||||
return self.app_env.lower() in {"production", "prod"}
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _refuse_insecure_production_secrets(self) -> Settings:
|
||||
"""Fail closed instead of booting a production API with a weak secret.
|
||||
|
||||
A short or placeholder ``JWT_SECRET`` lets anyone mint a token for any
|
||||
tenant — the single worst failure mode this service has. Development
|
||||
keeps working with the documented default; production must be explicit.
|
||||
"""
|
||||
if not self.is_production:
|
||||
return self
|
||||
problems: list[str] = []
|
||||
if self.jwt_secret.strip().lower() in _INSECURE_SECRETS:
|
||||
problems.append("JWT_SECRET is still the placeholder value")
|
||||
elif len(self.jwt_secret) < MIN_SECRET_LENGTH:
|
||||
problems.append(
|
||||
f"JWT_SECRET is {len(self.jwt_secret)} chars; "
|
||||
f"at least {MIN_SECRET_LENGTH} are required"
|
||||
)
|
||||
if not self.encryption_key.strip():
|
||||
problems.append("ENCRYPTION_KEY is empty; stored credentials cannot be encrypted")
|
||||
if self.cors_origins.strip() == "*":
|
||||
problems.append("CORS_ORIGINS must not be '*' in production")
|
||||
elif self.cors_origins.strip() == DEVELOPMENT_CORS_ORIGINS:
|
||||
problems.append(
|
||||
"CORS_ORIGINS is still the development default; "
|
||||
"set it to the real frontend origin"
|
||||
)
|
||||
if problems:
|
||||
raise ValueError(
|
||||
"Unsafe production configuration: " + "; ".join(problems) + "."
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
|
||||
|
||||
settings = get_settings()
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Symmetric encryption for secrets at rest (provider API keys, git credentials).
|
||||
|
||||
Fernet (AES-128-CBC + HMAC) with a key derived from ``ENCRYPTION_KEY``. The
|
||||
derivation is deterministic so a rotated key invalidates stored ciphertexts
|
||||
loudly instead of silently returning garbage.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.errors import AppError
|
||||
|
||||
|
||||
def _derive_key(raw: str) -> bytes:
|
||||
"""Fernet needs exactly 32 url-safe base64 bytes."""
|
||||
digest = hashlib.sha256(raw.encode("utf-8")).digest()
|
||||
return base64.urlsafe_b64encode(digest)
|
||||
|
||||
|
||||
_fernet: Fernet | None = None
|
||||
|
||||
|
||||
def _cipher() -> Fernet:
|
||||
global _fernet
|
||||
if _fernet is None:
|
||||
material = settings.encryption_key.strip() or settings.jwt_secret
|
||||
_fernet = Fernet(_derive_key(material))
|
||||
return _fernet
|
||||
|
||||
|
||||
def encrypt(plaintext: str) -> str:
|
||||
return _cipher().encrypt(plaintext.encode("utf-8")).decode("ascii")
|
||||
|
||||
|
||||
def decrypt(ciphertext: str) -> str:
|
||||
try:
|
||||
return _cipher().decrypt(ciphertext.encode("ascii")).decode("utf-8")
|
||||
except (InvalidToken, ValueError) as exc:
|
||||
raise AppError(
|
||||
"رمزنگاری ناموفق بود؛ ممکن است ENCRYPTION_KEY تغییر کرده باشد."
|
||||
) from exc
|
||||
|
||||
|
||||
def mask(secret: str | None, keep: int = 4) -> str:
|
||||
"""Safe display form: ``••••1234``. Never returns the full secret."""
|
||||
if not secret:
|
||||
return ""
|
||||
tail = secret[-keep:] if len(secret) > keep else ""
|
||||
return "•" * 6 + tail
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Async SQLAlchemy engine, session factory and declarative base.
|
||||
|
||||
The ORM lives in ``infrastructure``; ``domain`` never imports this module.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""Declarative base for every ORM table."""
|
||||
|
||||
|
||||
def _engine_kwargs() -> dict:
|
||||
url = settings.database_url
|
||||
if url.startswith("sqlite"):
|
||||
return {"echo": False, "future": True}
|
||||
return {
|
||||
"echo": False,
|
||||
"future": True,
|
||||
"pool_pre_ping": True,
|
||||
"pool_size": 10,
|
||||
"max_overflow": 20,
|
||||
}
|
||||
|
||||
|
||||
engine = create_async_engine(settings.database_url, **_engine_kwargs())
|
||||
|
||||
SessionFactory = async_sessionmaker(
|
||||
bind=engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
autoflush=False,
|
||||
)
|
||||
|
||||
|
||||
async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""FastAPI dependency yielding one transactional session per request."""
|
||||
async with SessionFactory() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Domain-level errors and their HTTP mapping.
|
||||
|
||||
Domain and application layers raise these; only ``interfaces/http`` knows about
|
||||
status codes. That keeps FastAPI out of the inner layers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
class AppError(Exception):
|
||||
"""Base class for every expected, user-facing failure."""
|
||||
|
||||
status_code = 400
|
||||
code = "app_error"
|
||||
|
||||
def __init__(self, message: str, *, details: Any = None) -> None:
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.details = details
|
||||
|
||||
|
||||
class ValidationError(AppError):
|
||||
status_code = 422
|
||||
code = "validation_error"
|
||||
|
||||
|
||||
class AuthenticationError(AppError):
|
||||
status_code = 401
|
||||
code = "authentication_error"
|
||||
|
||||
|
||||
class PermissionDeniedError(AppError):
|
||||
status_code = 403
|
||||
code = "permission_denied"
|
||||
|
||||
|
||||
class NotFoundError(AppError):
|
||||
status_code = 404
|
||||
code = "not_found"
|
||||
|
||||
|
||||
class ConflictError(AppError):
|
||||
status_code = 409
|
||||
code = "conflict"
|
||||
|
||||
|
||||
class QuotaExceededError(AppError):
|
||||
status_code = 402
|
||||
code = "quota_exceeded"
|
||||
|
||||
|
||||
class ProviderUnavailableError(AppError):
|
||||
status_code = 503
|
||||
code = "provider_unavailable"
|
||||
|
||||
|
||||
class ApprovalRequiredError(AppError):
|
||||
"""A dangerous operation was attempted without an approval record."""
|
||||
|
||||
status_code = 409
|
||||
code = "approval_required"
|
||||
@@ -0,0 +1,293 @@
|
||||
"""Scrub secrets out of anything before it is persisted.
|
||||
|
||||
This module exists because of one hard rule: **an error log that stores a
|
||||
password is worse than no error log at all.** Error records are written from
|
||||
inside exception handlers, where the values at hand are exactly the values most
|
||||
likely to carry credentials — request bodies, ``Authorization`` headers, DSNs,
|
||||
provider API keys, decrypted git tokens. Anything that reaches the database must
|
||||
pass through here first.
|
||||
|
||||
Two independent layers, because either one alone leaks:
|
||||
|
||||
1. **Structural** — a key that *means* a secret (``password``, ``api_key``,
|
||||
``refresh_token``, …) has its whole value replaced, whatever the value looks
|
||||
like. This catches secrets in shapes no pattern would recognise.
|
||||
2. **Lexical** — value patterns that are recognisable regardless of their key:
|
||||
PEM blocks, JWTs, ``scheme://user:pass@host`` URLs, ``Bearer`` headers,
|
||||
``sk-…``/``ghp_…``/``AKIA…`` provider keys, and ``password=…`` fragments
|
||||
embedded in a larger string.
|
||||
|
||||
On top of both sits a third net: the **configured secret values themselves**
|
||||
(``JWT_SECRET``, ``ENCRYPTION_KEY``, every ``*_API_KEY``/``*_TOKEN``) are
|
||||
replaced literally wherever they appear. A secret that is only ever compared by
|
||||
pattern will eventually be logged verbatim; a secret that is matched by value
|
||||
cannot be.
|
||||
|
||||
Deliberately dependency-free and framework-free: the recorder, the HTTP layer
|
||||
and the workers all import it, and it must never fail — a redactor that raises
|
||||
inside an exception handler turns a 500 into a hang.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
#: What replaces a scrubbed value. Deliberately obvious in a log so a reader can
|
||||
#: tell "we removed this" from "this was empty".
|
||||
REDACTED = "[REDACTED]"
|
||||
|
||||
#: Longest string we will hand to the regex engine. Error payloads can contain
|
||||
#: multi-megabyte blobs; redaction cost must stay bounded and predictable.
|
||||
MAX_INPUT_CHARS = 64_000
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Layer 1 — structural: keys that mean "secret"
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
#: Exact key names (after normalisation) whose value is never safe to store.
|
||||
_SENSITIVE_KEYS = frozenset(
|
||||
{
|
||||
"password",
|
||||
"passwd",
|
||||
"pwd",
|
||||
"passphrase",
|
||||
"secret",
|
||||
"client_secret",
|
||||
"token",
|
||||
"api_key",
|
||||
"apikey",
|
||||
"authorization",
|
||||
"auth",
|
||||
"credentials",
|
||||
"credential",
|
||||
"private_key",
|
||||
"access_token",
|
||||
"refresh_token",
|
||||
"id_token",
|
||||
"session",
|
||||
"session_id",
|
||||
"session_token",
|
||||
"cookie",
|
||||
"set_cookie",
|
||||
"jwt",
|
||||
"bearer",
|
||||
"encryption_key",
|
||||
"jwt_secret",
|
||||
"signing_key",
|
||||
"webhook_secret",
|
||||
"signature",
|
||||
}
|
||||
)
|
||||
|
||||
#: Suffixes that make a key sensitive however it is prefixed: ``db_password``,
|
||||
#: ``gitea_admin_token``, ``stripe_secret_key``, ``openai_api_key``.
|
||||
#:
|
||||
#: Suffix — not substring — matching on purpose. ``token_count`` and
|
||||
#: ``input_tokens`` are *usage* fields, and redacting them would silently
|
||||
#: destroy the billing data these same records are meant to help debug.
|
||||
_SENSITIVE_SUFFIXES = (
|
||||
"_password",
|
||||
"_passwd",
|
||||
"_secret",
|
||||
"_secret_key",
|
||||
"_token",
|
||||
"_api_key",
|
||||
"_apikey",
|
||||
"_private_key",
|
||||
"_credential",
|
||||
"_credentials",
|
||||
)
|
||||
|
||||
#: Keys that look sensitive by suffix rule but are known-safe counters.
|
||||
_SAFE_KEYS = frozenset(
|
||||
{
|
||||
"token_count",
|
||||
"token_limit",
|
||||
"tokens",
|
||||
"input_tokens",
|
||||
"output_tokens",
|
||||
"total_tokens",
|
||||
"prompt_tokens",
|
||||
"completion_tokens",
|
||||
"max_tokens",
|
||||
"cached_tokens",
|
||||
"secret_count",
|
||||
}
|
||||
)
|
||||
|
||||
_NORMALISE = re.compile(r"[^a-z0-9]+")
|
||||
#: Splits camelCase so ``refreshToken`` and ``apiKey`` normalise the same way as
|
||||
#: their snake_case spellings, instead of collapsing to one unguessable word.
|
||||
_CAMEL_BOUNDARY = re.compile(r"(?<=[a-z0-9])(?=[A-Z])")
|
||||
|
||||
|
||||
def _normalise_key(key: str) -> str:
|
||||
"""``X-API-Key`` / ``apiKey`` / ``api-key`` all become ``api_key``."""
|
||||
spaced = _CAMEL_BOUNDARY.sub("_", key.strip())
|
||||
return _NORMALISE.sub("_", spaced.lower()).strip("_")
|
||||
|
||||
|
||||
def is_sensitive_key(key: str) -> bool:
|
||||
"""True when a value stored under ``key`` must be replaced wholesale."""
|
||||
normalised = _normalise_key(key)
|
||||
if not normalised or normalised in _SAFE_KEYS:
|
||||
return False
|
||||
if normalised in _SENSITIVE_KEYS:
|
||||
return True
|
||||
return normalised.endswith(_SENSITIVE_SUFFIXES)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Layer 2 — lexical: values that are secrets whatever their key
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (
|
||||
# PEM / OpenSSH private keys, possibly multi-line.
|
||||
(
|
||||
re.compile(
|
||||
r"-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----.*?-----END [A-Z0-9 ]*PRIVATE KEY-----",
|
||||
re.DOTALL,
|
||||
),
|
||||
"[REDACTED_PRIVATE_KEY]",
|
||||
),
|
||||
# scheme://user:password@host — DSNs and git remotes. Keeps the scheme and
|
||||
# the host so the record stays useful; drops the userinfo.
|
||||
(
|
||||
re.compile(r"(?P<scheme>[a-zA-Z][a-zA-Z0-9+.\-]*://)[^/\s:@]+:[^/\s@]+@"),
|
||||
r"\g<scheme>[REDACTED]@",
|
||||
),
|
||||
# "Authorization: Bearer eyJ…" and friends. Keeps the scheme.
|
||||
(
|
||||
re.compile(r"(?i)\b(bearer|basic|token|apikey)\s+[A-Za-z0-9\-._~+/=]{8,}"),
|
||||
r"\1 [REDACTED]",
|
||||
),
|
||||
# JSON Web Tokens: three base64url segments, always starting "eyJ".
|
||||
(
|
||||
re.compile(r"\beyJ[A-Za-z0-9_\-]{4,}\.[A-Za-z0-9_\-]{4,}\.[A-Za-z0-9_\-]{4,}\b"),
|
||||
"[REDACTED_JWT]",
|
||||
),
|
||||
# Provider key shapes worth naming individually.
|
||||
(re.compile(r"\bsk-[A-Za-z0-9_\-]{16,}\b"), "[REDACTED_KEY]"),
|
||||
(re.compile(r"\b(?:gh[pousr]|github_pat)_[A-Za-z0-9_]{20,}\b"), "[REDACTED_KEY]"),
|
||||
(re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{10,}\b"), "[REDACTED_KEY]"),
|
||||
(re.compile(r"\bAKIA[0-9A-Z]{16}\b"), "[REDACTED_KEY]"),
|
||||
(re.compile(r"\bAIza[0-9A-Za-z_\-]{35}\b"), "[REDACTED_KEY]"),
|
||||
(re.compile(r"\bya29\.[0-9A-Za-z_\-]+\b"), "[REDACTED_KEY]"),
|
||||
# "password=hunter2", '"api_key": "…"', "secret: abc" inside a longer text.
|
||||
# The value is a quoted run, a scheme-prefixed token ("Bearer eyJ…"), or a
|
||||
# bare run up to the next delimiter.
|
||||
(
|
||||
re.compile(
|
||||
r"(?i)\b(password|passwd|pwd|secret|token|api[_\-]?key|client[_\-]?secret"
|
||||
r"|access[_\-]?token|refresh[_\-]?token|authorization|private[_\-]?key)"
|
||||
r"\b(\s*[:=]\s*)"
|
||||
r"(\"[^\"]{0,512}\""
|
||||
r"|'[^']{0,512}'"
|
||||
r"|(?:bearer|basic|token|apikey)\s+\S{1,512}"
|
||||
r"|[^\s,;&)\]}\"']{1,512})"
|
||||
),
|
||||
r"\1\2[REDACTED]",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _configured_secret_values() -> tuple[str, ...]:
|
||||
"""Literal secrets from settings, longest first.
|
||||
|
||||
Read lazily on every call rather than cached at import: the test suite
|
||||
rewrites ``settings.jwt_secret`` in a fixture, and a stale cache would keep
|
||||
the old value while the new one leaked.
|
||||
"""
|
||||
from app.core.config import settings
|
||||
|
||||
candidates = (
|
||||
settings.jwt_secret,
|
||||
settings.encryption_key,
|
||||
settings.openai_compatible_api_key,
|
||||
settings.gitea_admin_token,
|
||||
settings.daytona_api_key,
|
||||
settings.e2b_api_key,
|
||||
settings.stripe_secret_key,
|
||||
settings.stripe_webhook_secret,
|
||||
)
|
||||
# Short strings and documented placeholders are not secrets. Replacing
|
||||
# "change-me" would garble messages for no security gain.
|
||||
placeholders = {"change-me", "changeme", "secret", "test", "dev", "password", "none"}
|
||||
usable = {
|
||||
value
|
||||
for value in candidates
|
||||
if isinstance(value, str) and len(value) >= 8 and value.lower() not in placeholders
|
||||
}
|
||||
return tuple(sorted(usable, key=len, reverse=True))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def redact_text(value: str) -> str:
|
||||
"""Scrub secrets from a free-form string. Never raises."""
|
||||
try:
|
||||
text = value if len(value) <= MAX_INPUT_CHARS else value[:MAX_INPUT_CHARS] + "…[truncated]"
|
||||
for secret in _configured_secret_values():
|
||||
if secret in text:
|
||||
text = text.replace(secret, REDACTED)
|
||||
for pattern, replacement in _PATTERNS:
|
||||
text = pattern.sub(replacement, text)
|
||||
return text
|
||||
except Exception: # pragma: no cover - redaction must never break a handler
|
||||
# If a pattern ever explodes, drop the text rather than the request.
|
||||
return REDACTED
|
||||
|
||||
|
||||
def redact(value: Any) -> Any:
|
||||
"""Recursively scrub an arbitrary JSON-ish value. Never raises."""
|
||||
try:
|
||||
if value is None or isinstance(value, (bool, int, float)):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
return redact_text(value)
|
||||
if isinstance(value, (bytes, bytearray, memoryview)):
|
||||
return f"<{len(bytes(value))} bytes>"
|
||||
if isinstance(value, Mapping):
|
||||
return redact_mapping(value)
|
||||
if isinstance(value, (list, tuple, set, frozenset)):
|
||||
return [redact(item) for item in value]
|
||||
return redact_text(str(value))
|
||||
except Exception: # pragma: no cover - see redact_text
|
||||
return REDACTED
|
||||
|
||||
|
||||
def redact_mapping(data: Mapping[Any, Any] | None) -> dict[str, Any]:
|
||||
"""Scrub a mapping, replacing any sensitive key's value outright."""
|
||||
if not data:
|
||||
return {}
|
||||
try:
|
||||
result: dict[str, Any] = {}
|
||||
for key, item in data.items():
|
||||
name = str(key)
|
||||
result[name] = REDACTED if is_sensitive_key(name) else redact(item)
|
||||
return result
|
||||
except Exception: # pragma: no cover - see redact_text
|
||||
return {"_redaction_failed": True}
|
||||
|
||||
|
||||
def redact_headers(headers: Mapping[Any, Any] | None) -> dict[str, str]:
|
||||
"""Scrub HTTP headers, keeping the shape of the request for debugging.
|
||||
|
||||
Header names are normalised to lower-case; ``authorization``, ``cookie`` and
|
||||
``x-api-key`` fall out as sensitive through the same key rules as JSON.
|
||||
"""
|
||||
if not headers:
|
||||
return {}
|
||||
try:
|
||||
result: dict[str, str] = {}
|
||||
for key, value in headers.items():
|
||||
name = str(key).lower()
|
||||
result[name] = REDACTED if is_sensitive_key(name) else str(redact(value))
|
||||
return result
|
||||
except Exception: # pragma: no cover - see redact_text
|
||||
return {}
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Password hashing and JWT issuance/verification.
|
||||
|
||||
Argon2id for passwords, HS256 JWTs for sessions. No secret ever leaves this module.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import jwt
|
||||
from argon2 import PasswordHasher
|
||||
from argon2.exceptions import InvalidHashError, VerifyMismatchError
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.errors import AuthenticationError
|
||||
|
||||
_hasher = PasswordHasher()
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return _hasher.hash(password)
|
||||
|
||||
|
||||
def verify_password(password: str, password_hash: str) -> bool:
|
||||
try:
|
||||
return _hasher.verify(password_hash, password)
|
||||
except (VerifyMismatchError, InvalidHashError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def needs_rehash(password_hash: str) -> bool:
|
||||
try:
|
||||
return _hasher.check_needs_rehash(password_hash)
|
||||
except (InvalidHashError, ValueError):
|
||||
return True
|
||||
|
||||
|
||||
def create_access_token(user_id: str, *, extra: dict[str, Any] | None = None) -> str:
|
||||
now = datetime.now(UTC)
|
||||
payload: dict[str, Any] = {
|
||||
"sub": str(user_id),
|
||||
"iat": int(now.timestamp()),
|
||||
"exp": int((now + timedelta(minutes=settings.access_token_ttl_minutes)).timestamp()),
|
||||
"jti": uuid.uuid4().hex,
|
||||
}
|
||||
if extra:
|
||||
payload.update(extra)
|
||||
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
|
||||
|
||||
|
||||
def decode_access_token(token: str) -> dict[str, Any]:
|
||||
try:
|
||||
return jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm])
|
||||
except jwt.ExpiredSignatureError as exc:
|
||||
raise AuthenticationError("نشست منقضی شده است.") from exc
|
||||
except jwt.PyJWTError as exc:
|
||||
raise AuthenticationError("توکن نامعتبر است.") from exc
|
||||
|
||||
|
||||
def generate_token(nbytes: int = 32) -> str:
|
||||
"""Opaque token for invites / one-time links."""
|
||||
return secrets.token_urlsafe(nbytes)
|
||||
|
||||
|
||||
def hash_token(token: str) -> str:
|
||||
"""Invite tokens are stored hashed, never in plaintext."""
|
||||
import hashlib
|
||||
|
||||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||
@@ -0,0 +1,109 @@
|
||||
"""AI Control Plane domain: providers, credentials, models, routing, rate cards.
|
||||
|
||||
Everything here is platform-owner-only. A normal customer never receives these
|
||||
records through the API — the application layer strips them.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from enum import StrEnum
|
||||
|
||||
from app.domain.base import new_id, utcnow
|
||||
|
||||
|
||||
class ProviderType(StrEnum):
|
||||
MOCK = "mock"
|
||||
OPENAI_COMPATIBLE = "openai_compatible"
|
||||
ANTHROPIC = "anthropic"
|
||||
GEMINI = "gemini"
|
||||
OPENROUTER = "openrouter"
|
||||
|
||||
|
||||
class RoutingMode(StrEnum):
|
||||
FAST = "fast"
|
||||
CHEAP = "cheap"
|
||||
BALANCED = "balanced"
|
||||
REASONING = "reasoning"
|
||||
VISION = "vision"
|
||||
CODING = "coding"
|
||||
|
||||
|
||||
class Capability(StrEnum):
|
||||
TEXT = "text"
|
||||
VISION = "vision"
|
||||
TOOLS = "tools"
|
||||
REASONING = "reasoning"
|
||||
LONG_CONTEXT = "long_context"
|
||||
CODE = "code"
|
||||
|
||||
|
||||
class RateUnit(StrEnum):
|
||||
MODEL_INPUT_TOKENS = "model_input_tokens"
|
||||
MODEL_OUTPUT_TOKENS = "model_output_tokens"
|
||||
SANDBOX_CPU_SECOND = "sandbox_cpu_second"
|
||||
SANDBOX_MEMORY_GB_SECOND = "sandbox_memory_gb_second"
|
||||
STORAGE_GB_DAY = "storage_gb_day"
|
||||
TOOL_CALL = "tool_call"
|
||||
PREMIUM_EMPLOYEE_RUN = "premium_employee_run"
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class Provider:
|
||||
name: str
|
||||
type: ProviderType = ProviderType.MOCK
|
||||
base_url: str = ""
|
||||
id: str = field(default_factory=new_id)
|
||||
is_active: bool = True
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class ModelCredential:
|
||||
provider_id: str
|
||||
name: str
|
||||
encrypted_api_key: str
|
||||
id: str = field(default_factory=new_id)
|
||||
is_active: bool = True
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class AiModel:
|
||||
provider_id: str
|
||||
internal_name: str
|
||||
display_name_for_admin: str
|
||||
capabilities: list[str] = field(default_factory=list)
|
||||
input_price_per_1m: float = 0.0
|
||||
output_price_per_1m: float = 0.0
|
||||
id: str = field(default_factory=new_id)
|
||||
is_active: bool = True
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
|
||||
def supports(self, capability: str | Capability) -> bool:
|
||||
return str(capability) in set(self.capabilities)
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class RoutingPolicy:
|
||||
name: str
|
||||
mode: RoutingMode = RoutingMode.BALANCED
|
||||
description: str = ""
|
||||
fallback_policy_json: dict = field(default_factory=dict)
|
||||
budget_limits_json: dict = field(default_factory=dict)
|
||||
id: str = field(default_factory=new_id)
|
||||
is_default: bool = False
|
||||
is_active: bool = True
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class RateCard:
|
||||
name: str
|
||||
unit: RateUnit
|
||||
customer_price: float
|
||||
internal_cost_estimate: float = 0.0
|
||||
id: str = field(default_factory=new_id)
|
||||
is_active: bool = True
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
@@ -0,0 +1,153 @@
|
||||
"""Observability & Audit domain: the immutable trail of who did what."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
|
||||
from app.domain.base import new_id, utcnow
|
||||
|
||||
|
||||
class AuditAction:
|
||||
"""Well-known auditable actions. Free-form strings are allowed but these are
|
||||
the ones the application layer emits so far."""
|
||||
|
||||
LOGIN = "auth.login"
|
||||
LOGOUT = "auth.logout"
|
||||
SIGNUP = "auth.signup"
|
||||
ORG_CREATE = "organization.create"
|
||||
ORG_DELETE = "organization.delete"
|
||||
MEMBER_INVITE = "member.invite"
|
||||
MEMBER_ROLE_CHANGE = "member.role_change"
|
||||
MEMBER_REMOVE = "member.remove"
|
||||
EMPLOYEE_CREATE = "employee.create"
|
||||
EMPLOYEE_UPDATE = "employee.update"
|
||||
REPO_CREATE = "repo.create"
|
||||
REPO_DELETE = "repo.delete"
|
||||
DOMAIN_CREATE = "domain.create"
|
||||
DOMAIN_DELETE = "domain.delete"
|
||||
SANDBOX_COMMAND = "sandbox.command"
|
||||
SANDBOX_DESTROY = "sandbox.destroy"
|
||||
#: A platform worker stopped a sandbox session that had gone idle. System
|
||||
#: action, not a tenant one — the actor is the platform, not a user.
|
||||
SANDBOX_REAP = "sandbox.reap"
|
||||
APPROVAL_DECISION = "approval.decision"
|
||||
BILLING_CREDIT = "billing.credit"
|
||||
BILLING_PAYMENT = "billing.payment"
|
||||
PROVIDER_CHANGE = "provider.change"
|
||||
MODEL_CHANGE = "model.change"
|
||||
ROUTING_CHANGE = "routing.change"
|
||||
PROJECT_CREATE = "project.create"
|
||||
PROJECT_DELETE = "project.delete"
|
||||
TASK_CREATE = "task.create"
|
||||
RUN_CANCEL = "run.cancel"
|
||||
#: A platform owner triaged an error in the console. Auditable so "who
|
||||
#: decided this bug was fixed?" has an answer.
|
||||
ERROR_RESOLVE = "error.resolve"
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class AuditEvent:
|
||||
action: str
|
||||
organization_id: str | None = None
|
||||
actor_user_id: str | None = None
|
||||
target_type: str = ""
|
||||
target_id: str = ""
|
||||
metadata_json: dict = field(default_factory=dict)
|
||||
ip_address: str = ""
|
||||
user_agent: str = ""
|
||||
id: str = field(default_factory=new_id)
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class Trace:
|
||||
organization_id: str
|
||||
name: str = ""
|
||||
run_id: str | None = None
|
||||
id: str = field(default_factory=new_id)
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class Span:
|
||||
trace_id: str
|
||||
name: str
|
||||
kind: str = "internal"
|
||||
parent_span_id: str | None = None
|
||||
attributes_json: dict = field(default_factory=dict)
|
||||
id: str = field(default_factory=new_id)
|
||||
started_at: datetime = field(default_factory=utcnow)
|
||||
ended_at: datetime | None = None
|
||||
|
||||
@property
|
||||
def duration_ms(self) -> int | None:
|
||||
if self.ended_at is None:
|
||||
return None
|
||||
return int((self.ended_at - self.started_at).total_seconds() * 1000)
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class ErrorEvent:
|
||||
"""A persisted failure, recorded so it can be diagnosed later.
|
||||
|
||||
Deliberately *not* a domain aggregate: nothing in the business rules depends
|
||||
on it. It is an operational record, which is why it carries transport-level
|
||||
detail (method, path, status code) that no domain model would ever hold.
|
||||
"""
|
||||
|
||||
kind: str
|
||||
message: str
|
||||
organization_id: str | None = None
|
||||
run_id: str | None = None
|
||||
stack: str = ""
|
||||
metadata_json: dict = field(default_factory=dict)
|
||||
#: Where the failure happened: ``http``, ``worker`` or ``startup``.
|
||||
source: str = "http"
|
||||
#: ``error`` for anything unexpected, ``warning`` for expected 4xx noise,
|
||||
#: ``critical`` for startup/infrastructure failures.
|
||||
severity: str = "error"
|
||||
#: Exception class name (``ValueError``, ``IntegrityError``, …). Empty when
|
||||
#: the failure was not an exception.
|
||||
error_type: str = ""
|
||||
#: Stable hash of the failure's identity, so a thousand occurrences of the
|
||||
#: same bug group into one row in the console instead of a thousand.
|
||||
fingerprint: str = ""
|
||||
status_code: int | None = None
|
||||
method: str = ""
|
||||
path: str = ""
|
||||
user_id: str | None = None
|
||||
#: Correlates this record with the response the caller received and with
|
||||
#: every log line written while handling the same request.
|
||||
request_id: str = ""
|
||||
ip: str = ""
|
||||
user_agent: str = ""
|
||||
resolved_at: datetime | None = None
|
||||
resolved_note: str = ""
|
||||
id: str = field(default_factory=new_id)
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
|
||||
|
||||
class ErrorSource:
|
||||
"""Where an error was caught. Kept as strings so a new source needs no
|
||||
migration."""
|
||||
|
||||
HTTP = "http"
|
||||
WORKER = "worker"
|
||||
STARTUP = "startup"
|
||||
|
||||
|
||||
class ErrorSeverity:
|
||||
ERROR = "error"
|
||||
WARNING = "warning"
|
||||
CRITICAL = "critical"
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class QualityMetric:
|
||||
organization_id: str
|
||||
name: str
|
||||
value: float
|
||||
dimensions_json: dict = field(default_factory=dict)
|
||||
id: str = field(default_factory=new_id)
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Shared domain primitives.
|
||||
|
||||
Pure Python — no framework, no I/O. The domain layer must stay importable
|
||||
without FastAPI, SQLAlchemy or a database connection.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
_SLUG_RE = re.compile(r"[^a-z0-9]+")
|
||||
|
||||
|
||||
def new_id() -> str:
|
||||
"""Opaque, URL-safe entity id (portable across SQLite and PostgreSQL)."""
|
||||
return uuid.uuid4().hex
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
def slugify(value: str, *, max_length: int = 48) -> str:
|
||||
"""ASCII slug. Non-ASCII input (e.g. Persian) collapses to a stable fallback.
|
||||
|
||||
Persian organization names do not transliterate, so we keep a short random
|
||||
suffix appended by the caller when the slug would be empty.
|
||||
"""
|
||||
slug = _SLUG_RE.sub("-", value.strip().lower()).strip("-")
|
||||
slug = slug[:max_length].strip("-")
|
||||
if not slug:
|
||||
slug = f"x{uuid.uuid4().hex[:8]}"
|
||||
return slug
|
||||
|
||||
|
||||
def ensure_unique_slug(base: str, taken: set[str], *, max_length: int = 48) -> str:
|
||||
"""Append ``-2``, ``-3`` ... until the slug is free."""
|
||||
candidate = base[:max_length]
|
||||
if candidate not in taken:
|
||||
return candidate
|
||||
n = 2
|
||||
while True:
|
||||
suffix = f"-{n}"
|
||||
candidate = f"{base[: max_length - len(suffix)]}{suffix}"
|
||||
if candidate not in taken:
|
||||
return candidate
|
||||
n += 1
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Billing & Usage domain.
|
||||
|
||||
The internal usage ledger is the source of truth. Stripe (or any other payment
|
||||
provider) is an *adapter* that mirrors it — never the other way around.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from enum import StrEnum
|
||||
|
||||
from app.domain.ai_control.models import RateUnit
|
||||
from app.domain.base import new_id, utcnow
|
||||
|
||||
|
||||
class SubscriptionStatus(StrEnum):
|
||||
TRIALING = "trialing"
|
||||
ACTIVE = "active"
|
||||
PAST_DUE = "past_due"
|
||||
CANCELED = "canceled"
|
||||
|
||||
|
||||
class InvoiceStatus(StrEnum):
|
||||
DRAFT = "draft"
|
||||
OPEN = "open"
|
||||
PAID = "paid"
|
||||
VOID = "void"
|
||||
UNCOLLECTIBLE = "uncollectible"
|
||||
|
||||
|
||||
class PaymentStatus(StrEnum):
|
||||
PENDING = "pending"
|
||||
SUCCEEDED = "succeeded"
|
||||
FAILED = "failed"
|
||||
REFUNDED = "refunded"
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class Plan:
|
||||
name: str
|
||||
monthly_price: float = 0.0
|
||||
included_credit: float = 0.0
|
||||
features_json: dict = field(default_factory=dict)
|
||||
id: str = field(default_factory=new_id)
|
||||
is_active: bool = True
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class Subscription:
|
||||
organization_id: str
|
||||
plan_id: str
|
||||
status: SubscriptionStatus = SubscriptionStatus.ACTIVE
|
||||
external_subscription_id: str | None = None
|
||||
current_period_end: datetime | None = None
|
||||
id: str = field(default_factory=new_id)
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class CreditWallet:
|
||||
organization_id: str
|
||||
balance: float = 0.0
|
||||
currency: str = "USD"
|
||||
low_balance_threshold: float = 5.0
|
||||
id: str = field(default_factory=new_id)
|
||||
updated_at: datetime = field(default_factory=utcnow)
|
||||
|
||||
@property
|
||||
def is_low(self) -> bool:
|
||||
return self.balance <= self.low_balance_threshold
|
||||
|
||||
def can_afford(self, amount: float) -> bool:
|
||||
return self.balance >= amount
|
||||
|
||||
def debit(self, amount: float) -> None:
|
||||
self.balance -= amount
|
||||
self.updated_at = utcnow()
|
||||
|
||||
def credit(self, amount: float) -> None:
|
||||
self.balance += amount
|
||||
self.updated_at = utcnow()
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class UsageEvent:
|
||||
"""One billable unit of work. Append-only."""
|
||||
|
||||
organization_id: str
|
||||
unit: RateUnit
|
||||
quantity: float
|
||||
unit_price: float = 0.0
|
||||
internal_cost_estimate: float = 0.0
|
||||
total_price: float = 0.0
|
||||
project_id: str | None = None
|
||||
user_id: str | None = None
|
||||
employee_id: str | None = None
|
||||
run_id: str | None = None
|
||||
metadata_json: dict = field(default_factory=dict)
|
||||
id: str = field(default_factory=new_id)
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class Invoice:
|
||||
organization_id: str
|
||||
amount: float
|
||||
status: InvoiceStatus = InvoiceStatus.DRAFT
|
||||
currency: str = "USD"
|
||||
external_invoice_id: str | None = None
|
||||
period_start: datetime | None = None
|
||||
period_end: datetime | None = None
|
||||
id: str = field(default_factory=new_id)
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class Payment:
|
||||
organization_id: str
|
||||
amount: float
|
||||
status: PaymentStatus = PaymentStatus.PENDING
|
||||
provider: str = "mock"
|
||||
external_payment_id: str | None = None
|
||||
invoice_id: str | None = None
|
||||
id: str = field(default_factory=new_id)
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Identity & Access domain: users, organizations, memberships, invites."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from enum import StrEnum
|
||||
|
||||
from app.domain.base import new_id, utcnow
|
||||
|
||||
|
||||
class Role(StrEnum):
|
||||
OWNER = "owner"
|
||||
ADMIN = "admin"
|
||||
MANAGER = "manager"
|
||||
MEMBER = "member"
|
||||
VIEWER = "viewer"
|
||||
|
||||
|
||||
#: Higher rank implies every permission of the lower ranks.
|
||||
ROLE_RANK: dict[Role, int] = {
|
||||
Role.VIEWER: 10,
|
||||
Role.MEMBER: 20,
|
||||
Role.MANAGER: 30,
|
||||
Role.ADMIN: 40,
|
||||
Role.OWNER: 50,
|
||||
}
|
||||
|
||||
#: Capabilities used across the application layer.
|
||||
CAN_MANAGE_ORG = "org.manage"
|
||||
CAN_MANAGE_MEMBERS = "org.members"
|
||||
CAN_MANAGE_PROJECTS = "projects.manage"
|
||||
CAN_MANAGE_EMPLOYEES = "employees.manage"
|
||||
CAN_RUN_TASKS = "tasks.run"
|
||||
CAN_VIEW_BILLING = "billing.view"
|
||||
CAN_MANAGE_BILLING = "billing.manage"
|
||||
CAN_VIEW_AUDIT = "audit.view"
|
||||
CAN_USE_SANDBOX = "sandbox.use"
|
||||
|
||||
_ROLE_CAPABILITIES: dict[Role, set[str]] = {
|
||||
Role.VIEWER: set(),
|
||||
Role.MEMBER: {CAN_RUN_TASKS, CAN_VIEW_BILLING},
|
||||
Role.MANAGER: {
|
||||
CAN_RUN_TASKS,
|
||||
CAN_VIEW_BILLING,
|
||||
CAN_MANAGE_PROJECTS,
|
||||
CAN_MANAGE_EMPLOYEES,
|
||||
CAN_USE_SANDBOX,
|
||||
},
|
||||
Role.ADMIN: {
|
||||
CAN_RUN_TASKS,
|
||||
CAN_VIEW_BILLING,
|
||||
CAN_MANAGE_BILLING,
|
||||
CAN_MANAGE_PROJECTS,
|
||||
CAN_MANAGE_EMPLOYEES,
|
||||
CAN_USE_SANDBOX,
|
||||
CAN_MANAGE_MEMBERS,
|
||||
CAN_VIEW_AUDIT,
|
||||
},
|
||||
Role.OWNER: {
|
||||
CAN_RUN_TASKS,
|
||||
CAN_VIEW_BILLING,
|
||||
CAN_MANAGE_BILLING,
|
||||
CAN_MANAGE_PROJECTS,
|
||||
CAN_MANAGE_EMPLOYEES,
|
||||
CAN_USE_SANDBOX,
|
||||
CAN_MANAGE_MEMBERS,
|
||||
CAN_VIEW_AUDIT,
|
||||
CAN_MANAGE_ORG,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def role_has_capability(role: Role, capability: str) -> bool:
|
||||
return capability in _ROLE_CAPABILITIES.get(role, set())
|
||||
|
||||
|
||||
class MembershipStatus(StrEnum):
|
||||
ACTIVE = "active"
|
||||
INVITED = "invited"
|
||||
SUSPENDED = "suspended"
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class User:
|
||||
email: str
|
||||
password_hash: str
|
||||
name: str = ""
|
||||
id: str = field(default_factory=new_id)
|
||||
avatar_url: str | None = None
|
||||
is_platform_owner: bool = False
|
||||
is_active: bool = True
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
updated_at: datetime = field(default_factory=utcnow)
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
return self.name.strip() or self.email.split("@")[0]
|
||||
|
||||
def normalized_email(self) -> str:
|
||||
return self.email.strip().lower()
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class Organization:
|
||||
name: str
|
||||
slug: str
|
||||
owner_user_id: str
|
||||
id: str = field(default_factory=new_id)
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class Membership:
|
||||
organization_id: str
|
||||
user_id: str
|
||||
role: Role = Role.MEMBER
|
||||
status: MembershipStatus = MembershipStatus.ACTIVE
|
||||
id: str = field(default_factory=new_id)
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
|
||||
@property
|
||||
def is_active(self) -> bool:
|
||||
return self.status == MembershipStatus.ACTIVE
|
||||
|
||||
def has(self, capability: str) -> bool:
|
||||
return self.is_active and role_has_capability(self.role, capability)
|
||||
|
||||
def outranks(self, other_role: Role) -> bool:
|
||||
return ROLE_RANK[self.role] > ROLE_RANK[other_role]
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class Invite:
|
||||
organization_id: str
|
||||
email: str
|
||||
role: Role
|
||||
token_hash: str
|
||||
expires_at: datetime
|
||||
id: str = field(default_factory=new_id)
|
||||
invited_by_user_id: str | None = None
|
||||
accepted_at: datetime | None = None
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
|
||||
def is_expired(self, now: datetime | None = None) -> bool:
|
||||
return (now or utcnow()) >= self.expires_at
|
||||
|
||||
def is_pending(self, now: datetime | None = None) -> bool:
|
||||
return self.accepted_at is None and not self.is_expired(now)
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Knowledge domain: sources, documents, chunks and retrieval policy.
|
||||
|
||||
Retrieval is permission-aware: an employee only sees knowledge scoped to its
|
||||
organization (and optionally its project).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from enum import StrEnum
|
||||
|
||||
from app.domain.base import new_id, utcnow
|
||||
|
||||
|
||||
class SourceKind(StrEnum):
|
||||
FILE = "file"
|
||||
TEXT = "text"
|
||||
URL = "url"
|
||||
REPO = "repo"
|
||||
|
||||
|
||||
class EmbeddingJobStatus(StrEnum):
|
||||
PENDING = "pending"
|
||||
RUNNING = "running"
|
||||
DONE = "done"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class KnowledgeSource:
|
||||
organization_id: str
|
||||
name: str
|
||||
kind: SourceKind = SourceKind.TEXT
|
||||
uri: str = ""
|
||||
project_id: str | None = None
|
||||
id: str = field(default_factory=new_id)
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class Document:
|
||||
source_id: str
|
||||
title: str
|
||||
content: str = ""
|
||||
id: str = field(default_factory=new_id)
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class DocumentChunk:
|
||||
document_id: str
|
||||
index: int
|
||||
content: str
|
||||
embedding_json: list[float] = field(default_factory=list)
|
||||
id: str = field(default_factory=new_id)
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class EmbeddingJob:
|
||||
source_id: str
|
||||
status: EmbeddingJobStatus = EmbeddingJobStatus.PENDING
|
||||
error: str | None = None
|
||||
id: str = field(default_factory=new_id)
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class RetrievalPolicy:
|
||||
organization_id: str
|
||||
name: str
|
||||
scope_json: dict = field(default_factory=dict)
|
||||
id: str = field(default_factory=new_id)
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Platform-wide validation rules carried over from the existing infrastructure.
|
||||
|
||||
These are pure functions with no I/O, so both the application layer *and* the
|
||||
adapters can enforce them. Defence in depth: a rule that is only checked in one
|
||||
place is a rule that will eventually be bypassed.
|
||||
|
||||
Reference: ``docs/current-infra-notes.md`` §1.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
DEFAULT_ROOT_DOMAIN = "msa.artacloud.ir"
|
||||
|
||||
#: Ports that are never allocatable (system services + data stores).
|
||||
SYSTEM_RESERVED_PORTS: frozenset[int] = frozenset(
|
||||
{22, 53, 80, 443, 2019, 3000, 5432, 6379, 8008}
|
||||
)
|
||||
|
||||
#: Ports of internal platform services. A tenant preview must never proxy to
|
||||
#: these, otherwise a public hostname would expose another component.
|
||||
SYSTEM_PROTECTED_PORTS: frozenset[int] = (
|
||||
frozenset({3000, 35555, 35556, 35557}) | SYSTEM_RESERVED_PORTS
|
||||
)
|
||||
|
||||
DEFAULT_PORT_RANGE_START = 8080
|
||||
DEFAULT_PORT_RANGE_END = 8999
|
||||
|
||||
LABEL_MIN = 3
|
||||
LABEL_MAX = 40
|
||||
_LABEL_RE = re.compile(r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?$")
|
||||
_PROXY_TARGET_RE = re.compile(r"^(https?://)?(localhost|127\.0\.0\.1):(\d{1,5})$")
|
||||
|
||||
|
||||
class RuleViolation(ValueError):
|
||||
"""Raised when a request violates a platform safety rule."""
|
||||
|
||||
|
||||
def validate_subdomain_label(label: str) -> str:
|
||||
"""Normalise and validate a single DNS label.
|
||||
|
||||
Only ``a-z0-9-``, must start and end alphanumeric, 3-40 characters.
|
||||
"""
|
||||
clean = (label or "").strip().lower()
|
||||
if not (LABEL_MIN <= len(clean) <= LABEL_MAX):
|
||||
raise RuleViolation(
|
||||
f"نام زیردامنه باید بین {LABEL_MIN} تا {LABEL_MAX} کاراکتر باشد."
|
||||
)
|
||||
if not _LABEL_RE.match(clean):
|
||||
raise RuleViolation("نام زیردامنه فقط میتواند شامل حروف کوچک، عدد و خط تیره باشد.")
|
||||
return clean
|
||||
|
||||
|
||||
def build_host(label: str, root_domain: str = DEFAULT_ROOT_DOMAIN) -> str:
|
||||
return f"{validate_subdomain_label(label)}.{root_domain.strip().lower()}"
|
||||
|
||||
|
||||
def validate_managed_host(host: str, root_domain: str = DEFAULT_ROOT_DOMAIN) -> str:
|
||||
"""Validate a fully-qualified host against the managed wildcard domain.
|
||||
|
||||
Rejects anything that is not a single label under the root domain — this is
|
||||
the anti-injection check that keeps a crafted host from breaking out of a
|
||||
Caddy site block or pointing at an arbitrary host.
|
||||
"""
|
||||
clean = (host or "").strip().lower()
|
||||
suffix = f".{root_domain.strip().lower()}"
|
||||
if not clean.endswith(suffix):
|
||||
raise RuleViolation(f"دامنه باید زیرمجموعهٔ «{root_domain}» باشد.")
|
||||
label = clean[: -len(suffix)]
|
||||
if "." in label:
|
||||
raise RuleViolation("فقط یک برچسب (single label) مجاز است.")
|
||||
validate_subdomain_label(label)
|
||||
return clean
|
||||
|
||||
|
||||
def validate_proxy_target(target: str) -> tuple[str, int]:
|
||||
"""Ensure the reverse-proxy target is a loopback address with a numeric port.
|
||||
|
||||
Blocks SSRF: a tenant must not be able to proxy a public hostname to an
|
||||
internal service.
|
||||
"""
|
||||
clean = (target or "").strip()
|
||||
match = _PROXY_TARGET_RE.match(clean)
|
||||
if not match:
|
||||
raise RuleViolation(
|
||||
"مقصد reverse proxy باید localhost یا 127.0.0.1 با پورت عددی باشد."
|
||||
)
|
||||
port = int(match.group(3))
|
||||
if not (1 <= port <= 65535):
|
||||
raise RuleViolation("شمارهٔ پورت نامعتبر است.")
|
||||
return match.group(2), port
|
||||
|
||||
|
||||
def is_port_allocatable(
|
||||
port: int,
|
||||
*,
|
||||
start: int = DEFAULT_PORT_RANGE_START,
|
||||
end: int = DEFAULT_PORT_RANGE_END,
|
||||
) -> bool:
|
||||
if port in SYSTEM_RESERVED_PORTS:
|
||||
return False
|
||||
return start <= port <= end
|
||||
|
||||
|
||||
def assert_port_allocatable(
|
||||
port: int,
|
||||
*,
|
||||
start: int = DEFAULT_PORT_RANGE_START,
|
||||
end: int = DEFAULT_PORT_RANGE_END,
|
||||
) -> int:
|
||||
if not is_port_allocatable(port, start=start, end=end):
|
||||
raise RuleViolation(f"پورت {port} قابل تخصیص نیست.")
|
||||
return port
|
||||
|
||||
|
||||
def assert_not_protected_port(port: int) -> int:
|
||||
"""A preview domain must never point at an internal platform port."""
|
||||
if port in SYSTEM_PROTECTED_PORTS:
|
||||
raise RuleViolation(f"اتصال به پورت محافظتشدهٔ {port} ممنوع است.")
|
||||
return port
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Projects domain: projects, their Git repositories and per-project credentials."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
|
||||
from app.domain.base import new_id, utcnow
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class Project:
|
||||
organization_id: str
|
||||
name: str
|
||||
slug: str
|
||||
description: str = ""
|
||||
created_by_user_id: str | None = None
|
||||
default_branch: str = "main"
|
||||
id: str = field(default_factory=new_id)
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class ProjectGitRepo:
|
||||
"""The Git repository attached to a project.
|
||||
|
||||
Named ``ProjectGitRepo`` rather than ``ProjectRepository`` on purpose: the
|
||||
latter collided with the *infrastructure* repository that manages ``Project``
|
||||
rows, and the shadowing silently broke every Git endpoint at runtime.
|
||||
"""
|
||||
|
||||
project_id: str
|
||||
provider: str = "gitea"
|
||||
owner: str = ""
|
||||
repo_name: str = ""
|
||||
clone_url: str = ""
|
||||
web_url: str = ""
|
||||
default_branch: str = "main"
|
||||
id: str = field(default_factory=new_id)
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
|
||||
@property
|
||||
def full_name(self) -> str:
|
||||
return f"{self.owner}/{self.repo_name}"
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class ProjectGitCredential:
|
||||
"""Per-project Git identity. The password is encrypted at rest and never
|
||||
returned to the frontend."""
|
||||
|
||||
project_id: str
|
||||
username: str
|
||||
encrypted_password: str
|
||||
id: str = field(default_factory=new_id)
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Sandbox domain: templates, workspaces, sessions, commands, snapshots, limits.
|
||||
|
||||
The host machine must never execute tenant code. Every command flows through a
|
||||
SandboxProviderPort adapter and is bounded by a timeout and an output cap.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from enum import StrEnum
|
||||
|
||||
from app.domain.base import new_id, utcnow
|
||||
|
||||
|
||||
class SandboxProviderKind(StrEnum):
|
||||
MOCK = "mock"
|
||||
DAYTONA = "daytona"
|
||||
E2B = "e2b"
|
||||
|
||||
|
||||
class WorkspaceStatus(StrEnum):
|
||||
PENDING = "pending"
|
||||
READY = "ready"
|
||||
STOPPED = "stopped"
|
||||
DESTROYED = "destroyed"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class SessionStatus(StrEnum):
|
||||
STARTING = "starting"
|
||||
RUNNING = "running"
|
||||
PAUSED = "paused"
|
||||
STOPPED = "stopped"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class NetworkPolicy(StrEnum):
|
||||
DENY_ALL = "deny_all"
|
||||
ALLOWLIST = "allowlist"
|
||||
OPEN = "open"
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class SandboxTemplate:
|
||||
name: str
|
||||
provider: str = SandboxProviderKind.MOCK
|
||||
image: str = "python:3.13-slim"
|
||||
description: str = ""
|
||||
default_cpu: float = 1.0
|
||||
default_memory_mb: int = 1024
|
||||
default_disk_mb: int = 2048
|
||||
id: str = field(default_factory=new_id)
|
||||
is_system: bool = True
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class Workspace:
|
||||
organization_id: str
|
||||
project_id: str
|
||||
template_id: str
|
||||
provider: str = SandboxProviderKind.MOCK
|
||||
external_workspace_id: str | None = None
|
||||
status: WorkspaceStatus = WorkspaceStatus.PENDING
|
||||
id: str = field(default_factory=new_id)
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class SandboxSession:
|
||||
workspace_id: str
|
||||
external_session_id: str | None = None
|
||||
status: SessionStatus = SessionStatus.STARTING
|
||||
id: str = field(default_factory=new_id)
|
||||
started_at: datetime = field(default_factory=utcnow)
|
||||
stopped_at: datetime | None = None
|
||||
last_active_at: datetime = field(default_factory=utcnow)
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class CommandRun:
|
||||
session_id: str
|
||||
command: str
|
||||
exit_code: int | None = None
|
||||
stdout_ref: str = ""
|
||||
stderr_ref: str = ""
|
||||
duration_ms: int = 0
|
||||
truncated: bool = False
|
||||
id: str = field(default_factory=new_id)
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
|
||||
@property
|
||||
def succeeded(self) -> bool:
|
||||
return self.exit_code == 0
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class Snapshot:
|
||||
workspace_id: str
|
||||
name: str
|
||||
external_snapshot_id: str | None = None
|
||||
id: str = field(default_factory=new_id)
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class PreviewUrl:
|
||||
workspace_id: str
|
||||
port: int
|
||||
url: str
|
||||
id: str = field(default_factory=new_id)
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class SandboxResourceLimit:
|
||||
"""Limits are stored in policy, never trusted from the caller."""
|
||||
|
||||
cpu: float = 1.0
|
||||
memory_mb: int = 1024
|
||||
disk_mb: int = 2048
|
||||
timeout_seconds: int = 120
|
||||
max_output_bytes: int = 200_000
|
||||
network_policy: NetworkPolicy = NetworkPolicy.DENY_ALL
|
||||
scope: str = "organization"
|
||||
scope_id: str | None = None
|
||||
id: str = field(default_factory=new_id)
|
||||
@@ -0,0 +1,252 @@
|
||||
"""Agent Workforce domain: employees, chat threads, tasks, runs and artifacts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from enum import StrEnum
|
||||
|
||||
from app.domain.base import new_id, utcnow
|
||||
from app.domain.identity.models import Role
|
||||
|
||||
|
||||
class EmployeeStatus(StrEnum):
|
||||
ACTIVE = "active"
|
||||
PAUSED = "paused"
|
||||
ARCHIVED = "archived"
|
||||
|
||||
|
||||
class RoleKey(StrEnum):
|
||||
MANAGER = "manager"
|
||||
PLANNER = "planner"
|
||||
DEVELOPER = "developer"
|
||||
REVIEWER = "reviewer"
|
||||
TESTER = "tester"
|
||||
RESEARCHER = "researcher"
|
||||
CONTENT_CREATOR = "content_creator"
|
||||
SUPPORT = "support"
|
||||
|
||||
|
||||
class TaskStatus(StrEnum):
|
||||
NEW = "new"
|
||||
TRIAGED = "triaged"
|
||||
WAITING_APPROVAL = "waiting_approval"
|
||||
RUNNING = "running"
|
||||
DONE = "done"
|
||||
FAILED = "failed"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
class RunStatus(StrEnum):
|
||||
QUEUED = "queued"
|
||||
PLANNING = "planning"
|
||||
WAITING_APPROVAL = "waiting_approval"
|
||||
RUNNING = "running"
|
||||
TOOL_CALLING = "tool_calling"
|
||||
SANDBOX_RUNNING = "sandbox_running"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
CANCELLED = "cancelled"
|
||||
TIMED_OUT = "timed_out"
|
||||
|
||||
|
||||
TERMINAL_RUN_STATUSES: frozenset[RunStatus] = frozenset(
|
||||
{RunStatus.COMPLETED, RunStatus.FAILED, RunStatus.CANCELLED, RunStatus.TIMED_OUT}
|
||||
)
|
||||
TERMINAL_TASK_STATUSES: frozenset[TaskStatus] = frozenset(
|
||||
{TaskStatus.DONE, TaskStatus.FAILED, TaskStatus.CANCELLED}
|
||||
)
|
||||
|
||||
#: Allowed run state transitions. Anything else raises in the application layer.
|
||||
RUN_TRANSITIONS: dict[RunStatus, frozenset[RunStatus]] = {
|
||||
RunStatus.QUEUED: frozenset({RunStatus.PLANNING, RunStatus.CANCELLED, RunStatus.FAILED}),
|
||||
RunStatus.PLANNING: frozenset(
|
||||
{RunStatus.RUNNING, RunStatus.WAITING_APPROVAL, RunStatus.FAILED, RunStatus.CANCELLED}
|
||||
),
|
||||
RunStatus.WAITING_APPROVAL: frozenset(
|
||||
{RunStatus.RUNNING, RunStatus.CANCELLED, RunStatus.FAILED, RunStatus.TIMED_OUT}
|
||||
),
|
||||
RunStatus.RUNNING: frozenset(
|
||||
{
|
||||
RunStatus.TOOL_CALLING,
|
||||
RunStatus.SANDBOX_RUNNING,
|
||||
RunStatus.WAITING_APPROVAL,
|
||||
RunStatus.COMPLETED,
|
||||
RunStatus.FAILED,
|
||||
RunStatus.CANCELLED,
|
||||
RunStatus.TIMED_OUT,
|
||||
}
|
||||
),
|
||||
RunStatus.TOOL_CALLING: frozenset(
|
||||
{RunStatus.RUNNING, RunStatus.WAITING_APPROVAL, RunStatus.FAILED, RunStatus.TIMED_OUT}
|
||||
),
|
||||
RunStatus.SANDBOX_RUNNING: frozenset(
|
||||
{RunStatus.RUNNING, RunStatus.FAILED, RunStatus.CANCELLED, RunStatus.TIMED_OUT}
|
||||
),
|
||||
RunStatus.COMPLETED: frozenset(),
|
||||
RunStatus.FAILED: frozenset(),
|
||||
RunStatus.CANCELLED: frozenset(),
|
||||
RunStatus.TIMED_OUT: frozenset(),
|
||||
}
|
||||
|
||||
|
||||
def can_transition_run(current: RunStatus, target: RunStatus) -> bool:
|
||||
return target in RUN_TRANSITIONS.get(current, frozenset())
|
||||
|
||||
|
||||
class MessageRole(StrEnum):
|
||||
USER = "user"
|
||||
ASSISTANT = "assistant"
|
||||
SYSTEM = "system"
|
||||
|
||||
|
||||
class ToolCallStatus(StrEnum):
|
||||
PENDING = "pending"
|
||||
AWAITING_APPROVAL = "awaiting_approval"
|
||||
APPROVED = "approved"
|
||||
REJECTED = "rejected"
|
||||
SUCCEEDED = "succeeded"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class AgentTemplate:
|
||||
name: str
|
||||
role_key: RoleKey
|
||||
description: str = ""
|
||||
default_prompt: str = ""
|
||||
default_tools_json: list[str] = field(default_factory=list)
|
||||
default_routing_policy_id: str | None = None
|
||||
id: str = field(default_factory=new_id)
|
||||
is_system: bool = True
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class Employee:
|
||||
organization_id: str
|
||||
name: str
|
||||
role_key: RoleKey
|
||||
template_id: str | None = None
|
||||
project_id: str | None = None
|
||||
status: EmployeeStatus = EmployeeStatus.ACTIVE
|
||||
routing_policy_id: str | None = None
|
||||
budget_limit_json: dict = field(default_factory=dict)
|
||||
id: str = field(default_factory=new_id)
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
|
||||
@property
|
||||
def is_available(self) -> bool:
|
||||
return self.status == EmployeeStatus.ACTIVE
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class PromptVersion:
|
||||
employee_id: str
|
||||
version: int
|
||||
content: str
|
||||
created_by_user_id: str | None = None
|
||||
id: str = field(default_factory=new_id)
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class EmployeeToolPermission:
|
||||
employee_id: str
|
||||
tool_key: str
|
||||
allowed: bool = False
|
||||
policy_json: dict = field(default_factory=dict)
|
||||
id: str = field(default_factory=new_id)
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class Thread:
|
||||
organization_id: str
|
||||
title: str = ""
|
||||
project_id: str | None = None
|
||||
created_by_user_id: str | None = None
|
||||
id: str = field(default_factory=new_id)
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class Message:
|
||||
thread_id: str
|
||||
role: MessageRole
|
||||
content: str
|
||||
employee_id: str | None = None
|
||||
id: str = field(default_factory=new_id)
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class Task:
|
||||
organization_id: str
|
||||
title: str
|
||||
description: str = ""
|
||||
project_id: str | None = None
|
||||
thread_id: str | None = None
|
||||
assigned_employee_id: str | None = None
|
||||
created_by_user_id: str | None = None
|
||||
status: TaskStatus = TaskStatus.NEW
|
||||
id: str = field(default_factory=new_id)
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
updated_at: datetime = field(default_factory=utcnow)
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class Run:
|
||||
task_id: str
|
||||
employee_id: str | None = None
|
||||
status: RunStatus = RunStatus.QUEUED
|
||||
error: str | None = None
|
||||
id: str = field(default_factory=new_id)
|
||||
started_at: datetime | None = None
|
||||
finished_at: datetime | None = None
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
|
||||
@property
|
||||
def is_terminal(self) -> bool:
|
||||
return self.status in TERMINAL_RUN_STATUSES
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class RunStep:
|
||||
run_id: str
|
||||
index: int
|
||||
kind: str
|
||||
title: str = ""
|
||||
payload_json: dict = field(default_factory=dict)
|
||||
id: str = field(default_factory=new_id)
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class ToolCall:
|
||||
run_id: str
|
||||
tool_key: str
|
||||
arguments_json: dict = field(default_factory=dict)
|
||||
result_json: dict = field(default_factory=dict)
|
||||
status: ToolCallStatus = ToolCallStatus.PENDING
|
||||
requires_approval: bool = False
|
||||
approved_by_user_id: str | None = None
|
||||
id: str = field(default_factory=new_id)
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class Artifact:
|
||||
organization_id: str
|
||||
name: str
|
||||
kind: str = "file"
|
||||
content_ref: str = ""
|
||||
project_id: str | None = None
|
||||
run_id: str | None = None
|
||||
size_bytes: int = 0
|
||||
id: str = field(default_factory=new_id)
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
|
||||
|
||||
#: Roles that may only be created by an org owner/admin.
|
||||
ELEVATED_ROLE_KEYS: frozenset[RoleKey] = frozenset({RoleKey.MANAGER})
|
||||
DEFAULT_EMPLOYEE_ROLE: Role = Role.MEMBER
|
||||
@@ -0,0 +1,169 @@
|
||||
"""Tool catalog for AI employees.
|
||||
|
||||
Three rules are encoded here as data rather than as scattered ``if`` statements:
|
||||
|
||||
1. Every tool has a stable key and an explicit approval requirement.
|
||||
2. Dangerous tools require human approval **before** they run — the flag lives
|
||||
with the tool, so a new call site cannot forget it.
|
||||
3. Nothing is allowed by default; an employee needs an explicit permission row.
|
||||
|
||||
This module is pure: no I/O, no framework, no database.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
class ToolKey:
|
||||
"""Stable tool identifiers. These appear in audit logs and permissions."""
|
||||
|
||||
GIT_ENSURE_REPO = "git.ensure_repo"
|
||||
GIT_CREATE_BRANCH = "git.create_branch"
|
||||
GIT_OPEN_PR = "git.open_pr"
|
||||
GIT_MERGE_PR = "git.merge_pr"
|
||||
GIT_DELETE_REPO = "git.delete_repo"
|
||||
DOMAIN_CREATE_PREVIEW = "domain.create_preview"
|
||||
DOMAIN_DELETE_PREVIEW = "domain.delete_preview"
|
||||
SANDBOX_RUN_COMMAND = "sandbox.run_command"
|
||||
SANDBOX_READ_FILE = "sandbox.read_file"
|
||||
SANDBOX_WRITE_FILE = "sandbox.write_file"
|
||||
SANDBOX_DELETE_FILE = "sandbox.delete_file"
|
||||
SANDBOX_DESTROY = "sandbox.destroy"
|
||||
KNOWLEDGE_SEARCH = "knowledge.search"
|
||||
ARTIFACT_CREATE = "artifact.create"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ToolSpec:
|
||||
key: str
|
||||
label: str
|
||||
description: str
|
||||
#: Human approval is required before this tool may execute.
|
||||
requires_approval: bool = False
|
||||
#: Irreversible or destructive: never auto-approved, always audited.
|
||||
destructive: bool = False
|
||||
#: Tools that touch the outside world are the ones worth approving.
|
||||
category: str = "general"
|
||||
|
||||
|
||||
TOOL_CATALOG: tuple[ToolSpec, ...] = (
|
||||
ToolSpec(
|
||||
key=ToolKey.GIT_ENSURE_REPO,
|
||||
label="ساخت مخزن",
|
||||
description="ساخت مخزن خصوصی Git برای پروژه.",
|
||||
category="git",
|
||||
),
|
||||
ToolSpec(
|
||||
key=ToolKey.GIT_CREATE_BRANCH,
|
||||
label="ساخت شاخه",
|
||||
description="ساخت یک شاخهٔ جدید از شاخهٔ مشخص.",
|
||||
category="git",
|
||||
),
|
||||
ToolSpec(
|
||||
key=ToolKey.GIT_OPEN_PR,
|
||||
label="باز کردن درخواست ادغام",
|
||||
description="ثبت یک PR از شاخهٔ کاری به شاخهٔ پایه.",
|
||||
category="git",
|
||||
),
|
||||
ToolSpec(
|
||||
key=ToolKey.GIT_MERGE_PR,
|
||||
label="ادغام درخواست ادغام",
|
||||
description="ادغام PR در شاخهٔ پایه. بازگشتناپذیر.",
|
||||
requires_approval=True,
|
||||
destructive=True,
|
||||
category="git",
|
||||
),
|
||||
ToolSpec(
|
||||
key=ToolKey.GIT_DELETE_REPO,
|
||||
label="حذف مخزن",
|
||||
description="حذف کامل مخزن پروژه. بازگشتناپذیر.",
|
||||
requires_approval=True,
|
||||
destructive=True,
|
||||
category="git",
|
||||
),
|
||||
ToolSpec(
|
||||
key=ToolKey.DOMAIN_CREATE_PREVIEW,
|
||||
label="ساخت دامنهٔ تست",
|
||||
description="انتشار موقت پروژه روی یک زیردامنه.",
|
||||
category="domain",
|
||||
),
|
||||
ToolSpec(
|
||||
key=ToolKey.DOMAIN_DELETE_PREVIEW,
|
||||
label="حذف دامنهٔ تست",
|
||||
description="برداشتن دامنهٔ تست از دسترس.",
|
||||
requires_approval=True,
|
||||
category="domain",
|
||||
),
|
||||
ToolSpec(
|
||||
key=ToolKey.SANDBOX_RUN_COMMAND,
|
||||
label="اجرای دستور در سندباکس",
|
||||
description="اجرای یک دستور داخل محیط ایزوله با محدودیت زمان و خروجی.",
|
||||
category="sandbox",
|
||||
),
|
||||
ToolSpec(
|
||||
key=ToolKey.SANDBOX_READ_FILE,
|
||||
label="خواندن فایل",
|
||||
description="خواندن محتوای یک فایل در سندباکس.",
|
||||
category="sandbox",
|
||||
),
|
||||
ToolSpec(
|
||||
key=ToolKey.SANDBOX_WRITE_FILE,
|
||||
label="نوشتن فایل",
|
||||
description="نوشتن یا بازنویسی یک فایل در سندباکس.",
|
||||
category="sandbox",
|
||||
),
|
||||
ToolSpec(
|
||||
key=ToolKey.SANDBOX_DELETE_FILE,
|
||||
label="حذف فایل",
|
||||
description="حذف یک فایل از سندباکس. بازگشتناپذیر.",
|
||||
requires_approval=True,
|
||||
destructive=True,
|
||||
category="sandbox",
|
||||
),
|
||||
ToolSpec(
|
||||
key=ToolKey.SANDBOX_DESTROY,
|
||||
label="نابودی سندباکس",
|
||||
description="پایان دادن به نشست سندباکس و آزادسازی منابع.",
|
||||
requires_approval=True,
|
||||
destructive=True,
|
||||
category="sandbox",
|
||||
),
|
||||
ToolSpec(
|
||||
key=ToolKey.KNOWLEDGE_SEARCH,
|
||||
label="جستوجو در دانش",
|
||||
description="جستوجو در پایگاه دانش سازمان.",
|
||||
category="knowledge",
|
||||
),
|
||||
ToolSpec(
|
||||
key=ToolKey.ARTIFACT_CREATE,
|
||||
label="ساخت خروجی",
|
||||
description="ثبت یک سند یا خروجی قابل مشاهده برای کاربر.",
|
||||
category="artifact",
|
||||
),
|
||||
)
|
||||
|
||||
_BY_KEY: dict[str, ToolSpec] = {spec.key: spec for spec in TOOL_CATALOG}
|
||||
|
||||
#: Templates that reference an unknown tool key would silently grant nothing;
|
||||
#: this set lets a test assert the seed data and the catalog agree.
|
||||
KNOWN_TOOL_KEYS: frozenset[str] = frozenset(_BY_KEY)
|
||||
|
||||
|
||||
def get_tool_spec(key: str) -> ToolSpec | None:
|
||||
return _BY_KEY.get(key)
|
||||
|
||||
|
||||
def is_known_tool(key: str) -> bool:
|
||||
return key in _BY_KEY
|
||||
|
||||
|
||||
def tool_requires_approval(key: str) -> bool:
|
||||
"""Unknown tools are treated as requiring approval, never as safe."""
|
||||
spec = _BY_KEY.get(key)
|
||||
return True if spec is None else spec.requires_approval
|
||||
|
||||
|
||||
def is_destructive(key: str) -> bool:
|
||||
spec = _BY_KEY.get(key)
|
||||
return False if spec is None else spec.destructive
|
||||
@@ -0,0 +1,326 @@
|
||||
"""Caddy adapter for :class:`DomainRouterPort`.
|
||||
|
||||
Publishes tenant preview domains by maintaining a *managed snippet file* that the
|
||||
main Caddyfile imports, then reloading Caddy. The main Caddyfile is never edited
|
||||
by this adapter.
|
||||
|
||||
Why a snippet instead of editing the Caddyfile
|
||||
---------------------------------------------
|
||||
Rewriting an operator's Caddyfile means parsing and re-emitting a file we do not
|
||||
own; a mistake there takes down every site on the host. A separate file that
|
||||
Caddy imports by glob keeps the blast radius to tenant preview routes only.
|
||||
|
||||
Setup (one line, once)
|
||||
----------------------
|
||||
Add this to the Caddyfile and reload Caddy::
|
||||
|
||||
import /etc/caddy/agy-preview/*.caddy
|
||||
|
||||
The adapter refuses to run if that import is missing, because silently writing a
|
||||
file that Caddy never reads would report success for a domain that does not
|
||||
resolve.
|
||||
|
||||
Security rules enforced here, not only upstream
|
||||
-----------------------------------------------
|
||||
* the host must be a single label under ``PREVIEW_ROOT_DOMAIN``
|
||||
(:func:`validate_managed_host`) — this is what stops a crafted host from
|
||||
closing the site block and injecting directives
|
||||
* the proxy target must be loopback with a numeric port (:func:`validate_proxy_target`)
|
||||
— SSRF prevention
|
||||
* protected platform ports are refused (:func:`assert_not_protected_port`)
|
||||
* the rendered config is validated *before* it is loaded, and every failed
|
||||
change is rolled back to the previous file
|
||||
|
||||
The ``caddy`` binary runs on the platform host. That is a different trust domain
|
||||
from tenant code: tenant commands only ever run inside a sandbox adapter.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from app.application.ports import PreviewDomainInfo, ReverseProxyResult
|
||||
from app.core.errors import ProviderUnavailableError
|
||||
from app.domain.platform.rules import (
|
||||
RuleViolation,
|
||||
assert_not_protected_port,
|
||||
assert_port_allocatable,
|
||||
build_host,
|
||||
validate_managed_host,
|
||||
validate_proxy_target,
|
||||
)
|
||||
|
||||
_BEGIN = "# >>> agy-managed: {host}"
|
||||
_END = "# <<< agy-managed: {host}"
|
||||
_BLOCK_RE = re.compile(
|
||||
r"# >>> agy-managed: (?P<host>\S+)\n(?P<body>.*?)# <<< agy-managed: (?P=host)\n",
|
||||
re.DOTALL,
|
||||
)
|
||||
_VALIDATE_TIMEOUT_SECONDS = 15
|
||||
_RELOAD_TIMEOUT_SECONDS = 30
|
||||
|
||||
|
||||
class CaddyDomainRouterAdapter:
|
||||
"""Reverse-proxy routes managed through a Caddy snippet file."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
caddyfile_path: str,
|
||||
root_domain: str,
|
||||
port_start: int = 8080,
|
||||
port_end: int = 8999,
|
||||
snippet_dir: str | None = None,
|
||||
caddy_binary: str = "caddy",
|
||||
) -> None:
|
||||
self.caddyfile_path = Path(caddyfile_path)
|
||||
self.root_domain = root_domain
|
||||
self.port_start = port_start
|
||||
self.port_end = port_end
|
||||
self.caddy_binary = caddy_binary
|
||||
|
||||
# Default to a sibling directory of the Caddyfile so the import glob is
|
||||
# a short, predictable path.
|
||||
self.snippet_dir = (
|
||||
Path(snippet_dir)
|
||||
if snippet_dir
|
||||
else self.caddyfile_path.parent / "agy-preview"
|
||||
)
|
||||
self.snippet_path = self.snippet_dir / "preview.caddy"
|
||||
|
||||
# -- port allocation -----------------------------------------------------
|
||||
|
||||
async def allocate_port(
|
||||
self, *, organization_id: str, project_id: str, service_name: str
|
||||
) -> int:
|
||||
"""Pick the lowest free port that is not already routed.
|
||||
|
||||
The database is the authoritative port ledger
|
||||
(``PreviewDomainRepository``); this only avoids handing out a port that a
|
||||
live route already occupies.
|
||||
"""
|
||||
in_use = {entry.port for entry in self._parse(self._read_snippet())}
|
||||
for port in range(self.port_start, self.port_end + 1):
|
||||
if port in in_use:
|
||||
continue
|
||||
try:
|
||||
assert_port_allocatable(port, start=self.port_start, end=self.port_end)
|
||||
except RuleViolation:
|
||||
continue
|
||||
return port
|
||||
raise ProviderUnavailableError("پورت آزادی در بازهٔ تعیینشده وجود ندارد.")
|
||||
|
||||
async def claim_preview_domain(
|
||||
self,
|
||||
*,
|
||||
organization_id: str,
|
||||
project_id: str,
|
||||
requested_subdomain: str,
|
||||
port: int,
|
||||
) -> PreviewDomainInfo:
|
||||
host = build_host(requested_subdomain, self.root_domain)
|
||||
result = await self.configure_reverse_proxy(host=host, port=port)
|
||||
if not result.ok:
|
||||
raise RuleViolation(result.message or "پیکربندی دامنه ناموفق بود.")
|
||||
return PreviewDomainInfo(host=host, port=port, url=result.url, status="active")
|
||||
|
||||
# -- routing -------------------------------------------------------------
|
||||
|
||||
async def configure_reverse_proxy(self, *, host: str, port: int) -> ReverseProxyResult:
|
||||
"""Create or replace the route for ``host`` and reload Caddy."""
|
||||
try:
|
||||
clean_host = validate_managed_host(host, self.root_domain)
|
||||
# Raises unless the target is loopback with a numeric port.
|
||||
validate_proxy_target(f"localhost:{port}")
|
||||
assert_port_allocatable(port, start=self.port_start, end=self.port_end)
|
||||
assert_not_protected_port(port)
|
||||
except RuleViolation as exc:
|
||||
# A rejected rule is a caller error, not an outage.
|
||||
return ReverseProxyResult(ok=False, message=str(exc))
|
||||
|
||||
if not self._import_is_wired():
|
||||
return ReverseProxyResult(
|
||||
ok=False,
|
||||
message=(
|
||||
"فایل Caddyfile خط import مربوط به AGY را ندارد. "
|
||||
f"این خط را اضافه کنید: import {self.snippet_dir}/*.caddy"
|
||||
),
|
||||
)
|
||||
|
||||
previous = self._read_snippet()
|
||||
entries = [e for e in self._parse(previous) if e.host != clean_host]
|
||||
entries.append(_Entry(host=clean_host, port=port))
|
||||
updated = self._render(entries)
|
||||
|
||||
self._write_snippet(updated)
|
||||
error = await self._validate()
|
||||
if error is None:
|
||||
error = await self._reload()
|
||||
|
||||
if error is not None:
|
||||
# Roll back so a failed change never leaves a half-written config.
|
||||
self._write_snippet(previous)
|
||||
await self._reload()
|
||||
return ReverseProxyResult(ok=False, message=error)
|
||||
|
||||
return ReverseProxyResult(
|
||||
ok=True, message="مسیر دامنه ثبت و Caddy بارگذاری مجدد شد.", url=f"https://{clean_host}"
|
||||
)
|
||||
|
||||
async def remove_preview_domain(self, *, domain_id: str) -> None:
|
||||
"""Remove a route.
|
||||
|
||||
``domain_id`` is the host, matching how the application layer calls this
|
||||
(``remove_preview_domain(domain_id=record["host"])``).
|
||||
"""
|
||||
try:
|
||||
clean_host = validate_managed_host(domain_id, self.root_domain)
|
||||
except RuleViolation:
|
||||
# Not one of ours — nothing to remove, and not an error.
|
||||
return
|
||||
|
||||
previous = self._read_snippet()
|
||||
entries = [e for e in self._parse(previous) if e.host != clean_host]
|
||||
if len(entries) == len(self._parse(previous)):
|
||||
return
|
||||
|
||||
self._write_snippet(self._render(entries))
|
||||
error = await self._validate()
|
||||
if error is None:
|
||||
error = await self._reload()
|
||||
if error is not None:
|
||||
self._write_snippet(previous)
|
||||
await self._reload()
|
||||
|
||||
async def list_project_domains(self, *, project_id: str) -> list[PreviewDomainInfo]:
|
||||
"""Every managed route in the snippet.
|
||||
|
||||
NOT tenant-scoped: the snippet records hosts and ports, not projects, and
|
||||
``configure_reverse_proxy`` is not given a project id. The database
|
||||
(``PreviewDomainRepository``) is the authoritative, tenant-scoped ledger —
|
||||
never use this method for authorization.
|
||||
"""
|
||||
return [
|
||||
PreviewDomainInfo(host=e.host, port=e.port, url=f"https://{e.host}")
|
||||
for e in self._parse(self._read_snippet())
|
||||
]
|
||||
|
||||
# -- file handling -------------------------------------------------------
|
||||
|
||||
def _read_snippet(self) -> str:
|
||||
if not self.snippet_path.exists():
|
||||
return ""
|
||||
return self.snippet_path.read_text(encoding="utf-8")
|
||||
|
||||
def _write_snippet(self, content: str) -> None:
|
||||
"""Write atomically: a reader must never see a partial file."""
|
||||
try:
|
||||
self.snippet_dir.mkdir(parents=True, exist_ok=True)
|
||||
tmp = self.snippet_path.with_suffix(".tmp")
|
||||
tmp.write_text(content, encoding="utf-8")
|
||||
tmp.replace(self.snippet_path)
|
||||
except OSError as exc:
|
||||
raise ProviderUnavailableError(
|
||||
f"نوشتن فایل پیکربندی Caddy ناموفق بود: {exc}"
|
||||
) from exc
|
||||
|
||||
def _import_is_wired(self) -> bool:
|
||||
"""Confirm the Caddyfile actually imports our snippet."""
|
||||
try:
|
||||
text = self.caddyfile_path.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return False
|
||||
needle = str(self.snippet_dir)
|
||||
return any(
|
||||
line.strip().startswith("import") and needle in line
|
||||
for line in text.splitlines()
|
||||
)
|
||||
|
||||
# -- rendering and parsing ----------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _render(entries: list[_Entry]) -> str:
|
||||
header = (
|
||||
"# This file is generated and owned by AGY (web preview domains).\n"
|
||||
"# Manual edits will be overwritten. Managed by CaddyDomainRouterAdapter.\n\n"
|
||||
)
|
||||
blocks = []
|
||||
for entry in sorted(entries, key=lambda e: e.host):
|
||||
blocks.append(
|
||||
f"{_BEGIN.format(host=entry.host)}\n"
|
||||
f"{entry.host} {{\n"
|
||||
f"\treverse_proxy localhost:{entry.port}\n"
|
||||
f"}}\n"
|
||||
f"{_END.format(host=entry.host)}\n"
|
||||
)
|
||||
return header + "\n".join(blocks)
|
||||
|
||||
@staticmethod
|
||||
def _parse(text: str) -> list[_Entry]:
|
||||
entries: list[_Entry] = []
|
||||
for match in _BLOCK_RE.finditer(text or ""):
|
||||
port_match = re.search(r"reverse_proxy\s+localhost:(\d+)", match.group("body"))
|
||||
if not port_match:
|
||||
continue
|
||||
entries.append(_Entry(host=match.group("host"), port=int(port_match.group(1))))
|
||||
return entries
|
||||
|
||||
# -- caddy process -------------------------------------------------------
|
||||
|
||||
async def _run(self, *args: str, timeout: int) -> tuple[int, str]:
|
||||
"""Run the ``caddy`` binary. Platform process, never tenant code."""
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
self.caddy_binary,
|
||||
*args,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
raise ProviderUnavailableError(
|
||||
f"اجرایی «{self.caddy_binary}» پیدا نشد؛ برای DOMAIN_ROUTER=caddy لازم است."
|
||||
) from exc
|
||||
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout)
|
||||
except TimeoutError:
|
||||
process.kill()
|
||||
await process.wait()
|
||||
return 1, "عملیات Caddy زمانبر شد."
|
||||
|
||||
combined = (stderr or stdout or b"").decode("utf-8", errors="replace").strip()
|
||||
return process.returncode or 0, combined
|
||||
|
||||
async def _validate(self) -> str | None:
|
||||
"""Return an error message, or ``None`` when the config is valid."""
|
||||
code, output = await self._run(
|
||||
"validate", "--config", str(self.caddyfile_path),
|
||||
timeout=_VALIDATE_TIMEOUT_SECONDS,
|
||||
)
|
||||
if code != 0:
|
||||
return f"پیکربندی Caddy نامعتبر است: {output[:400]}"
|
||||
return None
|
||||
|
||||
async def _reload(self) -> str | None:
|
||||
code, output = await self._run(
|
||||
"reload", "--config", str(self.caddyfile_path),
|
||||
timeout=_RELOAD_TIMEOUT_SECONDS,
|
||||
)
|
||||
if code != 0:
|
||||
return f"بارگذاری مجدد Caddy ناموفق بود: {output[:400]}"
|
||||
return None
|
||||
|
||||
|
||||
class _Entry:
|
||||
"""One managed route."""
|
||||
|
||||
__slots__ = ("host", "port")
|
||||
|
||||
def __init__(self, *, host: str, port: int) -> None:
|
||||
self.host = host
|
||||
self.port = port
|
||||
|
||||
def __eq__(self, other: object) -> bool:
|
||||
return isinstance(other, _Entry) and (self.host, self.port) == (other.host, other.port)
|
||||
@@ -0,0 +1,412 @@
|
||||
"""Gitea adapter for :class:`GitProviderPort`.
|
||||
|
||||
Talked to over the HTTP API with an admin token. The token is held by this
|
||||
adapter only and is never placed on an object that a response could serialise.
|
||||
|
||||
Design decisions
|
||||
----------------
|
||||
|
||||
**The adapter is stateless.** It is handed ``owner`` / ``repo_name`` by the
|
||||
application layer instead of keeping a ``project_id`` map. A map would be lost on
|
||||
restart and could drift from ``ProjectRepository``, so the database stays the one
|
||||
source of truth for repository identity.
|
||||
|
||||
**Repositories live in one namespace, credentials are per project.** All
|
||||
repositories are created under a single owner (the configured admin user) so
|
||||
tenant repos cannot collide, and every project gets its *own* Gitea user that is
|
||||
added as a collaborator on that one repository. A leaked clone credential
|
||||
therefore reaches exactly one repository — least privilege rather than a shared
|
||||
platform token.
|
||||
|
||||
**Names are re-validated here.** The application layer already produces safe
|
||||
slugs, but the adapter must not depend on a caller behaving well: an unvalidated
|
||||
name is the classic path to a path-traversal or an unintended API path segment.
|
||||
|
||||
Reference: ``docs/current-infra-notes.md`` §4.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import re
|
||||
import secrets
|
||||
from urllib.parse import quote
|
||||
|
||||
import httpx
|
||||
|
||||
from app.application.ports import (
|
||||
BranchInfo,
|
||||
CommitInfo,
|
||||
GitCredentials,
|
||||
MergeResult,
|
||||
PullRequestInfo,
|
||||
RepoInfo,
|
||||
RepoStatus,
|
||||
)
|
||||
from app.core.errors import ProviderUnavailableError, ValidationError
|
||||
|
||||
#: Gitea accepts ``[a-zA-Z0-9._-]`` for repository names; we are stricter and
|
||||
#: keep the platform's lowercase-slug convention.
|
||||
_REPO_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9._-]{0,99}$")
|
||||
#: Gitea usernames: alphanumeric plus ``-``/``_``, at most 40 characters, and
|
||||
#: they may not start or end with a separator.
|
||||
_USERNAME_RE = re.compile(r"^[a-z0-9]([a-z0-9_-]{1,38}[a-z0-9])$")
|
||||
|
||||
DEFAULT_TIMEOUT_SECONDS = 20.0
|
||||
#: Cloning/committing can be slower than a metadata read.
|
||||
WRITE_TIMEOUT_SECONDS = 60.0
|
||||
|
||||
|
||||
class GiteaGitAdapter:
|
||||
"""Git provider backed by a Gitea instance."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
base_url: str,
|
||||
admin_user: str,
|
||||
admin_token: str,
|
||||
owner: str | None = None,
|
||||
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
|
||||
) -> None:
|
||||
if not base_url:
|
||||
raise ProviderUnavailableError(
|
||||
"GIT_PROVIDER=gitea نیازمند تنظیم GITEA_INTERNAL_URL یا GITEA_URL است."
|
||||
)
|
||||
if not admin_token:
|
||||
raise ProviderUnavailableError(
|
||||
"GIT_PROVIDER=gitea نیازمند تنظیم GITEA_ADMIN_TOKEN است."
|
||||
)
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.admin_user = admin_user
|
||||
self._admin_token = admin_token
|
||||
#: Namespace that owns every project repository.
|
||||
self.owner = owner or admin_user
|
||||
self.timeout_seconds = timeout_seconds
|
||||
|
||||
# -- plumbing ------------------------------------------------------------
|
||||
|
||||
def _client(self, *, write: bool = False) -> httpx.AsyncClient:
|
||||
return httpx.AsyncClient(
|
||||
base_url=self.base_url,
|
||||
headers={
|
||||
"Authorization": f"token {self._admin_token}",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
timeout=WRITE_TIMEOUT_SECONDS if write else self.timeout_seconds,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _sanitize_repo_name(repo_name: str) -> str:
|
||||
clean = (repo_name or "").strip().lower()
|
||||
if not _REPO_NAME_RE.match(clean):
|
||||
raise ValidationError(
|
||||
"نام مخزن نامعتبر است؛ فقط حروف کوچک، عدد، نقطه، خط تیره و زیرخط مجاز است."
|
||||
)
|
||||
return clean
|
||||
|
||||
@staticmethod
|
||||
def _sanitize_username(username: str) -> str:
|
||||
clean = (username or "").strip().lower()
|
||||
if not _USERNAME_RE.match(clean):
|
||||
raise ValidationError("نام کاربری مخزن نامعتبر است.")
|
||||
return clean
|
||||
|
||||
@staticmethod
|
||||
def _seg(value: str) -> str:
|
||||
"""Percent-encode a path segment so it cannot break out of the path."""
|
||||
return quote(value, safe="")
|
||||
|
||||
async def _send(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
write: bool = False,
|
||||
expect: tuple[int, ...] = (200, 201),
|
||||
**kwargs,
|
||||
) -> httpx.Response:
|
||||
"""Perform a request and translate transport failures.
|
||||
|
||||
A network problem must surface as ``ProviderUnavailableError`` (a 503),
|
||||
never as a raw ``httpx`` exception leaking through the API.
|
||||
"""
|
||||
try:
|
||||
async with self._client(write=write) as client:
|
||||
response = await client.request(method, path, **kwargs)
|
||||
except httpx.TimeoutException as exc:
|
||||
raise ProviderUnavailableError("ارتباط با Gitea زمانبر شد.") from exc
|
||||
except httpx.HTTPError as exc:
|
||||
raise ProviderUnavailableError("ارتباط با Gitea برقرار نشد.") from exc
|
||||
|
||||
if response.status_code not in expect:
|
||||
raise ProviderUnavailableError(
|
||||
f"Gitea پاسخ ناموفق داد ({response.status_code}) برای {method} {path}."
|
||||
)
|
||||
return response
|
||||
|
||||
async def _get_json(self, path: str, *, expect: tuple[int, ...] = (200,)) -> dict | list | None:
|
||||
"""GET that tolerates 404 by returning ``None``."""
|
||||
try:
|
||||
async with self._client() as client:
|
||||
response = await client.get(path)
|
||||
except httpx.HTTPError as exc:
|
||||
raise ProviderUnavailableError("ارتباط با Gitea برقرار نشد.") from exc
|
||||
if response.status_code == 404:
|
||||
return None
|
||||
if response.status_code not in expect:
|
||||
raise ProviderUnavailableError(
|
||||
f"Gitea پاسخ ناموفق داد ({response.status_code}) برای {path}."
|
||||
)
|
||||
return response.json()
|
||||
|
||||
# -- repository lifecycle ------------------------------------------------
|
||||
|
||||
async def ensure_project_repo(
|
||||
self, *, project_id: str, repo_name: str, description: str = ""
|
||||
) -> RepoInfo:
|
||||
name = self._sanitize_repo_name(repo_name)
|
||||
owner = self._sanitize_username(self.owner)
|
||||
path = f"/api/v1/repos/{self._seg(owner)}/{self._seg(name)}"
|
||||
|
||||
existing = await self._get_json(path)
|
||||
if existing is None:
|
||||
# The admin endpoint creates the repository inside the owner's
|
||||
# namespace, which a plain user token could not do.
|
||||
await self._send(
|
||||
"POST",
|
||||
f"/api/v1/admin/users/{self._seg(owner)}/repos",
|
||||
json={
|
||||
"name": name,
|
||||
"description": description,
|
||||
"private": True,
|
||||
"auto_init": True,
|
||||
"default_branch": "main",
|
||||
},
|
||||
)
|
||||
existing = await self._get_json(path)
|
||||
if existing is None:
|
||||
raise ProviderUnavailableError("ساخت مخزن در Gitea تأیید نشد.")
|
||||
|
||||
if not isinstance(existing, dict):
|
||||
raise ProviderUnavailableError("پاسخ Gitea برای مخزن قابل تفسیر نبود.")
|
||||
return self._to_repo_info(existing, fallback_name=name, fallback_owner=owner)
|
||||
|
||||
def _to_repo_info(self, payload: dict, *, fallback_name: str, fallback_owner: str) -> RepoInfo:
|
||||
owner = ((payload.get("owner") or {}).get("login")) or fallback_owner
|
||||
name = payload.get("name") or fallback_name
|
||||
return RepoInfo(
|
||||
owner=owner,
|
||||
repo_name=name,
|
||||
clone_url=payload.get("clone_url") or f"{self.base_url}/{owner}/{name}.git",
|
||||
web_url=payload.get("html_url") or f"{self.base_url}/{owner}/{name}",
|
||||
default_branch=payload.get("default_branch") or "main",
|
||||
# Never let a remote payload make a tenant repository public.
|
||||
private=True,
|
||||
)
|
||||
|
||||
async def ensure_credentials(
|
||||
self, *, project_id: str, owner: str = "", repo_name: str = ""
|
||||
) -> GitCredentials:
|
||||
"""Create (or reset) a dedicated Gitea user for this project.
|
||||
|
||||
The user is added as a *collaborator on one repository only*, so the
|
||||
returned password cannot reach any other tenant's code.
|
||||
"""
|
||||
repo_owner = self._sanitize_username(owner or self.owner)
|
||||
name = self._sanitize_repo_name(repo_name)
|
||||
username = self._sanitize_username(f"proj-{project_id[:24]}")
|
||||
|
||||
# A long random password, generated fresh every call. Gitea hashes it.
|
||||
password = secrets.token_urlsafe(24)
|
||||
email = f"{username}@users.noreply.agy.invalid"
|
||||
|
||||
created = await self._send(
|
||||
"POST",
|
||||
"/api/v1/admin/users",
|
||||
expect=(200, 201, 422),
|
||||
json={
|
||||
"username": username,
|
||||
"email": email,
|
||||
"password": password,
|
||||
"must_change_password": False,
|
||||
"visibility": "private",
|
||||
},
|
||||
)
|
||||
if created.status_code == 422:
|
||||
# Already exists — rotate its password instead of failing, so a
|
||||
# retried `ensure_credentials` stays idempotent.
|
||||
await self._send(
|
||||
"PATCH",
|
||||
f"/api/v1/admin/users/{self._seg(username)}",
|
||||
json={"password": password, "must_change_password": False},
|
||||
)
|
||||
|
||||
# Grant write access to exactly this repository.
|
||||
await self._send(
|
||||
"PUT",
|
||||
f"/api/v1/repos/{self._seg(repo_owner)}/{self._seg(name)}"
|
||||
f"/collaborators/{self._seg(username)}",
|
||||
json={"permission": "write"},
|
||||
)
|
||||
|
||||
return GitCredentials(username=username, password=password)
|
||||
|
||||
# -- branches and commits ------------------------------------------------
|
||||
|
||||
async def create_branch(
|
||||
self,
|
||||
*,
|
||||
project_id: str,
|
||||
branch_name: str,
|
||||
from_branch: str,
|
||||
owner: str = "",
|
||||
repo_name: str = "",
|
||||
) -> BranchInfo:
|
||||
base_path = self._repo_path(owner, repo_name)
|
||||
response = await self._send(
|
||||
"POST",
|
||||
f"{base_path}/branches",
|
||||
expect=(200, 201),
|
||||
json={"new_branch_name": branch_name, "old_branch_name": from_branch},
|
||||
)
|
||||
payload = response.json()
|
||||
commit = payload.get("commit") or {}
|
||||
return BranchInfo(name=payload.get("name", branch_name), sha=commit.get("id", ""))
|
||||
|
||||
async def commit_files(
|
||||
self,
|
||||
*,
|
||||
project_id: str,
|
||||
branch_name: str,
|
||||
files: dict[str, str],
|
||||
message: str,
|
||||
owner: str = "",
|
||||
repo_name: str = "",
|
||||
) -> CommitInfo:
|
||||
"""Commit every file, one Contents-API call each.
|
||||
|
||||
Gitea's contents endpoint handles a single file per request, so a
|
||||
multi-file change is a sequence of commits. Each update needs the blob's
|
||||
current ``sha``, which we read first.
|
||||
"""
|
||||
base_path = self._repo_path(owner, repo_name)
|
||||
last_sha = ""
|
||||
changed = 0
|
||||
|
||||
for raw_path, content in files.items():
|
||||
path = self._safe_content_path(raw_path)
|
||||
encoded = quote(path, safe="/")
|
||||
existing = await self._get_json(f"{base_path}/contents/{encoded}?ref={branch_name}")
|
||||
body = {
|
||||
"content": base64.b64encode(content.encode("utf-8")).decode("ascii"),
|
||||
"message": message,
|
||||
"branch": branch_name,
|
||||
}
|
||||
if isinstance(existing, dict) and existing.get("sha"):
|
||||
body["sha"] = existing["sha"]
|
||||
response = await self._send(
|
||||
"PUT", f"{base_path}/contents/{encoded}", write=True, json=body
|
||||
)
|
||||
else:
|
||||
response = await self._send(
|
||||
"POST", f"{base_path}/contents/{encoded}", write=True, json=body
|
||||
)
|
||||
payload = response.json()
|
||||
last_sha = (payload.get("commit") or {}).get("sha") or last_sha
|
||||
changed += 1
|
||||
|
||||
return CommitInfo(
|
||||
sha=last_sha, branch=branch_name, message=message, files_changed=changed
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _safe_content_path(raw_path: str) -> str:
|
||||
"""Reject absolute paths and traversal before they reach the URL."""
|
||||
clean = (raw_path or "").strip().lstrip("/")
|
||||
if not clean:
|
||||
raise ValidationError("مسیر فایل نامعتبر است.")
|
||||
segments = clean.split("/")
|
||||
if any(seg in {"", ".", ".."} for seg in segments):
|
||||
raise ValidationError("مسیر فایل نامعتبر است.")
|
||||
return "/".join(segments)
|
||||
|
||||
# -- pull requests -------------------------------------------------------
|
||||
|
||||
async def open_pull_request(
|
||||
self,
|
||||
*,
|
||||
project_id: str,
|
||||
head: str,
|
||||
base: str,
|
||||
title: str,
|
||||
body: str = "",
|
||||
owner: str = "",
|
||||
repo_name: str = "",
|
||||
) -> PullRequestInfo:
|
||||
response = await self._send(
|
||||
"POST",
|
||||
f"{self._repo_path(owner, repo_name)}/pulls",
|
||||
expect=(200, 201),
|
||||
json={"head": head, "base": base, "title": title, "body": body},
|
||||
)
|
||||
payload = response.json()
|
||||
return PullRequestInfo(
|
||||
number=int(payload.get("number", 0)),
|
||||
url=payload.get("html_url", ""),
|
||||
state=payload.get("state", "open"),
|
||||
)
|
||||
|
||||
async def merge_pull_request(
|
||||
self, *, project_id: str, pr_number: int, owner: str = "", repo_name: str = ""
|
||||
) -> MergeResult:
|
||||
try:
|
||||
await self._send(
|
||||
"POST",
|
||||
f"{self._repo_path(owner, repo_name)}/pulls/{int(pr_number)}/merge",
|
||||
json={"Do": "merge"},
|
||||
)
|
||||
except ProviderUnavailableError as exc:
|
||||
# A merge conflict is a normal outcome, not an outage.
|
||||
return MergeResult(merged=False, message=str(exc))
|
||||
return MergeResult(merged=True, message="PR ادغام شد.")
|
||||
|
||||
# -- status --------------------------------------------------------------
|
||||
|
||||
async def get_repo_status(
|
||||
self, *, project_id: str, owner: str = "", repo_name: str = ""
|
||||
) -> RepoStatus:
|
||||
base_path = self._repo_path(owner, repo_name)
|
||||
repo = await self._get_json(base_path)
|
||||
if repo is None:
|
||||
return RepoStatus(exists=False)
|
||||
if not isinstance(repo, dict):
|
||||
raise ProviderUnavailableError("پاسخ Gitea برای مخزن قابل تفسیر نبود.")
|
||||
|
||||
branches: list[str] = []
|
||||
branch_payload = await self._get_json(f"{base_path}/branches")
|
||||
if isinstance(branch_payload, list):
|
||||
branches = sorted(b.get("name", "") for b in branch_payload if b.get("name"))
|
||||
|
||||
open_prs = 0
|
||||
pr_payload = await self._get_json(f"{base_path}/pulls?state=open&limit=50")
|
||||
if isinstance(pr_payload, list):
|
||||
open_prs = len(pr_payload)
|
||||
|
||||
return RepoStatus(
|
||||
exists=True,
|
||||
private=True,
|
||||
default_branch=repo.get("default_branch") or "main",
|
||||
branches=branches,
|
||||
open_pull_requests=open_prs,
|
||||
)
|
||||
|
||||
# -- helpers -------------------------------------------------------------
|
||||
|
||||
def _repo_path(self, owner: str, repo_name: str) -> str:
|
||||
"""Build ``/api/v1/repos/{owner}/{repo}`` from validated segments."""
|
||||
if not repo_name:
|
||||
raise ValidationError("نام مخزن مشخص نیست.")
|
||||
clean_owner = self._sanitize_username(owner or self.owner)
|
||||
clean_repo = self._sanitize_repo_name(repo_name)
|
||||
return f"/api/v1/repos/{self._seg(clean_owner)}/{self._seg(clean_repo)}"
|
||||
@@ -0,0 +1,520 @@
|
||||
"""Mock adapters — every port has one so the stack runs with zero credentials.
|
||||
|
||||
These are not toys: they enforce the same contracts (timeouts, output caps,
|
||||
validation) as the real adapters, so switching a ``*_PROVIDER`` env var does not
|
||||
change application behaviour.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import secrets
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Iterable
|
||||
from typing import Any
|
||||
|
||||
from app.application.ports import (
|
||||
BranchInfo,
|
||||
ChatMessage,
|
||||
CheckoutSession,
|
||||
CommandResult,
|
||||
CommitInfo,
|
||||
GitCredentials,
|
||||
MergeResult,
|
||||
ModelHealth,
|
||||
ModelResponse,
|
||||
Notification,
|
||||
PaymentResult,
|
||||
PreviewDomainInfo,
|
||||
PullRequestInfo,
|
||||
RepoInfo,
|
||||
RepoStatus,
|
||||
ReverseProxyResult,
|
||||
SessionInfo,
|
||||
SnapshotInfo,
|
||||
StoredObject,
|
||||
VectorHit,
|
||||
WorkspaceInfo,
|
||||
)
|
||||
from app.domain.ai_control.models import AiModel
|
||||
from app.domain.platform.rules import (
|
||||
assert_port_allocatable,
|
||||
build_host,
|
||||
validate_managed_host,
|
||||
validate_proxy_target,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Model
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class MockModelProviderAdapter:
|
||||
"""Deterministic, offline stand-in for an LLM.
|
||||
|
||||
Returns a canned Persian acknowledgement that names the task so tests can
|
||||
assert on it without a network call.
|
||||
"""
|
||||
|
||||
def __init__(self, latency_ms: int = 0) -> None:
|
||||
self._latency_ms = latency_ms
|
||||
|
||||
async def complete(
|
||||
self,
|
||||
*,
|
||||
model: AiModel,
|
||||
credential=None,
|
||||
messages: list[ChatMessage],
|
||||
system: str = "",
|
||||
temperature: float = 0.2,
|
||||
max_tokens: int = 2048,
|
||||
) -> ModelResponse:
|
||||
started = time.perf_counter()
|
||||
if self._latency_ms:
|
||||
await asyncio.sleep(self._latency_ms / 1000)
|
||||
last_user = next((m.content for m in reversed(messages) if m.role == "user"), "")
|
||||
prompt_tokens = sum(len(m.content.split()) for m in messages)
|
||||
# The canned text must read like model output, not like a debug tag: the
|
||||
# provider/model identity is platform-internal and must never surface in
|
||||
# anything a customer can read (see ``test_run_payload_never_leaks_model_identity``).
|
||||
content = (
|
||||
"این پاسخ آزمایشی است. "
|
||||
f"درخواست دریافتی: {last_user[:280] or '—'}"
|
||||
)
|
||||
return ModelResponse(
|
||||
content=content,
|
||||
model_internal_name=model.internal_name,
|
||||
input_tokens=prompt_tokens + len(system.split()),
|
||||
output_tokens=len(content.split()),
|
||||
latency_ms=int((time.perf_counter() - started) * 1000),
|
||||
raw={"mock": True},
|
||||
)
|
||||
|
||||
async def health(self) -> ModelHealth:
|
||||
return ModelHealth(ok=True, detail="mock provider")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Git
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class MockGitAdapter:
|
||||
"""In-memory Git provider. Nothing touches a real forge.
|
||||
|
||||
``owner`` / ``repo_name`` are accepted for interface parity with the Gitea
|
||||
adapter but ignored here — this adapter keys everything by ``project_id``.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._repos: dict[str, RepoInfo] = {}
|
||||
self._credentials: dict[str, GitCredentials] = {}
|
||||
self._branches: dict[str, dict[str, str]] = {}
|
||||
self._prs: dict[str, list[PullRequestInfo]] = {}
|
||||
|
||||
async def ensure_project_repo(
|
||||
self, *, project_id: str, repo_name: str, description: str = ""
|
||||
) -> RepoInfo:
|
||||
if project_id not in self._repos:
|
||||
self._repos[project_id] = RepoInfo(
|
||||
owner="mock",
|
||||
repo_name=repo_name,
|
||||
clone_url=f"https://git.example.invalid/mock/{repo_name}.git",
|
||||
web_url=f"https://git.example.invalid/mock/{repo_name}",
|
||||
default_branch="main",
|
||||
private=True,
|
||||
)
|
||||
self._branches[project_id] = {"main": hashlib.sha1(b"init").hexdigest()[:40]}
|
||||
self._prs[project_id] = []
|
||||
return self._repos[project_id]
|
||||
|
||||
async def ensure_credentials(
|
||||
self, *, project_id: str, owner: str = "", repo_name: str = ""
|
||||
) -> GitCredentials:
|
||||
if project_id not in self._credentials:
|
||||
self._credentials[project_id] = GitCredentials(
|
||||
username=f"proj_{project_id[:8]}",
|
||||
password=secrets.token_urlsafe(12),
|
||||
)
|
||||
return self._credentials[project_id]
|
||||
|
||||
async def create_branch(
|
||||
self,
|
||||
*,
|
||||
project_id: str,
|
||||
branch_name: str,
|
||||
from_branch: str,
|
||||
owner: str = "",
|
||||
repo_name: str = "",
|
||||
) -> BranchInfo:
|
||||
branches = self._branches.setdefault(project_id, {"main": "0" * 40})
|
||||
base = branches.get(from_branch, "0" * 40)
|
||||
sha = hashlib.sha1(f"{base}:{branch_name}".encode()).hexdigest()[:40]
|
||||
branches[branch_name] = sha
|
||||
return BranchInfo(name=branch_name, sha=sha)
|
||||
|
||||
async def commit_files(
|
||||
self,
|
||||
*,
|
||||
project_id: str,
|
||||
branch_name: str,
|
||||
files: dict[str, str],
|
||||
message: str,
|
||||
owner: str = "",
|
||||
repo_name: str = "",
|
||||
) -> CommitInfo:
|
||||
digest = hashlib.sha1(
|
||||
f"{branch_name}:{message}:{sorted(files)}".encode()
|
||||
).hexdigest()[:40]
|
||||
self._branches.setdefault(project_id, {})[branch_name] = digest
|
||||
return CommitInfo(
|
||||
sha=digest, branch=branch_name, message=message, files_changed=len(files)
|
||||
)
|
||||
|
||||
async def open_pull_request(
|
||||
self,
|
||||
*,
|
||||
project_id: str,
|
||||
head: str,
|
||||
base: str,
|
||||
title: str,
|
||||
body: str = "",
|
||||
owner: str = "",
|
||||
repo_name: str = "",
|
||||
) -> PullRequestInfo:
|
||||
prs = self._prs.setdefault(project_id, [])
|
||||
number = len(prs) + 1
|
||||
repo = self._repos.get(project_id)
|
||||
web = repo.web_url if repo else "https://git.example.invalid/mock"
|
||||
pr = PullRequestInfo(number=number, url=f"{web}/pulls/{number}", state="open")
|
||||
prs.append(pr)
|
||||
return pr
|
||||
|
||||
async def merge_pull_request(
|
||||
self, *, project_id: str, pr_number: int, owner: str = "", repo_name: str = ""
|
||||
) -> MergeResult:
|
||||
prs = self._prs.setdefault(project_id, [])
|
||||
for pr in prs:
|
||||
if pr.number == pr_number:
|
||||
pr.state = "merged"
|
||||
return MergeResult(merged=True, message="ادغام شد (mock).")
|
||||
return MergeResult(merged=False, message="PR یافت نشد.")
|
||||
|
||||
async def get_repo_status(
|
||||
self, *, project_id: str, owner: str = "", repo_name: str = ""
|
||||
) -> RepoStatus:
|
||||
repo = self._repos.get(project_id)
|
||||
if repo is None:
|
||||
return RepoStatus(exists=False)
|
||||
return RepoStatus(
|
||||
exists=True,
|
||||
private=repo.private,
|
||||
default_branch=repo.default_branch,
|
||||
branches=sorted(self._branches.get(project_id, {}).keys()),
|
||||
open_pull_requests=sum(
|
||||
1 for pr in self._prs.get(project_id, []) if pr.state == "open"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Domain router
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class MockDomainRouterAdapter:
|
||||
"""In-memory port + subdomain ledger.
|
||||
|
||||
Applies the *same* validation rules as the real Caddy adapter, so tests that
|
||||
pass here describe behaviour the production adapter also guarantees.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
root_domain: str = "msa.artacloud.ir",
|
||||
port_start: int = 8080,
|
||||
port_end: int = 8999,
|
||||
) -> None:
|
||||
self.root_domain = root_domain
|
||||
self.port_start = port_start
|
||||
self.port_end = port_end
|
||||
self._ports: dict[tuple[str, str], int] = {}
|
||||
self._domains: dict[str, PreviewDomainInfo] = {}
|
||||
self._project_domains: dict[str, list[str]] = {}
|
||||
|
||||
async def allocate_port(
|
||||
self, *, organization_id: str, project_id: str, service_name: str
|
||||
) -> int:
|
||||
key = (project_id, service_name)
|
||||
if key in self._ports:
|
||||
return self._ports[key]
|
||||
used = set(self._ports.values())
|
||||
for port in range(self.port_start, self.port_end + 1):
|
||||
if port in used:
|
||||
continue
|
||||
try:
|
||||
assert_port_allocatable(port, start=self.port_start, end=self.port_end)
|
||||
except Exception:
|
||||
continue
|
||||
self._ports[key] = port
|
||||
return port
|
||||
raise RuntimeError("No available ports in the configured range.")
|
||||
|
||||
async def claim_preview_domain(
|
||||
self,
|
||||
*,
|
||||
organization_id: str,
|
||||
project_id: str,
|
||||
requested_subdomain: str,
|
||||
port: int,
|
||||
) -> PreviewDomainInfo:
|
||||
host = build_host(requested_subdomain, self.root_domain)
|
||||
if host in self._domains:
|
||||
owner_domains = self._project_domains.get(project_id, [])
|
||||
if host not in owner_domains:
|
||||
raise ValueError(f"دامنهٔ {host} قبلاً ثبت شده است.")
|
||||
info = PreviewDomainInfo(
|
||||
host=host, port=port, url=f"https://{host}", status="active"
|
||||
)
|
||||
self._domains[host] = info
|
||||
self._project_domains.setdefault(project_id, []).append(host)
|
||||
return info
|
||||
|
||||
async def configure_reverse_proxy(self, *, host: str, port: int) -> ReverseProxyResult:
|
||||
validate_managed_host(host, self.root_domain)
|
||||
validate_proxy_target(f"localhost:{port}")
|
||||
return ReverseProxyResult(ok=True, message="route ثبت شد (mock).", url=f"https://{host}")
|
||||
|
||||
async def remove_preview_domain(self, *, domain_id: str) -> None:
|
||||
for host, info in list(self._domains.items()):
|
||||
if info.host == domain_id or domain_id in self._project_domains.get(domain_id, []):
|
||||
self._domains.pop(host, None)
|
||||
self._domains.pop(domain_id, None)
|
||||
for hosts in self._project_domains.values():
|
||||
if domain_id in hosts:
|
||||
hosts.remove(domain_id)
|
||||
|
||||
async def list_project_domains(self, *, project_id: str) -> list[PreviewDomainInfo]:
|
||||
return [
|
||||
self._domains[h]
|
||||
for h in self._project_domains.get(project_id, [])
|
||||
if h in self._domains
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sandbox
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class LocalMockSandboxAdapter:
|
||||
"""Executes nothing. Returns plausible results and records commands.
|
||||
|
||||
``run_command`` still honours the timeout and output cap contract so callers
|
||||
cannot depend on behaviour the real adapters will not provide.
|
||||
"""
|
||||
|
||||
def __init__(self, max_output_bytes: int = 200_000) -> None:
|
||||
self._workspaces: dict[str, str] = {}
|
||||
self._sessions: dict[str, str] = {}
|
||||
self._files: dict[str, dict[str, str]] = {}
|
||||
self.commands: list[str] = []
|
||||
self.max_output_bytes = max_output_bytes
|
||||
|
||||
async def create_workspace(
|
||||
self, *, organization_id: str, project_id: str, template_id: str, image: str
|
||||
) -> WorkspaceInfo:
|
||||
external_id = f"ws_{uuid.uuid4().hex[:12]}"
|
||||
self._workspaces[external_id] = image
|
||||
return WorkspaceInfo(external_id=external_id, status="ready", raw={"image": image})
|
||||
|
||||
async def start_session(self, *, workspace_external_id: str) -> SessionInfo:
|
||||
external_id = f"ses_{uuid.uuid4().hex[:12]}"
|
||||
self._sessions[external_id] = workspace_external_id
|
||||
self._files.setdefault(external_id, {})
|
||||
return SessionInfo(external_id=external_id, status="running")
|
||||
|
||||
async def run_command(
|
||||
self,
|
||||
*,
|
||||
session_external_id: str,
|
||||
command: str,
|
||||
timeout_seconds: int,
|
||||
max_output_bytes: int | None = None,
|
||||
env: dict[str, str] | None = None,
|
||||
) -> CommandResult:
|
||||
limit = max_output_bytes or self.max_output_bytes
|
||||
started = time.perf_counter()
|
||||
self.commands.append(command)
|
||||
stdout = f"[mock] اجرا شد: {command}"
|
||||
truncated = len(stdout.encode()) > limit
|
||||
if truncated:
|
||||
stdout = stdout.encode()[:limit].decode(errors="ignore")
|
||||
return CommandResult(
|
||||
exit_code=0,
|
||||
stdout=stdout,
|
||||
stderr="",
|
||||
duration_ms=max(1, int((time.perf_counter() - started) * 1000)),
|
||||
truncated=truncated,
|
||||
)
|
||||
|
||||
async def read_file(self, *, session_external_id: str, path: str) -> str:
|
||||
return self._files.get(session_external_id, {}).get(path, "")
|
||||
|
||||
async def write_file(self, *, session_external_id: str, path: str, content: str) -> None:
|
||||
self._files.setdefault(session_external_id, {})[path] = content
|
||||
|
||||
async def snapshot(self, *, session_external_id: str, name: str) -> SnapshotInfo:
|
||||
return SnapshotInfo(external_id=f"snap_{uuid.uuid4().hex[:12]}", name=name)
|
||||
|
||||
async def pause(self, *, session_external_id: str) -> None:
|
||||
return None
|
||||
|
||||
async def resume(self, *, session_external_id: str) -> SessionInfo:
|
||||
return SessionInfo(external_id=session_external_id, status="running")
|
||||
|
||||
async def destroy(self, *, session_external_id: str) -> None:
|
||||
self._sessions.pop(session_external_id, None)
|
||||
self._files.pop(session_external_id, None)
|
||||
|
||||
async def get_preview_url(self, *, session_external_id: str, port: int) -> str:
|
||||
return f"http://127.0.0.1:{port}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Payment
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class LocalMockPaymentAdapter:
|
||||
async def create_checkout(
|
||||
self,
|
||||
*,
|
||||
organization_id: str,
|
||||
amount: float,
|
||||
currency: str,
|
||||
success_url: str,
|
||||
cancel_url: str,
|
||||
) -> CheckoutSession:
|
||||
external_id = f"mock_cs_{uuid.uuid4().hex[:12]}"
|
||||
return CheckoutSession(
|
||||
external_id=external_id,
|
||||
url=f"{success_url}?mock_checkout={external_id}",
|
||||
amount=amount,
|
||||
currency=currency,
|
||||
)
|
||||
|
||||
async def refund(
|
||||
self, *, external_payment_id: str, amount: float | None = None
|
||||
) -> PaymentResult:
|
||||
return PaymentResult(ok=True, external_id=external_payment_id, message="بازگشت (mock).")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Storage / vector / notification
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class InMemoryStorageAdapter:
|
||||
def __init__(self) -> None:
|
||||
self._objects: dict[str, bytes] = {}
|
||||
|
||||
async def put(self, *, key: str, data: bytes, content_type: str = "") -> StoredObject:
|
||||
self._objects[key] = data
|
||||
return StoredObject(key=key, size_bytes=len(data), url=f"memory://{key}")
|
||||
|
||||
async def get(self, *, key: str) -> bytes:
|
||||
return self._objects.get(key, b"")
|
||||
|
||||
async def delete(self, *, key: str) -> None:
|
||||
self._objects.pop(key, None)
|
||||
|
||||
async def signed_url(self, *, key: str, expires_seconds: int = 900) -> str:
|
||||
return f"memory://{key}?expires={expires_seconds}"
|
||||
|
||||
|
||||
class InMemoryVectorStoreAdapter:
|
||||
"""Keyword-overlap search. Good enough for MVP; swappable for pgvector."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._chunks: dict[str, tuple[str, str, str, dict]] = {}
|
||||
|
||||
async def upsert(
|
||||
self,
|
||||
*,
|
||||
organization_id: str,
|
||||
chunks: Iterable[tuple[str, str, list[float]]],
|
||||
metadata: dict | None = None,
|
||||
) -> int:
|
||||
count = 0
|
||||
for chunk_id, content, _embedding in chunks:
|
||||
document_id = str((metadata or {}).get("document_id", ""))
|
||||
self._chunks[chunk_id] = (organization_id, document_id, content, metadata or {})
|
||||
count += 1
|
||||
return count
|
||||
|
||||
async def search(
|
||||
self,
|
||||
*,
|
||||
organization_id: str,
|
||||
query: str,
|
||||
limit: int = 5,
|
||||
project_id: str | None = None,
|
||||
) -> list[VectorHit]:
|
||||
terms = {t for t in query.lower().split() if t}
|
||||
scored: list[VectorHit] = []
|
||||
for chunk_id, (org, document_id, content, meta) in self._chunks.items():
|
||||
if org != organization_id:
|
||||
continue
|
||||
if project_id and meta.get("project_id") not in (None, project_id):
|
||||
continue
|
||||
overlap = len(terms & set(content.lower().split()))
|
||||
if overlap:
|
||||
scored.append(
|
||||
VectorHit(
|
||||
chunk_id=chunk_id,
|
||||
document_id=document_id,
|
||||
content=content,
|
||||
score=float(overlap),
|
||||
metadata=meta,
|
||||
)
|
||||
)
|
||||
scored.sort(key=lambda h: h.score, reverse=True)
|
||||
return scored[:limit]
|
||||
|
||||
async def delete_document(self, *, document_id: str) -> None:
|
||||
for chunk_id, (_, doc_id, _, _) in list(self._chunks.items()):
|
||||
if doc_id == document_id:
|
||||
self._chunks.pop(chunk_id, None)
|
||||
|
||||
|
||||
class ConsoleNotificationAdapter:
|
||||
"""Records notifications instead of sending them."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.sent: list[Notification] = []
|
||||
|
||||
async def send(self, *, notification: Notification) -> bool:
|
||||
self.sent.append(notification)
|
||||
return True
|
||||
|
||||
|
||||
class InMemoryAuditAdapter:
|
||||
"""In-memory AuditPort, used by unit tests that do not want a database."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
# Records whatever the caller hands over; the port is untyped here.
|
||||
self.events: list[Any] = []
|
||||
|
||||
async def record(self, *, event):
|
||||
self.events.append(event)
|
||||
return event
|
||||
|
||||
async def list_for_organization(
|
||||
self, *, organization_id: str, limit: int = 100, offset: int = 0
|
||||
):
|
||||
matching = [e for e in self.events if e.organization_id == organization_id]
|
||||
return matching[offset : offset + limit]
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Real model adapter for any OpenAI-compatible endpoint.
|
||||
|
||||
Works with OpenAI, OpenRouter, vLLM, LiteLLM, Ollama's OpenAI shim, etc. The
|
||||
API key is read from the credential record (decrypted by the caller), never from
|
||||
the environment at request time.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
from app.application.ports import ChatMessage, ModelHealth, ModelResponse
|
||||
from app.core.errors import ProviderUnavailableError
|
||||
from app.domain.ai_control.models import AiModel, ModelCredential
|
||||
|
||||
|
||||
class OpenAICompatibleModelAdapter:
|
||||
def __init__(self, *, base_url: str, default_api_key: str = "", timeout: float = 120.0) -> None:
|
||||
self.base_url = (base_url or "").rstrip("/")
|
||||
self.default_api_key = default_api_key
|
||||
self.timeout = timeout
|
||||
|
||||
def _resolve_key(self, credential: ModelCredential | None, decrypt) -> str:
|
||||
if credential is not None and credential.encrypted_api_key:
|
||||
try:
|
||||
return decrypt(credential.encrypted_api_key)
|
||||
except Exception: # noqa: BLE001 - fall through to the env default
|
||||
pass
|
||||
return self.default_api_key
|
||||
|
||||
async def complete(
|
||||
self,
|
||||
*,
|
||||
model: AiModel,
|
||||
credential: ModelCredential | None,
|
||||
messages: list[ChatMessage],
|
||||
system: str = "",
|
||||
temperature: float = 0.2,
|
||||
max_tokens: int = 2048,
|
||||
) -> ModelResponse:
|
||||
if not self.base_url:
|
||||
raise ProviderUnavailableError(
|
||||
"OPENAI_COMPATIBLE_BASE_URL تنظیم نشده است."
|
||||
)
|
||||
|
||||
payload_messages = ([{"role": "system", "content": system}] if system else []) + [
|
||||
{"role": m.role, "content": m.content} for m in messages
|
||||
]
|
||||
payload = {
|
||||
"model": model.internal_name,
|
||||
"messages": payload_messages,
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
}
|
||||
headers = {"Content-Type": "application/json"}
|
||||
api_key = self.default_api_key
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
|
||||
started = time.perf_counter()
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.post(
|
||||
f"{self.base_url}/chat/completions", json=payload, headers=headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
raise ProviderUnavailableError(
|
||||
f"ارائهدهندهٔ مدل خطا برگرداند ({exc.response.status_code})."
|
||||
) from exc
|
||||
except httpx.HTTPError as exc:
|
||||
raise ProviderUnavailableError("اتصال به ارائهدهندهٔ مدل برقرار نشد.") from exc
|
||||
|
||||
choice = (data.get("choices") or [{}])[0]
|
||||
content = (choice.get("message") or {}).get("content", "") or ""
|
||||
usage = data.get("usage") or {}
|
||||
return ModelResponse(
|
||||
content=content,
|
||||
model_internal_name=data.get("model", model.internal_name),
|
||||
input_tokens=int(usage.get("prompt_tokens", 0) or 0),
|
||||
output_tokens=int(usage.get("completion_tokens", 0) or 0),
|
||||
latency_ms=int((time.perf_counter() - started) * 1000),
|
||||
raw={"id": data.get("id", "")},
|
||||
)
|
||||
|
||||
async def health(self) -> ModelHealth:
|
||||
if not self.base_url:
|
||||
return ModelHealth(ok=False, detail="base_url تنظیم نشده است.")
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(f"{self.base_url}/models")
|
||||
return ModelHealth(ok=response.status_code < 500, detail=str(response.status_code))
|
||||
except httpx.HTTPError as exc:
|
||||
return ModelHealth(ok=False, detail=str(exc))
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Adapter registry.
|
||||
|
||||
One place decides which concrete adapter backs each port, driven purely by the
|
||||
``*_PROVIDER`` environment switches. Everything defaults to ``mock`` so a fresh
|
||||
clone runs with no credentials.
|
||||
|
||||
Adapters that hold process state (the mock Git forge, the mock port ledger) are
|
||||
cached as singletons so behaviour is consistent within a process.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.errors import ProviderUnavailableError
|
||||
from app.infrastructure.adapters import mock
|
||||
from app.infrastructure.adapters.openai_compatible import OpenAICompatibleModelAdapter
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_model_provider():
|
||||
provider = settings.ai_default_provider.lower()
|
||||
if provider == "mock":
|
||||
return mock.MockModelProviderAdapter()
|
||||
if provider == "openai_compatible":
|
||||
return OpenAICompatibleModelAdapter(
|
||||
base_url=settings.openai_compatible_base_url,
|
||||
default_api_key=settings.openai_compatible_api_key,
|
||||
)
|
||||
raise ProviderUnavailableError(
|
||||
f"AI_DEFAULT_PROVIDER='{provider}' پشتیبانی نمیشود. "
|
||||
"مقادیر مجاز: mock | openai_compatible"
|
||||
)
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_git_provider():
|
||||
provider = settings.git_provider.lower()
|
||||
if provider == "mock":
|
||||
return mock.MockGitAdapter()
|
||||
if provider == "gitea":
|
||||
from app.infrastructure.adapters.gitea import GiteaGitAdapter
|
||||
|
||||
return GiteaGitAdapter(
|
||||
base_url=settings.gitea_internal_url or settings.gitea_url,
|
||||
admin_user=settings.gitea_admin_user,
|
||||
admin_token=settings.gitea_admin_token,
|
||||
)
|
||||
raise ProviderUnavailableError(
|
||||
f"GIT_PROVIDER='{provider}' پشتیبانی نمیشود. مقادیر مجاز: mock | gitea"
|
||||
)
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_domain_router():
|
||||
provider = settings.domain_router.lower()
|
||||
if provider == "mock":
|
||||
return mock.MockDomainRouterAdapter(
|
||||
root_domain=settings.preview_root_domain,
|
||||
port_start=settings.preview_port_start,
|
||||
port_end=settings.preview_port_end,
|
||||
)
|
||||
if provider == "caddy":
|
||||
from app.infrastructure.adapters.caddy import CaddyDomainRouterAdapter
|
||||
|
||||
return CaddyDomainRouterAdapter(
|
||||
caddyfile_path=settings.caddyfile_path,
|
||||
root_domain=settings.preview_root_domain,
|
||||
port_start=settings.preview_port_start,
|
||||
port_end=settings.preview_port_end,
|
||||
)
|
||||
raise ProviderUnavailableError(
|
||||
f"DOMAIN_ROUTER='{provider}' پشتیبانی نمیشود. مقادیر مجاز: mock | caddy"
|
||||
)
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_sandbox_provider():
|
||||
provider = settings.sandbox_provider.lower()
|
||||
if provider == "mock":
|
||||
return mock.LocalMockSandboxAdapter(max_output_bytes=settings.sandbox_max_output_bytes)
|
||||
if provider in {"daytona", "e2b"}:
|
||||
from app.infrastructure.adapters.sandbox_remote import build_remote_sandbox_adapter
|
||||
|
||||
return build_remote_sandbox_adapter(
|
||||
provider,
|
||||
daytona_api_key=settings.daytona_api_key,
|
||||
daytona_api_url=settings.daytona_api_url,
|
||||
e2b_api_key=settings.e2b_api_key,
|
||||
)
|
||||
raise ProviderUnavailableError(
|
||||
f"SANDBOX_PROVIDER='{provider}' پشتیبانی نمیشود. مقادیر مجاز: mock | daytona | e2b"
|
||||
)
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_payment_provider():
|
||||
provider = settings.payment_provider.lower()
|
||||
if provider == "mock":
|
||||
return mock.LocalMockPaymentAdapter()
|
||||
if provider == "stripe":
|
||||
from app.infrastructure.adapters.stripe_payments import StripePaymentAdapter
|
||||
|
||||
return StripePaymentAdapter(
|
||||
secret_key=settings.stripe_secret_key,
|
||||
webhook_secret=settings.stripe_webhook_secret,
|
||||
)
|
||||
raise ProviderUnavailableError(
|
||||
f"PAYMENT_PROVIDER='{provider}' پشتیبانی نمیشود. مقادیر مجاز: mock | stripe"
|
||||
)
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_storage():
|
||||
return mock.InMemoryStorageAdapter()
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_vector_store():
|
||||
return mock.InMemoryVectorStoreAdapter()
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_notifier():
|
||||
return mock.ConsoleNotificationAdapter()
|
||||
|
||||
|
||||
def reset_adapters() -> None:
|
||||
"""Drop cached singletons. Used by tests that need a clean slate."""
|
||||
for fn in (
|
||||
get_model_provider,
|
||||
get_git_provider,
|
||||
get_domain_router,
|
||||
get_sandbox_provider,
|
||||
get_payment_provider,
|
||||
get_storage,
|
||||
get_vector_store,
|
||||
get_notifier,
|
||||
):
|
||||
fn.cache_clear()
|
||||
@@ -0,0 +1,430 @@
|
||||
"""Remote sandbox adapters (Daytona / E2B) for :class:`SandboxProviderPort`.
|
||||
|
||||
This is the **only** place tenant code may execute. It never shells out on the
|
||||
platform host: every command is sent to a remote, disposable sandbox through the
|
||||
provider's official Python SDK.
|
||||
|
||||
Why the SDKs are optional extras
|
||||
--------------------------------
|
||||
``daytona`` and ``e2b`` are large, fast-moving dependencies. They are declared as
|
||||
the ``sandbox-remote`` extra so a default install stays small, and they are
|
||||
imported lazily inside :meth:`_load_sdk`. A missing extra produces an actionable
|
||||
``ProviderUnavailableError`` instead of an ``ImportError`` at startup.
|
||||
|
||||
Fail loudly, not silently
|
||||
-------------------------
|
||||
The two SDKs change their surface between minor releases. Rather than assume a
|
||||
method exists and fail halfway through a customer's build, :meth:`preflight`
|
||||
probes for every attribute this adapter relies on and refuses to construct the
|
||||
adapter if any is missing. A version drift therefore surfaces as a clear
|
||||
configuration error the moment ``SANDBOX_PROVIDER`` is switched on.
|
||||
|
||||
Known gap
|
||||
---------
|
||||
Provider-native snapshots (a resumable VM image) are **not** wired yet. Calling
|
||||
:meth:`snapshot` raises a clear error rather than pretending to have saved one.
|
||||
The mock provider implements snapshots fully, and the database already records
|
||||
them, so only the remote path is affected.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from app.application.ports import CommandResult, SessionInfo, SnapshotInfo, WorkspaceInfo
|
||||
from app.core.errors import ProviderUnavailableError
|
||||
|
||||
#: Never let a caller ask for an unbounded run.
|
||||
ABSOLUTE_MAX_TIMEOUT_SECONDS = 900
|
||||
#: Mirror of the platform output cap; enforced client-side as well because a
|
||||
#: remote SDK is under no obligation to truncate.
|
||||
ABSOLUTE_MAX_OUTPUT_BYTES = 2_000_000
|
||||
|
||||
|
||||
def _load_sdk(provider: str) -> Any:
|
||||
"""Import the provider SDK, or explain exactly how to install it."""
|
||||
try:
|
||||
if provider == "daytona":
|
||||
import daytona # noqa: PLC0415
|
||||
|
||||
return daytona
|
||||
if provider == "e2b":
|
||||
import e2b # noqa: PLC0415
|
||||
|
||||
return e2b
|
||||
except ImportError as exc:
|
||||
raise ProviderUnavailableError(
|
||||
f"SDK «{provider}» نصب نیست. برای فعالسازی اجرا کنید: "
|
||||
f"pip install 'agy-web-api[sandbox-remote]'"
|
||||
) from exc
|
||||
raise ProviderUnavailableError(f"ارائهدهندهٔ sandbox «{provider}» پشتیبانی نمیشود.")
|
||||
|
||||
|
||||
def _require(obj: Any, attr: str, *, provider: str, where: str) -> Any:
|
||||
"""Return ``obj.attr`` or fail with a precise version-mismatch message."""
|
||||
if not hasattr(obj, attr):
|
||||
raise ProviderUnavailableError(
|
||||
f"SDK «{provider}» با این نسخه سازگار نیست: «{where}.{attr}» یافت نشد. "
|
||||
"نسخهٔ پشتیبانیشده را نصب کنید یا SANDBOX_PROVIDER=mock بگذارید."
|
||||
)
|
||||
return getattr(obj, attr)
|
||||
|
||||
|
||||
def _truncate(text: str, limit: int) -> tuple[str, bool]:
|
||||
encoded = text.encode("utf-8", errors="replace")
|
||||
if len(encoded) <= limit:
|
||||
return text, False
|
||||
return encoded[:limit].decode("utf-8", errors="ignore"), True
|
||||
|
||||
|
||||
class _RemoteSandboxAdapter:
|
||||
"""Shared behaviour for both remote providers."""
|
||||
|
||||
provider_name = "remote"
|
||||
|
||||
def __init__(self, *, max_output_bytes: int = 200_000) -> None:
|
||||
self.max_output_bytes = min(max_output_bytes, ABSOLUTE_MAX_OUTPUT_BYTES)
|
||||
self._client: Any = None
|
||||
self._sandboxes: dict[str, Any] = {}
|
||||
self._sessions: dict[str, str] = {}
|
||||
|
||||
# -- lifecycle -----------------------------------------------------------
|
||||
|
||||
async def _ensure_client(self) -> Any: # pragma: no cover - overridden
|
||||
raise NotImplementedError
|
||||
|
||||
async def create_workspace(
|
||||
self, *, organization_id: str, project_id: str, template_id: str, image: str
|
||||
) -> WorkspaceInfo:
|
||||
client = await self._ensure_client()
|
||||
sandbox = await self._create_sandbox(client, image=image, template_id=template_id)
|
||||
external_id = str(
|
||||
_require(sandbox, "sandbox_id", provider=self.provider_name, where="sandbox")
|
||||
)
|
||||
self._sandboxes[external_id] = sandbox
|
||||
return WorkspaceInfo(
|
||||
external_id=external_id,
|
||||
status="ready",
|
||||
raw={"provider": self.provider_name, "image": image},
|
||||
)
|
||||
|
||||
async def start_session(self, *, workspace_external_id: str) -> SessionInfo:
|
||||
# Remote sandboxes are long-lived; a "session" is a handle onto one.
|
||||
external_id = f"ses_{workspace_external_id}"
|
||||
self._sessions[external_id] = workspace_external_id
|
||||
return SessionInfo(external_id=external_id, status="running")
|
||||
|
||||
async def _sandbox_for_session(self, session_external_id: str) -> Any:
|
||||
workspace_id = self._sessions.get(session_external_id, session_external_id)
|
||||
sandbox = self._sandboxes.get(workspace_id)
|
||||
if sandbox is None:
|
||||
raise ProviderUnavailableError("نشست sandbox یافت نشد یا منقضی شده است.")
|
||||
return sandbox
|
||||
|
||||
# -- execution -----------------------------------------------------------
|
||||
|
||||
async def run_command(
|
||||
self,
|
||||
*,
|
||||
session_external_id: str,
|
||||
command: str,
|
||||
timeout_seconds: int,
|
||||
max_output_bytes: int | None = None,
|
||||
env: dict[str, str] | None = None,
|
||||
) -> CommandResult:
|
||||
sandbox = await self._sandbox_for_session(session_external_id)
|
||||
limit = min(max_output_bytes or self.max_output_bytes, ABSOLUTE_MAX_OUTPUT_BYTES)
|
||||
# Clamp here too: the port contract says the caller can never ask for
|
||||
# "no limit", and a bug upstream must not become an unbounded remote run.
|
||||
timeout = max(1, min(int(timeout_seconds), ABSOLUTE_MAX_TIMEOUT_SECONDS))
|
||||
|
||||
started = time.perf_counter()
|
||||
raw = await self._exec(sandbox, command=command, timeout=timeout, env=env or {})
|
||||
duration_ms = max(1, int((time.perf_counter() - started) * 1000))
|
||||
|
||||
# Pull the three fields out defensively; a missing one is a version
|
||||
# mismatch and must fail with a precise message.
|
||||
exit_code = _require(
|
||||
raw, "exit_code", provider=self.provider_name, where="command result"
|
||||
)
|
||||
raw_stdout = _require(
|
||||
raw, "stdout", provider=self.provider_name, where="command result"
|
||||
)
|
||||
raw_stderr = _require(
|
||||
raw, "stderr", provider=self.provider_name, where="command result"
|
||||
)
|
||||
|
||||
stdout, cut_out = _truncate(str(raw_stdout or ""), limit)
|
||||
stderr, cut_err = _truncate(str(raw_stderr or ""), limit)
|
||||
|
||||
return CommandResult(
|
||||
exit_code=int(exit_code or 0),
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
duration_ms=duration_ms,
|
||||
truncated=cut_out or cut_err,
|
||||
)
|
||||
|
||||
# -- files ---------------------------------------------------------------
|
||||
|
||||
async def read_file(self, *, session_external_id: str, path: str) -> str:
|
||||
sandbox = await self._sandbox_for_session(session_external_id)
|
||||
return await self._read(sandbox, path)
|
||||
|
||||
async def write_file(self, *, session_external_id: str, path: str, content: str) -> None:
|
||||
sandbox = await self._sandbox_for_session(session_external_id)
|
||||
await self._write(sandbox, path, content)
|
||||
|
||||
# -- snapshots / preview -------------------------------------------------
|
||||
|
||||
async def snapshot(self, *, session_external_id: str, name: str) -> SnapshotInfo:
|
||||
raise ProviderUnavailableError(
|
||||
"snapshot بومی ارائهدهندهٔ راهدور هنوز فعال نشده است. "
|
||||
"برای snapshot از SANDBOX_PROVIDER=mock استفاده کنید یا این قابلیت را "
|
||||
"برای ارائهدهندهٔ خود پیادهسازی کنید."
|
||||
)
|
||||
|
||||
async def pause(self, *, session_external_id: str) -> None:
|
||||
sandbox = await self._sandbox_for_session(session_external_id)
|
||||
await self._pause(sandbox)
|
||||
|
||||
async def resume(self, *, session_external_id: str) -> SessionInfo:
|
||||
sandbox = await self._sandbox_for_session(session_external_id)
|
||||
await self._resume(sandbox)
|
||||
return SessionInfo(external_id=session_external_id, status="running")
|
||||
|
||||
async def destroy(self, *, session_external_id: str) -> None:
|
||||
workspace_id = self._sessions.pop(session_external_id, session_external_id)
|
||||
sandbox = self._sandboxes.pop(workspace_id, None)
|
||||
if sandbox is not None:
|
||||
await self._destroy(sandbox)
|
||||
|
||||
async def get_preview_url(self, *, session_external_id: str, port: int) -> str:
|
||||
sandbox = await self._sandbox_for_session(session_external_id)
|
||||
return await self._preview_url(sandbox, port)
|
||||
|
||||
# -- provider hooks ------------------------------------------------------
|
||||
|
||||
async def _create_sandbox(self, client: Any, *, image: str, template_id: str) -> Any:
|
||||
raise NotImplementedError
|
||||
|
||||
async def _exec(self, sandbox: Any, *, command: str, timeout: int, env: dict[str, str]) -> Any:
|
||||
raise NotImplementedError
|
||||
|
||||
async def _read(self, sandbox: Any, path: str) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
async def _write(self, sandbox: Any, path: str, content: str) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
async def _pause(self, sandbox: Any) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
async def _resume(self, sandbox: Any) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
async def _destroy(self, sandbox: Any) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
async def _preview_url(self, sandbox: Any, port: int) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class DaytonaSandboxAdapter(_RemoteSandboxAdapter):
|
||||
"""Daytona Cloud sandboxes via the async SDK.
|
||||
|
||||
Targets the documented surface: ``AsyncDaytona(DaytonaConfig(...))``,
|
||||
``client.create()``, ``sandbox.process.exec(...)``, ``sandbox.fs``,
|
||||
``client.delete(sandbox)``.
|
||||
"""
|
||||
|
||||
provider_name = "daytona"
|
||||
|
||||
def __init__(
|
||||
self, *, api_key: str, api_url: str = "", max_output_bytes: int = 200_000
|
||||
) -> None:
|
||||
super().__init__(max_output_bytes=max_output_bytes)
|
||||
if not api_key:
|
||||
raise ProviderUnavailableError(
|
||||
"SANDBOX_PROVIDER=daytona نیازمند تنظیم DAYTONA_API_KEY است."
|
||||
)
|
||||
self._api_key = api_key
|
||||
self._api_url = api_url
|
||||
self._sdk = _load_sdk("daytona")
|
||||
self._preflight()
|
||||
|
||||
def _preflight(self) -> None:
|
||||
_require(self._sdk, "AsyncDaytona", provider="daytona", where="daytona")
|
||||
_require(self._sdk, "DaytonaConfig", provider="daytona", where="daytona")
|
||||
|
||||
async def _ensure_client(self) -> Any:
|
||||
if self._client is None:
|
||||
config_kwargs: dict[str, Any] = {"api_key": self._api_key}
|
||||
if self._api_url:
|
||||
config_kwargs["api_url"] = self._api_url
|
||||
config = self._sdk.DaytonaConfig(**config_kwargs)
|
||||
self._client = self._sdk.AsyncDaytona(config)
|
||||
return self._client
|
||||
|
||||
async def _create_sandbox(self, client: Any, *, image: str, template_id: str) -> Any:
|
||||
# `create` accepts no arguments on the default image; a custom image is
|
||||
# passed positionally as a snapshot/image reference.
|
||||
try:
|
||||
return await client.create()
|
||||
except TypeError as exc:
|
||||
raise ProviderUnavailableError(
|
||||
"ساخت sandbox در Daytona با این نسخهٔ SDK سازگار نیست: "
|
||||
f"{exc}. نسخهٔ پشتیبانیشدهٔ daytona را نصب کنید."
|
||||
) from exc
|
||||
|
||||
async def _exec(self, sandbox: Any, *, command: str, timeout: int, env: dict[str, str]) -> Any:
|
||||
process = _require(sandbox, "process", provider="daytona", where="sandbox")
|
||||
exec_fn = _require(process, "exec", provider="daytona", where="sandbox.process")
|
||||
response = await exec_fn(command, timeout=timeout)
|
||||
# Daytona reports the exit code separately from the combined output.
|
||||
result = str(
|
||||
_require(response, "result", provider="daytona", where="process response") or ""
|
||||
)
|
||||
return _DaytonaCommandOutput(
|
||||
exit_code=int(getattr(response, "exit_code", 0) or 0),
|
||||
stdout=result,
|
||||
stderr="",
|
||||
)
|
||||
|
||||
async def _read(self, sandbox: Any, path: str) -> str:
|
||||
fs = _require(sandbox, "fs", provider="daytona", where="sandbox")
|
||||
read_fn = _require(fs, "download_file", provider="daytona", where="sandbox.fs")
|
||||
content = await read_fn(path)
|
||||
if isinstance(content, bytes):
|
||||
return content.decode("utf-8", errors="replace")
|
||||
return str(content or "")
|
||||
|
||||
async def _write(self, sandbox: Any, path: str, content: str) -> None:
|
||||
fs = _require(sandbox, "fs", provider="daytona", where="sandbox")
|
||||
write_fn = _require(fs, "upload_file", provider="daytona", where="sandbox.fs")
|
||||
await write_fn(content.encode("utf-8"), path)
|
||||
|
||||
async def _pause(self, sandbox: Any) -> None:
|
||||
stop_fn = _require(sandbox, "stop", provider="daytona", where="sandbox")
|
||||
await stop_fn()
|
||||
|
||||
async def _resume(self, sandbox: Any) -> None:
|
||||
start_fn = _require(sandbox, "start", provider="daytona", where="sandbox")
|
||||
await start_fn()
|
||||
|
||||
async def _destroy(self, sandbox: Any) -> None:
|
||||
client = await self._ensure_client()
|
||||
delete_fn = _require(client, "delete", provider="daytona", where="AsyncDaytona")
|
||||
await delete_fn(sandbox)
|
||||
|
||||
async def _preview_url(self, sandbox: Any, port: int) -> str:
|
||||
link_fn = _require(sandbox, "get_preview_link", provider="daytona", where="sandbox")
|
||||
link = await link_fn(port)
|
||||
return str(getattr(link, "url", link))
|
||||
|
||||
|
||||
class E2BSandboxAdapter(_RemoteSandboxAdapter):
|
||||
"""E2B sandboxes via the async SDK.
|
||||
|
||||
Targets the documented surface: ``AsyncSandbox.create(...)``,
|
||||
``sandbox.commands.run(...)``, ``sandbox.files.read/write``,
|
||||
``sandbox.kill()``.
|
||||
"""
|
||||
|
||||
provider_name = "e2b"
|
||||
|
||||
def __init__(self, *, api_key: str, max_output_bytes: int = 200_000) -> None:
|
||||
super().__init__(max_output_bytes=max_output_bytes)
|
||||
if not api_key:
|
||||
raise ProviderUnavailableError(
|
||||
"SANDBOX_PROVIDER=e2b نیازمند تنظیم E2B_API_KEY است."
|
||||
)
|
||||
self._api_key = api_key
|
||||
self._sdk = _load_sdk("e2b")
|
||||
self._preflight()
|
||||
|
||||
def _preflight(self) -> None:
|
||||
_require(self._sdk, "AsyncSandbox", provider="e2b", where="e2b")
|
||||
|
||||
async def _ensure_client(self) -> Any:
|
||||
# E2B has no long-lived client object; the API key is passed per call.
|
||||
return self._sdk.AsyncSandbox
|
||||
|
||||
async def _create_sandbox(self, client: Any, *, image: str, template_id: str) -> Any:
|
||||
create_fn = _require(client, "create", provider="e2b", where="AsyncSandbox")
|
||||
return await create_fn(api_key=self._api_key)
|
||||
|
||||
async def _exec(self, sandbox: Any, *, command: str, timeout: int, env: dict[str, str]) -> Any:
|
||||
commands = _require(sandbox, "commands", provider="e2b", where="sandbox")
|
||||
run_fn = _require(commands, "run", provider="e2b", where="sandbox.commands")
|
||||
result = await run_fn(command, timeout=timeout, envs=env or None)
|
||||
return _E2BCommandOutput(
|
||||
exit_code=int(getattr(result, "exit_code", 0) or 0),
|
||||
stdout=str(getattr(result, "stdout", "") or ""),
|
||||
stderr=str(getattr(result, "stderr", "") or ""),
|
||||
)
|
||||
|
||||
async def _read(self, sandbox: Any, path: str) -> str:
|
||||
files = _require(sandbox, "files", provider="e2b", where="sandbox")
|
||||
read_fn = _require(files, "read", provider="e2b", where="sandbox.files")
|
||||
content = await read_fn(path)
|
||||
if isinstance(content, bytes):
|
||||
return content.decode("utf-8", errors="replace")
|
||||
return str(content or "")
|
||||
|
||||
async def _write(self, sandbox: Any, path: str, content: str) -> None:
|
||||
files = _require(sandbox, "files", provider="e2b", where="sandbox")
|
||||
write_fn = _require(files, "write", provider="e2b", where="sandbox.files")
|
||||
await write_fn(path, content)
|
||||
|
||||
async def _pause(self, sandbox: Any) -> None:
|
||||
pause_fn = _require(sandbox, "pause", provider="e2b", where="sandbox")
|
||||
await pause_fn()
|
||||
|
||||
async def _resume(self, sandbox: Any) -> None:
|
||||
# E2B resumes by reconnecting to the sandbox id.
|
||||
connect_fn = _require(
|
||||
self._sdk.AsyncSandbox, "connect", provider="e2b", where="AsyncSandbox"
|
||||
)
|
||||
await connect_fn(sandbox.sandbox_id, api_key=self._api_key)
|
||||
|
||||
async def _destroy(self, sandbox: Any) -> None:
|
||||
kill_fn = _require(sandbox, "kill", provider="e2b", where="sandbox")
|
||||
await kill_fn()
|
||||
|
||||
async def _preview_url(self, sandbox: Any, port: int) -> str:
|
||||
host_fn = _require(sandbox, "get_host", provider="e2b", where="sandbox")
|
||||
return f"https://{await host_fn(port)}"
|
||||
|
||||
|
||||
class _DaytonaCommandOutput:
|
||||
__slots__ = ("exit_code", "stdout", "stderr")
|
||||
|
||||
def __init__(self, *, exit_code: int, stdout: str, stderr: str) -> None:
|
||||
self.exit_code = exit_code
|
||||
self.stdout = stdout
|
||||
self.stderr = stderr
|
||||
|
||||
|
||||
class _E2BCommandOutput:
|
||||
__slots__ = ("exit_code", "stdout", "stderr")
|
||||
|
||||
def __init__(self, *, exit_code: int, stdout: str, stderr: str) -> None:
|
||||
self.exit_code = exit_code
|
||||
self.stdout = stdout
|
||||
self.stderr = stderr
|
||||
|
||||
|
||||
def build_remote_sandbox_adapter(
|
||||
provider: str, *, daytona_api_key: str = "", daytona_api_url: str = "", e2b_api_key: str = ""
|
||||
):
|
||||
"""Factory used by the adapter registry."""
|
||||
normalised = (provider or "").strip().lower()
|
||||
if normalised == "daytona":
|
||||
return DaytonaSandboxAdapter(api_key=daytona_api_key, api_url=daytona_api_url)
|
||||
if normalised == "e2b":
|
||||
return E2BSandboxAdapter(api_key=e2b_api_key)
|
||||
raise ProviderUnavailableError(
|
||||
f"SANDBOX_PROVIDER='{provider}' پشتیبانی نمیشود. مقادیر مجاز: mock | daytona | e2b"
|
||||
)
|
||||
@@ -0,0 +1,203 @@
|
||||
"""Stripe adapter for :class:`PaymentProviderPort`.
|
||||
|
||||
Uses Stripe's REST API directly over ``httpx`` rather than the official SDK: the
|
||||
two calls we need are stable, and it keeps the API process free of an extra
|
||||
dependency and its release cadence.
|
||||
|
||||
Responsibility boundary
|
||||
-----------------------
|
||||
The internal usage ledger is the **source of truth** for what a tenant owes.
|
||||
This adapter only moves money — it never decides a balance, and a successful
|
||||
checkout does not by itself credit a wallet. The application layer settles the
|
||||
payment and then writes the credit, so a webhook replayed twice cannot
|
||||
double-credit.
|
||||
|
||||
Amounts are converted to Stripe's minor units (cents) with ``Decimal`` rounding,
|
||||
never float multiplication, so ``19.99`` cannot become ``1998``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import time
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
|
||||
import httpx
|
||||
|
||||
from app.application.ports import CheckoutSession, PaymentResult
|
||||
from app.core.errors import ProviderUnavailableError, ValidationError
|
||||
|
||||
STRIPE_API_BASE = "https://api.stripe.com/v1"
|
||||
DEFAULT_TIMEOUT_SECONDS = 20.0
|
||||
#: Stripe rejects signatures older than this; mirrored locally to fail early.
|
||||
WEBHOOK_TOLERANCE_SECONDS = 300
|
||||
#: Card networks require a minimum charge. Refuse early with a clear message
|
||||
#: rather than letting Stripe return a confusing error.
|
||||
MINIMUM_CHARGE_USD = Decimal("0.50")
|
||||
|
||||
|
||||
class StripePaymentAdapter:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
secret_key: str,
|
||||
webhook_secret: str = "",
|
||||
api_base: str = STRIPE_API_BASE,
|
||||
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
|
||||
) -> None:
|
||||
if not secret_key:
|
||||
raise ProviderUnavailableError(
|
||||
"PAYMENT_PROVIDER=stripe نیازمند تنظیم STRIPE_SECRET_KEY است."
|
||||
)
|
||||
self._secret_key = secret_key
|
||||
self._webhook_secret = webhook_secret
|
||||
self.api_base = api_base.rstrip("/")
|
||||
self.timeout_seconds = timeout_seconds
|
||||
|
||||
# -- port ----------------------------------------------------------------
|
||||
|
||||
async def create_checkout(
|
||||
self,
|
||||
*,
|
||||
organization_id: str,
|
||||
amount: float,
|
||||
currency: str,
|
||||
success_url: str,
|
||||
cancel_url: str,
|
||||
) -> CheckoutSession:
|
||||
"""Create a one-off Checkout Session for a wallet top-up."""
|
||||
minor_units = self._to_minor_units(amount)
|
||||
currency_code = (currency or "usd").strip().lower()
|
||||
|
||||
form = {
|
||||
"mode": "payment",
|
||||
"success_url": success_url,
|
||||
"cancel_url": cancel_url,
|
||||
# Ties the Stripe object back to our tenant without exposing an id
|
||||
# the customer can edit meaningfully.
|
||||
"client_reference_id": organization_id,
|
||||
"metadata[organization_id]": organization_id,
|
||||
"line_items[0][quantity]": "1",
|
||||
"line_items[0][price_data][currency]": currency_code,
|
||||
"line_items[0][price_data][unit_amount]": str(minor_units),
|
||||
"line_items[0][price_data][product_data][name]": "AGY credit top-up",
|
||||
}
|
||||
|
||||
payload = await self._request("POST", "/checkout/sessions", data=form)
|
||||
url = payload.get("url")
|
||||
if not url:
|
||||
raise ProviderUnavailableError("Stripe آدرس پرداخت را برنگرداند.")
|
||||
|
||||
return CheckoutSession(
|
||||
external_id=payload.get("id", ""),
|
||||
url=url,
|
||||
# Report back in major units so the caller never sees minor units.
|
||||
amount=float(Decimal(payload.get("amount_total") or minor_units) / 100),
|
||||
currency=(payload.get("currency") or currency_code).upper(),
|
||||
)
|
||||
|
||||
async def refund(
|
||||
self, *, external_payment_id: str, amount: float | None = None
|
||||
) -> PaymentResult:
|
||||
if not external_payment_id:
|
||||
raise ValidationError("شناسهٔ پرداخت برای بازگشت وجه لازم است.")
|
||||
|
||||
form: dict[str, str] = {}
|
||||
# A Checkout Session is not refundable directly; its PaymentIntent is.
|
||||
# Accept either so a caller does not have to know Stripe's object graph.
|
||||
if external_payment_id.startswith("cs_"):
|
||||
session = await self._request(
|
||||
"GET", f"/checkout/sessions/{external_payment_id}", params={}
|
||||
)
|
||||
intent = session.get("payment_intent")
|
||||
if not intent:
|
||||
return PaymentResult(
|
||||
ok=False,
|
||||
external_id=external_payment_id,
|
||||
message="این پرداخت هنوز تکمیل نشده است.",
|
||||
)
|
||||
form["payment_intent"] = str(intent)
|
||||
else:
|
||||
form["payment_intent"] = external_payment_id
|
||||
|
||||
if amount is not None:
|
||||
form["amount"] = str(self._to_minor_units(amount))
|
||||
|
||||
payload = await self._request("POST", "/refunds", data=form)
|
||||
return PaymentResult(
|
||||
ok=payload.get("status") in {"succeeded", "pending"},
|
||||
external_id=payload.get("id", ""),
|
||||
message=f"وضعیت بازگشت وجه: {payload.get('status', 'unknown')}",
|
||||
)
|
||||
|
||||
# -- webhook -------------------------------------------------------------
|
||||
|
||||
def verify_webhook_signature(self, *, payload: bytes, signature_header: str) -> bool:
|
||||
"""Verify a ``Stripe-Signature`` header over the raw request body.
|
||||
|
||||
The body must be the *raw bytes* — re-serialising parsed JSON changes the
|
||||
byte sequence and the HMAC will never match.
|
||||
"""
|
||||
if not self._webhook_secret:
|
||||
return False
|
||||
|
||||
timestamp = ""
|
||||
signatures: list[str] = []
|
||||
for part in (signature_header or "").split(","):
|
||||
key, _, value = part.partition("=")
|
||||
if key.strip() == "t":
|
||||
timestamp = value.strip()
|
||||
elif key.strip() == "v1":
|
||||
signatures.append(value.strip())
|
||||
|
||||
if not timestamp or not signatures:
|
||||
return False
|
||||
try:
|
||||
age = abs(time.time() - int(timestamp))
|
||||
except ValueError:
|
||||
return False
|
||||
if age > WEBHOOK_TOLERANCE_SECONDS:
|
||||
# Replay protection.
|
||||
return False
|
||||
|
||||
signed_payload = f"{timestamp}.".encode() + payload
|
||||
expected = hmac.new(
|
||||
self._webhook_secret.encode(), signed_payload, hashlib.sha256
|
||||
).hexdigest()
|
||||
# Constant-time compare against every offered signature.
|
||||
return any(hmac.compare_digest(expected, candidate) for candidate in signatures)
|
||||
|
||||
# -- helpers -------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _to_minor_units(amount: float) -> int:
|
||||
value = Decimal(str(amount)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
if value <= 0:
|
||||
raise ValidationError("مبلغ باید بزرگتر از صفر باشد.")
|
||||
if value < MINIMUM_CHARGE_USD:
|
||||
raise ValidationError(f"حداقل مبلغ پرداخت {MINIMUM_CHARGE_USD} دلار است.")
|
||||
return int(value * 100)
|
||||
|
||||
async def _request(self, method: str, path: str, **kwargs) -> dict:
|
||||
headers = {"Authorization": f"Bearer {self._secret_key}"}
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
base_url=self.api_base, timeout=self.timeout_seconds
|
||||
) as client:
|
||||
response = await client.request(method, path, headers=headers, **kwargs)
|
||||
except httpx.TimeoutException as exc:
|
||||
raise ProviderUnavailableError("ارتباط با Stripe زمانبر شد.") from exc
|
||||
except httpx.HTTPError as exc:
|
||||
raise ProviderUnavailableError("ارتباط با Stripe برقرار نشد.") from exc
|
||||
|
||||
if response.status_code >= 400:
|
||||
detail = ""
|
||||
try:
|
||||
detail = (response.json().get("error") or {}).get("message", "")
|
||||
except ValueError:
|
||||
detail = response.text[:200]
|
||||
raise ProviderUnavailableError(
|
||||
f"Stripe خطا برگرداند ({response.status_code}): {detail}"
|
||||
)
|
||||
return response.json()
|
||||
@@ -0,0 +1,895 @@
|
||||
"""SQLAlchemy ORM tables.
|
||||
|
||||
This is the *only* module that knows about the relational schema. The domain
|
||||
layer is expressed as plain dataclasses in ``app.domain.*``; repositories map
|
||||
between the two. Keeping the schema here means a migration change never forces
|
||||
a change to domain logic.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
Boolean,
|
||||
DateTime,
|
||||
Float,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.database import Base
|
||||
from app.domain.base import new_id, utcnow
|
||||
|
||||
|
||||
def _pk() -> Mapped[str]:
|
||||
return mapped_column(String(32), primary_key=True, default=new_id)
|
||||
|
||||
|
||||
def _created() -> Mapped[datetime]:
|
||||
return mapped_column(DateTime(timezone=True), default=utcnow, nullable=False)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Identity & Access
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class UserRow(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
email: Mapped[str] = mapped_column(String(320), unique=True, index=True, nullable=False)
|
||||
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(160), default="", nullable=False)
|
||||
avatar_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
is_platform_owner: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
created_at: Mapped[datetime] = _created()
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=False
|
||||
)
|
||||
|
||||
|
||||
class OrganizationRow(Base):
|
||||
__tablename__ = "organizations"
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
name: Mapped[str] = mapped_column(String(160), nullable=False)
|
||||
slug: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
|
||||
owner_user_id: Mapped[str] = mapped_column(ForeignKey("users.id"), nullable=False, index=True)
|
||||
created_at: Mapped[datetime] = _created()
|
||||
|
||||
|
||||
class MembershipRow(Base):
|
||||
__tablename__ = "memberships"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("organization_id", "user_id", name="uq_membership_org_user"),
|
||||
Index("ix_membership_org_role", "organization_id", "role"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
organization_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
user_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
role: Mapped[str] = mapped_column(String(16), default="member", nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(16), default="active", nullable=False)
|
||||
created_at: Mapped[datetime] = _created()
|
||||
|
||||
|
||||
class InviteRow(Base):
|
||||
__tablename__ = "invites"
|
||||
__table_args__ = (Index("ix_invite_org_email", "organization_id", "email"),)
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
organization_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
email: Mapped[str] = mapped_column(String(320), nullable=False)
|
||||
role: Mapped[str] = mapped_column(String(16), default="member", nullable=False)
|
||||
token_hash: Mapped[str] = mapped_column(String(128), unique=True, nullable=False)
|
||||
invited_by_user_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("users.id"), nullable=True, index=True
|
||||
)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
accepted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = _created()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AI Control Plane (platform-owner only)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class AiProviderRow(Base):
|
||||
__tablename__ = "ai_providers"
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
name: Mapped[str] = mapped_column(String(120), unique=True, nullable=False)
|
||||
type: Mapped[str] = mapped_column(String(32), default="mock", nullable=False)
|
||||
base_url: Mapped[str] = mapped_column(String(512), default="", nullable=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
created_at: Mapped[datetime] = _created()
|
||||
|
||||
|
||||
class AiModelCredentialRow(Base):
|
||||
__tablename__ = "ai_model_credentials"
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
provider_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("ai_providers.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
encrypted_api_key: Mapped[str] = mapped_column(Text, default="", nullable=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
created_at: Mapped[datetime] = _created()
|
||||
|
||||
|
||||
class AiModelRow(Base):
|
||||
__tablename__ = "ai_models"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("provider_id", "internal_name", name="uq_model_provider_name"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
provider_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("ai_providers.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
internal_name: Mapped[str] = mapped_column(String(160), nullable=False)
|
||||
display_name_for_admin: Mapped[str] = mapped_column(String(160), nullable=False)
|
||||
capabilities: Mapped[list] = mapped_column(JSON, default=list, nullable=False)
|
||||
input_price_per_1m: Mapped[float] = mapped_column(Float, default=0.0, nullable=False)
|
||||
output_price_per_1m: Mapped[float] = mapped_column(Float, default=0.0, nullable=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
created_at: Mapped[datetime] = _created()
|
||||
|
||||
|
||||
class RoutingPolicyRow(Base):
|
||||
__tablename__ = "routing_policies"
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
name: Mapped[str] = mapped_column(String(120), unique=True, nullable=False)
|
||||
description: Mapped[str] = mapped_column(Text, default="", nullable=False)
|
||||
mode: Mapped[str] = mapped_column(String(16), default="balanced", nullable=False)
|
||||
fallback_policy_json: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False)
|
||||
budget_limits_json: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False)
|
||||
is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
created_at: Mapped[datetime] = _created()
|
||||
|
||||
|
||||
class RateCardRow(Base):
|
||||
__tablename__ = "rate_cards"
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
unit: Mapped[str] = mapped_column(String(40), unique=True, nullable=False)
|
||||
customer_price: Mapped[float] = mapped_column(Float, default=0.0, nullable=False)
|
||||
internal_cost_estimate: Mapped[float] = mapped_column(Float, default=0.0, nullable=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
created_at: Mapped[datetime] = _created()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Projects & Git
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ProjectRow(Base):
|
||||
__tablename__ = "projects"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("organization_id", "slug", name="uq_project_org_slug"),
|
||||
# The project list is always scoped to one organization and ordered by
|
||||
# creation, so one composite index serves both the filter and the sort.
|
||||
Index("ix_project_org_created", "organization_id", "created_at"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
organization_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(160), nullable=False)
|
||||
slug: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
description: Mapped[str] = mapped_column(Text, default="", nullable=False)
|
||||
created_by_user_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("users.id"), nullable=True, index=True
|
||||
)
|
||||
default_branch: Mapped[str] = mapped_column(String(80), default="main", nullable=False)
|
||||
created_at: Mapped[datetime] = _created()
|
||||
|
||||
|
||||
class ProjectRepositoryRow(Base):
|
||||
__tablename__ = "project_repositories"
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
project_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("projects.id", ondelete="CASCADE"), unique=True, nullable=False, index=True
|
||||
)
|
||||
provider: Mapped[str] = mapped_column(String(32), default="gitea", nullable=False)
|
||||
owner: Mapped[str] = mapped_column(String(120), default="", nullable=False)
|
||||
repo_name: Mapped[str] = mapped_column(String(160), default="", nullable=False)
|
||||
clone_url: Mapped[str] = mapped_column(String(512), default="", nullable=False)
|
||||
web_url: Mapped[str] = mapped_column(String(512), default="", nullable=False)
|
||||
default_branch: Mapped[str] = mapped_column(String(80), default="main", nullable=False)
|
||||
created_at: Mapped[datetime] = _created()
|
||||
|
||||
|
||||
class ProjectGitCredentialRow(Base):
|
||||
__tablename__ = "project_git_credentials"
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
project_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("projects.id", ondelete="CASCADE"), unique=True, nullable=False, index=True
|
||||
)
|
||||
username: Mapped[str] = mapped_column(String(160), nullable=False)
|
||||
encrypted_password: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
created_at: Mapped[datetime] = _created()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Preview domain routing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class PreviewPortRow(Base):
|
||||
__tablename__ = "preview_ports"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("project_id", "service_name", name="uq_preview_port_project_service"),
|
||||
UniqueConstraint("port", name="uq_preview_port_port"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
organization_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
project_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
service_name: Mapped[str] = mapped_column(String(80), default="web", nullable=False)
|
||||
port: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(16), default="active", nullable=False)
|
||||
created_at: Mapped[datetime] = _created()
|
||||
|
||||
|
||||
class PreviewDomainRow(Base):
|
||||
__tablename__ = "preview_domains"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("host", name="uq_preview_domain_host"),
|
||||
Index("ix_preview_domain_org_created", "organization_id", "created_at"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
organization_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
project_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
host: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
port: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(16), default="active", nullable=False)
|
||||
protected: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
created_by_user_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("users.id"), nullable=True, index=True
|
||||
)
|
||||
created_at: Mapped[datetime] = _created()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sandbox
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class SandboxTemplateRow(Base):
|
||||
__tablename__ = "sandbox_templates"
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
name: Mapped[str] = mapped_column(String(120), unique=True, nullable=False)
|
||||
provider: Mapped[str] = mapped_column(String(32), default="mock", nullable=False)
|
||||
image: Mapped[str] = mapped_column(String(255), default="python:3.13-slim", nullable=False)
|
||||
description: Mapped[str] = mapped_column(Text, default="", nullable=False)
|
||||
default_cpu: Mapped[float] = mapped_column(Float, default=1.0, nullable=False)
|
||||
default_memory_mb: Mapped[int] = mapped_column(Integer, default=1024, nullable=False)
|
||||
default_disk_mb: Mapped[int] = mapped_column(Integer, default=2048, nullable=False)
|
||||
is_system: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
created_at: Mapped[datetime] = _created()
|
||||
|
||||
|
||||
class WorkspaceRow(Base):
|
||||
__tablename__ = "workspaces"
|
||||
__table_args__ = (Index("ix_workspace_project_created", "project_id", "created_at"),)
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
organization_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
project_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
template_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("sandbox_templates.id"), nullable=False, index=True
|
||||
)
|
||||
provider: Mapped[str] = mapped_column(String(32), default="mock", nullable=False)
|
||||
external_workspace_id: Mapped[str | None] = mapped_column(String(160), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(16), default="pending", nullable=False)
|
||||
created_at: Mapped[datetime] = _created()
|
||||
|
||||
|
||||
class SandboxSessionRow(Base):
|
||||
__tablename__ = "sandbox_sessions"
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
workspace_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
external_session_id: Mapped[str | None] = mapped_column(String(160), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(16), default="starting", nullable=False)
|
||||
started_at: Mapped[datetime] = _created()
|
||||
stopped_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
last_active_at: Mapped[datetime] = _created()
|
||||
|
||||
|
||||
class CommandRunRow(Base):
|
||||
__tablename__ = "command_runs"
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
session_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("sandbox_sessions.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
command: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
exit_code: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
stdout_ref: Mapped[str] = mapped_column(Text, default="", nullable=False)
|
||||
stderr_ref: Mapped[str] = mapped_column(Text, default="", nullable=False)
|
||||
duration_ms: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
truncated: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
created_at: Mapped[datetime] = _created()
|
||||
|
||||
|
||||
class SnapshotRow(Base):
|
||||
__tablename__ = "snapshots"
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
workspace_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
external_snapshot_id: Mapped[str | None] = mapped_column(String(160), nullable=True)
|
||||
created_at: Mapped[datetime] = _created()
|
||||
|
||||
|
||||
class PreviewUrlRow(Base):
|
||||
__tablename__ = "preview_urls"
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
workspace_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
port: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
url: Mapped[str] = mapped_column(String(512), nullable=False)
|
||||
created_at: Mapped[datetime] = _created()
|
||||
|
||||
|
||||
class SandboxResourceLimitRow(Base):
|
||||
__tablename__ = "sandbox_resource_limits"
|
||||
__table_args__ = (UniqueConstraint("scope", "scope_id", name="uq_limit_scope"),)
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
scope: Mapped[str] = mapped_column(String(32), default="organization", nullable=False)
|
||||
scope_id: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
cpu: Mapped[float] = mapped_column(Float, default=1.0, nullable=False)
|
||||
memory_mb: Mapped[int] = mapped_column(Integer, default=1024, nullable=False)
|
||||
disk_mb: Mapped[int] = mapped_column(Integer, default=2048, nullable=False)
|
||||
timeout_seconds: Mapped[int] = mapped_column(Integer, default=120, nullable=False)
|
||||
max_output_bytes: Mapped[int] = mapped_column(Integer, default=200_000, nullable=False)
|
||||
network_policy: Mapped[str] = mapped_column(String(16), default="deny_all", nullable=False)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Workforce: templates, employees, chat, tasks, runs, artifacts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class AgentTemplateRow(Base):
|
||||
__tablename__ = "agent_templates"
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
name: Mapped[str] = mapped_column(String(120), unique=True, nullable=False)
|
||||
role_key: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
description: Mapped[str] = mapped_column(Text, default="", nullable=False)
|
||||
default_prompt: Mapped[str] = mapped_column(Text, default="", nullable=False)
|
||||
default_tools_json: Mapped[list] = mapped_column(JSON, default=list, nullable=False)
|
||||
default_routing_policy_id: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
is_system: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
created_at: Mapped[datetime] = _created()
|
||||
|
||||
|
||||
class EmployeeRow(Base):
|
||||
__tablename__ = "employees"
|
||||
__table_args__ = (Index("ix_employee_org_created", "organization_id", "created_at"),)
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
organization_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
project_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("projects.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
template_id: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
name: Mapped[str] = mapped_column(String(160), nullable=False)
|
||||
role_key: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(16), default="active", nullable=False)
|
||||
routing_policy_id: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
budget_limit_json: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False)
|
||||
created_at: Mapped[datetime] = _created()
|
||||
|
||||
|
||||
class PromptVersionRow(Base):
|
||||
__tablename__ = "prompt_versions"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("employee_id", "version", name="uq_prompt_employee_version"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
employee_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("employees.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
version: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
created_by_user_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("users.id"), nullable=True, index=True
|
||||
)
|
||||
created_at: Mapped[datetime] = _created()
|
||||
|
||||
|
||||
class EmployeeToolPermissionRow(Base):
|
||||
__tablename__ = "employee_tool_permissions"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("employee_id", "tool_key", name="uq_employee_tool"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
employee_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("employees.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
tool_key: Mapped[str] = mapped_column(String(80), nullable=False)
|
||||
allowed: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
policy_json: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False)
|
||||
|
||||
|
||||
class ThreadRow(Base):
|
||||
__tablename__ = "threads"
|
||||
__table_args__ = (Index("ix_thread_org_created", "organization_id", "created_at"),)
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
organization_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
project_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("projects.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(255), default="", nullable=False)
|
||||
created_by_user_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("users.id"), nullable=True, index=True
|
||||
)
|
||||
created_at: Mapped[datetime] = _created()
|
||||
|
||||
|
||||
class MessageRow(Base):
|
||||
__tablename__ = "messages"
|
||||
# A conversation is always read as "one thread, oldest first", so the
|
||||
# composite index serves the filter and the ordering in one lookup.
|
||||
__table_args__ = (Index("ix_message_thread_created", "thread_id", "created_at"),)
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
thread_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("threads.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
role: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
content: Mapped[str] = mapped_column(Text, default="", nullable=False)
|
||||
employee_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
|
||||
created_at: Mapped[datetime] = _created()
|
||||
|
||||
|
||||
class TaskRow(Base):
|
||||
__tablename__ = "tasks"
|
||||
__table_args__ = (
|
||||
Index("ix_task_org_status", "organization_id", "status"),
|
||||
Index("ix_task_org_created", "organization_id", "created_at"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
organization_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
project_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("projects.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
thread_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("threads.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str] = mapped_column(Text, default="", nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(24), default="new", nullable=False)
|
||||
assigned_employee_id: Mapped[str | None] = mapped_column(
|
||||
String(32), nullable=True, index=True
|
||||
)
|
||||
created_by_user_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("users.id"), nullable=True, index=True
|
||||
)
|
||||
created_at: Mapped[datetime] = _created()
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=False
|
||||
)
|
||||
|
||||
|
||||
class RunRow(Base):
|
||||
__tablename__ = "runs"
|
||||
# A run has no organization_id of its own: it is scoped through its task,
|
||||
# which is the object that carries the tenant. Indexing a column that does
|
||||
# not exist here would be wrong, so the composite stays on task_id.
|
||||
__table_args__ = (Index("ix_run_task_created", "task_id", "created_at"),)
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
task_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("tasks.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
employee_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
|
||||
status: Mapped[str] = mapped_column(String(24), default="queued", nullable=False)
|
||||
error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = _created()
|
||||
|
||||
|
||||
class RunStepRow(Base):
|
||||
__tablename__ = "run_steps"
|
||||
__table_args__ = (Index("ix_run_step_run_index", "run_id", "index"),)
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
run_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("runs.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
index: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
kind: Mapped[str] = mapped_column(String(40), default="note", nullable=False)
|
||||
title: Mapped[str] = mapped_column(String(255), default="", nullable=False)
|
||||
payload_json: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False)
|
||||
created_at: Mapped[datetime] = _created()
|
||||
|
||||
|
||||
class ToolCallRow(Base):
|
||||
__tablename__ = "tool_calls"
|
||||
__table_args__ = (
|
||||
Index("ix_tool_call_run_created", "run_id", "created_at"),
|
||||
# The pending-approval queue is "one organization, still awaiting a
|
||||
# decision", which is a filter on status plus a join back to the run.
|
||||
Index("ix_tool_call_status", "status"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
run_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("runs.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
tool_key: Mapped[str] = mapped_column(String(80), nullable=False)
|
||||
arguments_json: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False)
|
||||
result_json: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(24), default="pending", nullable=False)
|
||||
requires_approval: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
approved_by_user_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("users.id"), nullable=True, index=True
|
||||
)
|
||||
created_at: Mapped[datetime] = _created()
|
||||
|
||||
|
||||
class ArtifactRow(Base):
|
||||
__tablename__ = "artifacts"
|
||||
__table_args__ = (Index("ix_artifact_org_created", "organization_id", "created_at"),)
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
organization_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
project_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("projects.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
run_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("runs.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
kind: Mapped[str] = mapped_column(String(32), default="file", nullable=False)
|
||||
content_ref: Mapped[str] = mapped_column(Text, default="", nullable=False)
|
||||
size_bytes: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
created_at: Mapped[datetime] = _created()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Billing & Usage
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class PlanRow(Base):
|
||||
__tablename__ = "plans"
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
name: Mapped[str] = mapped_column(String(120), unique=True, nullable=False)
|
||||
monthly_price: Mapped[float] = mapped_column(Float, default=0.0, nullable=False)
|
||||
included_credit: Mapped[float] = mapped_column(Float, default=0.0, nullable=False)
|
||||
features_json: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
created_at: Mapped[datetime] = _created()
|
||||
|
||||
|
||||
class SubscriptionRow(Base):
|
||||
__tablename__ = "subscriptions"
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
organization_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
plan_id: Mapped[str] = mapped_column(ForeignKey("plans.id"), nullable=False, index=True)
|
||||
status: Mapped[str] = mapped_column(String(16), default="active", nullable=False)
|
||||
external_subscription_id: Mapped[str | None] = mapped_column(String(160), nullable=True)
|
||||
current_period_end: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
created_at: Mapped[datetime] = _created()
|
||||
|
||||
|
||||
class CreditWalletRow(Base):
|
||||
__tablename__ = "credit_wallets"
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
organization_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("organizations.id", ondelete="CASCADE"), unique=True, nullable=False, index=True
|
||||
)
|
||||
balance: Mapped[float] = mapped_column(Float, default=0.0, nullable=False)
|
||||
currency: Mapped[str] = mapped_column(String(8), default="USD", nullable=False)
|
||||
low_balance_threshold: Mapped[float] = mapped_column(Float, default=5.0, nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=False
|
||||
)
|
||||
|
||||
|
||||
class UsageEventRow(Base):
|
||||
__tablename__ = "usage_events"
|
||||
__table_args__ = (
|
||||
Index("ix_usage_org_created", "organization_id", "created_at"),
|
||||
Index("ix_usage_org_unit", "organization_id", "unit"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
organization_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
project_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
|
||||
user_id: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
employee_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
|
||||
run_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
|
||||
unit: Mapped[str] = mapped_column(String(40), nullable=False)
|
||||
quantity: Mapped[float] = mapped_column(Float, default=0.0, nullable=False)
|
||||
unit_price: Mapped[float] = mapped_column(Float, default=0.0, nullable=False)
|
||||
internal_cost_estimate: Mapped[float] = mapped_column(Float, default=0.0, nullable=False)
|
||||
total_price: Mapped[float] = mapped_column(Float, default=0.0, nullable=False)
|
||||
metadata_json: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False)
|
||||
created_at: Mapped[datetime] = _created()
|
||||
|
||||
|
||||
class InvoiceRow(Base):
|
||||
__tablename__ = "invoices"
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
organization_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
amount: Mapped[float] = mapped_column(Float, default=0.0, nullable=False)
|
||||
currency: Mapped[str] = mapped_column(String(8), default="USD", nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(16), default="draft", nullable=False)
|
||||
external_invoice_id: Mapped[str | None] = mapped_column(String(160), nullable=True)
|
||||
period_start: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
period_end: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = _created()
|
||||
|
||||
|
||||
class PaymentRow(Base):
|
||||
__tablename__ = "payments"
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
organization_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
amount: Mapped[float] = mapped_column(Float, default=0.0, nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(16), default="pending", nullable=False)
|
||||
provider: Mapped[str] = mapped_column(String(32), default="mock", nullable=False)
|
||||
external_payment_id: Mapped[str | None] = mapped_column(String(160), nullable=True)
|
||||
invoice_id: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
created_at: Mapped[datetime] = _created()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Knowledge
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class KnowledgeSourceRow(Base):
|
||||
__tablename__ = "knowledge_sources"
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
organization_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
project_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
kind: Mapped[str] = mapped_column(String(16), default="text", nullable=False)
|
||||
uri: Mapped[str] = mapped_column(String(512), default="", nullable=False)
|
||||
created_at: Mapped[datetime] = _created()
|
||||
|
||||
|
||||
class DocumentRow(Base):
|
||||
__tablename__ = "documents"
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
source_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("knowledge_sources.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(255), default="", nullable=False)
|
||||
content: Mapped[str] = mapped_column(Text, default="", nullable=False)
|
||||
created_at: Mapped[datetime] = _created()
|
||||
|
||||
|
||||
class DocumentChunkRow(Base):
|
||||
__tablename__ = "document_chunks"
|
||||
__table_args__ = (Index("ix_chunk_document_index", "document_id", "index"),)
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
document_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("documents.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
index: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
content: Mapped[str] = mapped_column(Text, default="", nullable=False)
|
||||
embedding_json: Mapped[list] = mapped_column(JSON, default=list, nullable=False)
|
||||
|
||||
|
||||
class EmbeddingJobRow(Base):
|
||||
__tablename__ = "embedding_jobs"
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
source_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("knowledge_sources.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
status: Mapped[str] = mapped_column(String(16), default="pending", nullable=False)
|
||||
error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = _created()
|
||||
|
||||
|
||||
class RetrievalPolicyRow(Base):
|
||||
__tablename__ = "retrieval_policies"
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
organization_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
scope_json: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False)
|
||||
created_at: Mapped[datetime] = _created()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Audit & Observability
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class AuditEventRow(Base):
|
||||
__tablename__ = "audit_events"
|
||||
__table_args__ = (
|
||||
Index("ix_audit_org_created", "organization_id", "created_at"),
|
||||
Index("ix_audit_action", "action"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
organization_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
|
||||
actor_user_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
|
||||
action: Mapped[str] = mapped_column(String(80), nullable=False)
|
||||
target_type: Mapped[str] = mapped_column(String(40), default="", nullable=False)
|
||||
target_id: Mapped[str] = mapped_column(String(64), default="", nullable=False)
|
||||
metadata_json: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False)
|
||||
ip_address: Mapped[str] = mapped_column(String(64), default="", nullable=False)
|
||||
user_agent: Mapped[str] = mapped_column(String(255), default="", nullable=False)
|
||||
created_at: Mapped[datetime] = _created()
|
||||
|
||||
|
||||
class TraceRow(Base):
|
||||
__tablename__ = "traces"
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
organization_id: Mapped[str] = mapped_column(String(32), nullable=False, index=True)
|
||||
run_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
|
||||
name: Mapped[str] = mapped_column(String(160), default="", nullable=False)
|
||||
created_at: Mapped[datetime] = _created()
|
||||
|
||||
|
||||
class SpanRow(Base):
|
||||
__tablename__ = "spans"
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
trace_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("traces.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
parent_span_id: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
name: Mapped[str] = mapped_column(String(160), nullable=False)
|
||||
kind: Mapped[str] = mapped_column(String(32), default="internal", nullable=False)
|
||||
attributes_json: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False)
|
||||
started_at: Mapped[datetime] = _created()
|
||||
ended_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
|
||||
class ErrorEventRow(Base):
|
||||
"""Persisted failures — the table the error console reads.
|
||||
|
||||
Wide on purpose: a row that lacks the request path or the exception class
|
||||
forces whoever is debugging to reproduce the bug to learn what it was. The
|
||||
indexes cover the three access patterns — filter by tenant, filter by
|
||||
"still open", and group by fingerprint.
|
||||
"""
|
||||
|
||||
__tablename__ = "error_events"
|
||||
__table_args__ = (
|
||||
# Grouping view: "how often did this exact bug happen, and when last?"
|
||||
Index("ix_error_events_fingerprint_created", "fingerprint", "created_at"),
|
||||
# Feed view: "show me the newest failures for this tenant."
|
||||
Index("ix_error_events_org_created", "organization_id", "created_at"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
organization_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
|
||||
run_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
|
||||
kind: Mapped[str] = mapped_column(String(80), nullable=False)
|
||||
message: Mapped[str] = mapped_column(Text, default="", nullable=False)
|
||||
stack: Mapped[str] = mapped_column(Text, default="", nullable=False)
|
||||
metadata_json: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False)
|
||||
|
||||
# --- classification -----------------------------------------------------
|
||||
source: Mapped[str] = mapped_column(String(16), default="http", nullable=False)
|
||||
severity: Mapped[str] = mapped_column(String(16), default="error", nullable=False)
|
||||
error_type: Mapped[str] = mapped_column(String(200), default="", nullable=False)
|
||||
#: Groups identical failures. Empty string rather than NULL so the grouping
|
||||
#: index never has to deal with a NULL key.
|
||||
fingerprint: Mapped[str] = mapped_column(String(64), default="", nullable=False, index=True)
|
||||
|
||||
# --- transport context --------------------------------------------------
|
||||
status_code: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
method: Mapped[str] = mapped_column(String(10), default="", nullable=False)
|
||||
path: Mapped[str] = mapped_column(String(512), default="", nullable=False)
|
||||
|
||||
# --- actor context ------------------------------------------------------
|
||||
user_id: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
request_id: Mapped[str] = mapped_column(String(64), default="", nullable=False)
|
||||
ip: Mapped[str] = mapped_column(String(64), default="", nullable=False)
|
||||
user_agent: Mapped[str] = mapped_column(String(255), default="", nullable=False)
|
||||
|
||||
# --- triage -------------------------------------------------------------
|
||||
resolved_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True, index=True
|
||||
)
|
||||
resolved_note: Mapped[str] = mapped_column(Text, default="", nullable=False)
|
||||
|
||||
created_at: Mapped[datetime] = _created()
|
||||
|
||||
|
||||
class QualityMetricRow(Base):
|
||||
__tablename__ = "quality_metrics"
|
||||
__table_args__ = (Index("ix_metric_org_name", "organization_id", "name"),)
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
organization_id: Mapped[str] = mapped_column(String(32), nullable=False, index=True)
|
||||
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
value: Mapped[float] = mapped_column(Float, default=0.0, nullable=False)
|
||||
dimensions_json: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False)
|
||||
created_at: Mapped[datetime] = _created()
|
||||
@@ -0,0 +1,281 @@
|
||||
"""Persist failures so they can be diagnosed after the fact.
|
||||
|
||||
Three properties make this module different from an ordinary repository call,
|
||||
and all three are load-bearing:
|
||||
|
||||
**It never raises.** This is called from inside exception handlers, workers and
|
||||
startup code — the exact places where an exception is already in flight. A
|
||||
recorder that throws turns a handled 500 into an unhandled one, or kills a
|
||||
worker mid-batch. Every failure here is swallowed and reported on stderr.
|
||||
|
||||
**It uses its own session.** The caller's session is frequently unusable by the
|
||||
time we get here: an ``IntegrityError`` leaves it in a failed transaction where
|
||||
every subsequent statement raises ``PendingRollbackError``. Reusing it would
|
||||
mean the errors hardest to record — the database errors — are exactly the ones
|
||||
that never get recorded. So we open a short-lived session of our own, commit,
|
||||
and close.
|
||||
|
||||
**It redacts before it writes.** Everything passes through
|
||||
``app.core.redact`` first. Error payloads are where credentials concentrate.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import sys
|
||||
import traceback
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from app.core.database import SessionFactory
|
||||
from app.core.redact import redact_mapping, redact_text
|
||||
from app.domain.audit.models import (
|
||||
ErrorEvent,
|
||||
ErrorSeverity,
|
||||
ErrorSource,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("agy.errors")
|
||||
|
||||
#: Field caps. An error log that accepts unbounded input is a denial-of-service
|
||||
#: vector and a storage leak; a 2 MB stack trace helps nobody.
|
||||
MAX_MESSAGE_CHARS = 4_000
|
||||
MAX_STACK_CHARS = 16_000
|
||||
MAX_METADATA_JSON_CHARS = 20_000
|
||||
MAX_KIND_CHARS = 80
|
||||
MAX_TYPE_CHARS = 200
|
||||
MAX_PATH_CHARS = 512
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fingerprinting — collapse a thousand occurrences into one row
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_UUID_RE = re.compile(
|
||||
r"\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}"
|
||||
r"-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b"
|
||||
)
|
||||
_HEX_RE = re.compile(r"\b[0-9a-fA-F]{8,}\b")
|
||||
_NUMBER_RE = re.compile(r"\b\d+\b")
|
||||
_QUOTED_RE = re.compile(r"'[^']{0,200}'|\"[^\"]{0,200}\"")
|
||||
#: A path segment that is an entity id rather than a route name. All-hex catches
|
||||
#: ``new_id()`` (32 hex chars); the digit+length rule catches other id styles.
|
||||
#: Route words like ``organizations`` have no digits and survive untouched.
|
||||
_ID_SEGMENT_RE = re.compile(r"^(?:[0-9a-fA-F]{8,}|(?=.*\d)[A-Za-z0-9_-]{16,})$")
|
||||
|
||||
|
||||
def _normalise_message(message: str) -> str:
|
||||
"""Strip the variable parts of a message so equal bugs hash equally.
|
||||
|
||||
``"task 4f3a… not found"`` and ``"task 9b1c… not found"`` are one bug with
|
||||
two victims; without this they are two unrelated rows.
|
||||
"""
|
||||
text = _UUID_RE.sub("{uuid}", message)
|
||||
text = _HEX_RE.sub("{hex}", text)
|
||||
text = _QUOTED_RE.sub("{value}", text)
|
||||
return _NUMBER_RE.sub("{n}", text)
|
||||
|
||||
|
||||
def _normalise_path(path: str) -> str:
|
||||
"""``/api/orgs/4f3a…/projects`` → ``/api/orgs/{id}/projects``."""
|
||||
if not path:
|
||||
return ""
|
||||
return "/".join(
|
||||
"{id}" if _ID_SEGMENT_RE.match(segment) else segment for segment in path.split("/")
|
||||
)
|
||||
|
||||
|
||||
def compute_fingerprint(
|
||||
*,
|
||||
kind: str,
|
||||
error_type: str,
|
||||
method: str = "",
|
||||
path: str = "",
|
||||
message: str = "",
|
||||
) -> str:
|
||||
"""Stable identity for a failure, independent of tenant and occurrence.
|
||||
|
||||
Deliberately *excludes* organization, user and request id: the same bug
|
||||
hitting two tenants is still one bug, and grouping it that way is the whole
|
||||
point of a fingerprint.
|
||||
"""
|
||||
material = "\x1f".join(
|
||||
(
|
||||
kind.strip().lower(),
|
||||
error_type.strip().lower(),
|
||||
method.strip().upper(),
|
||||
_normalise_path(path).lower(),
|
||||
_normalise_message(message.strip())[:500],
|
||||
)
|
||||
)
|
||||
return hashlib.sha256(material.encode("utf-8", "replace")).hexdigest()[:32]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Truncation helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _clip(value: str, limit: int) -> str:
|
||||
if len(value) <= limit:
|
||||
return value
|
||||
return value[:limit] + f"…[+{len(value) - limit} chars]"
|
||||
|
||||
|
||||
def _safe_metadata(metadata: Mapping[str, Any] | None) -> dict[str, Any]:
|
||||
"""Redact, then bound. Returns a dict that is always JSON-serialisable."""
|
||||
if not metadata:
|
||||
return {}
|
||||
scrubbed = redact_mapping(metadata)
|
||||
try:
|
||||
encoded = json.dumps(scrubbed, ensure_ascii=False, default=str)
|
||||
except (TypeError, ValueError):
|
||||
return {"_note": "metadata was not JSON-serialisable"}
|
||||
if len(encoded) <= MAX_METADATA_JSON_CHARS:
|
||||
return scrubbed
|
||||
# Too large to keep: retain the keys so the shape is still visible.
|
||||
return {
|
||||
"_note": f"metadata dropped: {len(encoded)} chars exceeds {MAX_METADATA_JSON_CHARS}",
|
||||
"_keys": sorted(str(k) for k in scrubbed)[:50],
|
||||
}
|
||||
|
||||
|
||||
def format_exception_stack(exc: BaseException) -> str:
|
||||
"""``traceback.format_exception`` without the chained-context noise limit."""
|
||||
return "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))
|
||||
|
||||
|
||||
def _to_stderr(event: ErrorEvent, reason: str) -> None:
|
||||
"""Last-resort output when the database write failed.
|
||||
|
||||
Goes to stderr rather than through ``logging`` so it survives even a
|
||||
misconfigured logging setup — the one case where losing the error entirely
|
||||
is unacceptable.
|
||||
"""
|
||||
with contextlib.suppress(Exception):
|
||||
sys.stderr.write(
|
||||
f"[error-log] could not persist error ({reason}) "
|
||||
f"kind={event.kind} type={event.error_type} "
|
||||
f"path={event.path} msg={event.message[:300]}\n"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def record_error(
|
||||
*,
|
||||
kind: str,
|
||||
message: str,
|
||||
exc: BaseException | None = None,
|
||||
stack: str = "",
|
||||
source: str = ErrorSource.HTTP,
|
||||
severity: str = ErrorSeverity.ERROR,
|
||||
status_code: int | None = None,
|
||||
method: str = "",
|
||||
path: str = "",
|
||||
organization_id: str | None = None,
|
||||
user_id: str | None = None,
|
||||
run_id: str | None = None,
|
||||
request_id: str = "",
|
||||
ip: str = "",
|
||||
user_agent: str = "",
|
||||
metadata: Mapping[str, Any] | None = None,
|
||||
) -> str | None:
|
||||
"""Write one error row. Returns its id, or ``None`` if recording failed.
|
||||
|
||||
Returning ``None`` rather than raising is the contract: callers treat error
|
||||
recording as best-effort telemetry, never as a step that can fail a request.
|
||||
"""
|
||||
try:
|
||||
error_type = type(exc).__name__ if exc is not None else ""
|
||||
if not stack and exc is not None:
|
||||
stack = format_exception_stack(exc)
|
||||
if not message and exc is not None:
|
||||
message = str(exc) or error_type
|
||||
|
||||
# Redact *before* fingerprinting is not required, but redact before
|
||||
# *storage* absolutely is. Fingerprint the raw message so a secret that
|
||||
# happens to appear in a message does not change the grouping.
|
||||
fingerprint = compute_fingerprint(
|
||||
kind=kind, error_type=error_type, method=method, path=path, message=message
|
||||
)
|
||||
|
||||
event = ErrorEvent(
|
||||
kind=_clip(redact_text(kind), MAX_KIND_CHARS),
|
||||
message=_clip(redact_text(message), MAX_MESSAGE_CHARS),
|
||||
stack=_clip(redact_text(stack), MAX_STACK_CHARS),
|
||||
metadata_json=_safe_metadata(metadata),
|
||||
organization_id=organization_id,
|
||||
user_id=user_id,
|
||||
run_id=run_id,
|
||||
source=source,
|
||||
severity=severity,
|
||||
error_type=_clip(redact_text(error_type), MAX_TYPE_CHARS),
|
||||
fingerprint=fingerprint,
|
||||
status_code=status_code,
|
||||
method=_clip(method, 10),
|
||||
path=_clip(redact_text(path), MAX_PATH_CHARS),
|
||||
request_id=_clip(request_id, 64),
|
||||
ip=_clip(redact_text(ip), 64),
|
||||
user_agent=_clip(redact_text(user_agent), 255),
|
||||
)
|
||||
except Exception as build_exc: # pragma: no cover - redaction is defensive
|
||||
logger.exception("error-log: could not build the error record: %s", build_exc)
|
||||
return None
|
||||
|
||||
# A dedicated session: the caller's may be mid-rollback, and a failed error
|
||||
# write must not roll back whatever the caller was legitimately doing.
|
||||
try:
|
||||
async with SessionFactory() as session:
|
||||
from app.infrastructure.repositories.audit import ObservabilityRepository
|
||||
|
||||
await ObservabilityRepository(session).record_error(event)
|
||||
await session.commit()
|
||||
except Exception as write_exc:
|
||||
_to_stderr(event, type(write_exc).__name__)
|
||||
logger.warning("error-log: persistence failed: %s", write_exc)
|
||||
return None
|
||||
|
||||
# Mirror to the application log so the console and the terminal agree.
|
||||
logger.log(
|
||||
logging.ERROR if severity != ErrorSeverity.WARNING else logging.WARNING,
|
||||
"%s [%s] %s %s -> %s: %s (request_id=%s fingerprint=%s)",
|
||||
severity.upper(),
|
||||
source,
|
||||
method or "-",
|
||||
path or "-",
|
||||
status_code if status_code is not None else "-",
|
||||
event.message[:500],
|
||||
request_id or "-",
|
||||
fingerprint,
|
||||
)
|
||||
return event.id
|
||||
|
||||
|
||||
async def record_exception(
|
||||
exc: BaseException,
|
||||
*,
|
||||
kind: str | None = None,
|
||||
**context: Any,
|
||||
) -> str | None:
|
||||
"""``record_error`` for the common case where there is an exception object."""
|
||||
return await record_error(
|
||||
kind=kind or type(exc).__name__,
|
||||
message=str(exc),
|
||||
exc=exc,
|
||||
**context,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"compute_fingerprint",
|
||||
"format_exception_stack",
|
||||
"record_error",
|
||||
"record_exception",
|
||||
]
|
||||
@@ -0,0 +1,276 @@
|
||||
"""Repositories for the AI Control Plane.
|
||||
|
||||
Platform-owner-only data. The application layer is responsible for never
|
||||
serialising any of it to a customer-facing response; these repositories just
|
||||
move rows.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.domain.ai_control.models import (
|
||||
AiModel,
|
||||
ModelCredential,
|
||||
Provider,
|
||||
ProviderType,
|
||||
RateCard,
|
||||
RateUnit,
|
||||
RoutingMode,
|
||||
RoutingPolicy,
|
||||
)
|
||||
from app.infrastructure.db.models import (
|
||||
AiModelCredentialRow,
|
||||
AiModelRow,
|
||||
AiProviderRow,
|
||||
RateCardRow,
|
||||
RoutingPolicyRow,
|
||||
)
|
||||
|
||||
|
||||
def _provider_to_domain(row: AiProviderRow) -> Provider:
|
||||
return Provider(
|
||||
id=row.id, name=row.name, type=ProviderType(row.type),
|
||||
base_url=row.base_url, is_active=row.is_active, created_at=row.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _credential_to_domain(row: AiModelCredentialRow) -> ModelCredential:
|
||||
return ModelCredential(
|
||||
id=row.id, provider_id=row.provider_id, name=row.name,
|
||||
encrypted_api_key=row.encrypted_api_key, is_active=row.is_active,
|
||||
created_at=row.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _model_to_domain(row: AiModelRow) -> AiModel:
|
||||
return AiModel(
|
||||
id=row.id, provider_id=row.provider_id, internal_name=row.internal_name,
|
||||
display_name_for_admin=row.display_name_for_admin,
|
||||
capabilities=list(row.capabilities or []),
|
||||
input_price_per_1m=row.input_price_per_1m,
|
||||
output_price_per_1m=row.output_price_per_1m,
|
||||
is_active=row.is_active, created_at=row.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _policy_to_domain(row: RoutingPolicyRow) -> RoutingPolicy:
|
||||
return RoutingPolicy(
|
||||
id=row.id, name=row.name, description=row.description,
|
||||
mode=RoutingMode(row.mode),
|
||||
fallback_policy_json=dict(row.fallback_policy_json or {}),
|
||||
budget_limits_json=dict(row.budget_limits_json or {}),
|
||||
is_default=row.is_default, is_active=row.is_active, created_at=row.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _rate_card_to_domain(row: RateCardRow) -> RateCard:
|
||||
return RateCard(
|
||||
id=row.id, name=row.name, unit=RateUnit(row.unit),
|
||||
customer_price=row.customer_price,
|
||||
internal_cost_estimate=row.internal_cost_estimate,
|
||||
is_active=row.is_active, created_at=row.created_at,
|
||||
)
|
||||
|
||||
|
||||
class ProviderRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def list(self) -> list[Provider]:
|
||||
stmt = select(AiProviderRow).order_by(AiProviderRow.created_at)
|
||||
return [_provider_to_domain(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||
|
||||
async def get(self, provider_id: str) -> Provider | None:
|
||||
row = await self.session.get(AiProviderRow, provider_id)
|
||||
return _provider_to_domain(row) if row else None
|
||||
|
||||
async def get_by_name(self, name: str) -> Provider | None:
|
||||
stmt = select(AiProviderRow).where(AiProviderRow.name == name)
|
||||
row = (await self.session.execute(stmt)).scalar_one_or_none()
|
||||
return _provider_to_domain(row) if row else None
|
||||
|
||||
async def create(self, provider: Provider) -> Provider:
|
||||
row = AiProviderRow(
|
||||
id=provider.id, name=provider.name, type=provider.type.value,
|
||||
base_url=provider.base_url, is_active=provider.is_active,
|
||||
created_at=provider.created_at,
|
||||
)
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
return _provider_to_domain(row)
|
||||
|
||||
async def set_active(self, provider_id: str, active: bool) -> None:
|
||||
row = await self.session.get(AiProviderRow, provider_id)
|
||||
if row:
|
||||
row.is_active = active
|
||||
await self.session.flush()
|
||||
|
||||
|
||||
class ModelCredentialRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def list_for_provider(self, provider_id: str) -> list[ModelCredential]:
|
||||
stmt = select(AiModelCredentialRow).where(
|
||||
AiModelCredentialRow.provider_id == provider_id
|
||||
)
|
||||
return [_credential_to_domain(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||
|
||||
async def get(self, credential_id: str) -> ModelCredential | None:
|
||||
row = await self.session.get(AiModelCredentialRow, credential_id)
|
||||
return _credential_to_domain(row) if row else None
|
||||
|
||||
async def get_active_for_provider(self, provider_id: str) -> ModelCredential | None:
|
||||
stmt = (
|
||||
select(AiModelCredentialRow)
|
||||
.where(
|
||||
AiModelCredentialRow.provider_id == provider_id,
|
||||
AiModelCredentialRow.is_active.is_(True),
|
||||
)
|
||||
.order_by(AiModelCredentialRow.created_at.desc())
|
||||
)
|
||||
row = (await self.session.execute(stmt)).scalars().first()
|
||||
return _credential_to_domain(row) if row else None
|
||||
|
||||
async def create(self, credential: ModelCredential) -> ModelCredential:
|
||||
row = AiModelCredentialRow(
|
||||
id=credential.id, provider_id=credential.provider_id, name=credential.name,
|
||||
encrypted_api_key=credential.encrypted_api_key,
|
||||
is_active=credential.is_active, created_at=credential.created_at,
|
||||
)
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
return _credential_to_domain(row)
|
||||
|
||||
|
||||
class AiModelRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def list(self, *, provider_id: str | None = None) -> list[AiModel]:
|
||||
stmt = select(AiModelRow)
|
||||
if provider_id:
|
||||
stmt = stmt.where(AiModelRow.provider_id == provider_id)
|
||||
stmt = stmt.order_by(AiModelRow.created_at)
|
||||
return [_model_to_domain(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||
|
||||
async def get(self, model_id: str) -> AiModel | None:
|
||||
row = await self.session.get(AiModelRow, model_id)
|
||||
return _model_to_domain(row) if row else None
|
||||
|
||||
async def create(self, model: AiModel) -> AiModel:
|
||||
row = AiModelRow(
|
||||
id=model.id, provider_id=model.provider_id,
|
||||
internal_name=model.internal_name,
|
||||
display_name_for_admin=model.display_name_for_admin,
|
||||
capabilities=list(model.capabilities),
|
||||
input_price_per_1m=model.input_price_per_1m,
|
||||
output_price_per_1m=model.output_price_per_1m,
|
||||
is_active=model.is_active, created_at=model.created_at,
|
||||
)
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
return _model_to_domain(row)
|
||||
|
||||
async def pick_for_mode(self, mode: RoutingMode) -> AiModel | None:
|
||||
"""Choose a model for a routing mode.
|
||||
|
||||
Deterministic and explainable: filter by required capability, then take
|
||||
the cheapest active model. A real implementation would add health and
|
||||
latency signals; keeping it simple here makes the behaviour testable.
|
||||
"""
|
||||
required: set[str] = set()
|
||||
if mode == RoutingMode.VISION:
|
||||
required.add("vision")
|
||||
if mode == RoutingMode.CODING:
|
||||
required.add("code")
|
||||
if mode == RoutingMode.REASONING:
|
||||
required.add("reasoning")
|
||||
|
||||
candidates = [m for m in await self.list() if m.is_active]
|
||||
if required:
|
||||
filtered = [m for m in candidates if required.issubset(set(m.capabilities))]
|
||||
if filtered:
|
||||
candidates = filtered
|
||||
if not candidates:
|
||||
return None
|
||||
return min(candidates, key=lambda m: (m.input_price_per_1m, m.output_price_per_1m))
|
||||
|
||||
|
||||
class RoutingPolicyRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def list(self) -> list[RoutingPolicy]:
|
||||
stmt = select(RoutingPolicyRow).order_by(RoutingPolicyRow.created_at)
|
||||
return [_policy_to_domain(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||
|
||||
async def get(self, policy_id: str) -> RoutingPolicy | None:
|
||||
row = await self.session.get(RoutingPolicyRow, policy_id)
|
||||
return _policy_to_domain(row) if row else None
|
||||
|
||||
async def get_default(self) -> RoutingPolicy | None:
|
||||
stmt = select(RoutingPolicyRow).where(RoutingPolicyRow.is_default.is_(True))
|
||||
row = (await self.session.execute(stmt)).scalars().first()
|
||||
return _policy_to_domain(row) if row else None
|
||||
|
||||
async def create(self, policy: RoutingPolicy) -> RoutingPolicy:
|
||||
if policy.is_default:
|
||||
await self._clear_defaults()
|
||||
row = RoutingPolicyRow(
|
||||
id=policy.id, name=policy.name, description=policy.description,
|
||||
mode=policy.mode.value,
|
||||
fallback_policy_json=policy.fallback_policy_json,
|
||||
budget_limits_json=policy.budget_limits_json,
|
||||
is_default=policy.is_default, is_active=policy.is_active,
|
||||
created_at=policy.created_at,
|
||||
)
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
return _policy_to_domain(row)
|
||||
|
||||
async def _clear_defaults(self) -> None:
|
||||
rows = (await self.session.execute(select(RoutingPolicyRow))).scalars().all()
|
||||
for row in rows:
|
||||
row.is_default = False
|
||||
await self.session.flush()
|
||||
|
||||
|
||||
class RateCardRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def list(self) -> list[RateCard]:
|
||||
stmt = select(RateCardRow).order_by(RateCardRow.unit)
|
||||
return [_rate_card_to_domain(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||
|
||||
async def get_by_unit(self, unit: RateUnit) -> RateCard | None:
|
||||
stmt = select(RateCardRow).where(RateCardRow.unit == unit.value)
|
||||
row = (await self.session.execute(stmt)).scalar_one_or_none()
|
||||
return _rate_card_to_domain(row) if row else None
|
||||
|
||||
async def upsert(self, card: RateCard) -> RateCard:
|
||||
existing = await self.get_by_unit(card.unit)
|
||||
if existing:
|
||||
row = await self.session.get(RateCardRow, existing.id)
|
||||
assert row is not None
|
||||
row.customer_price = card.customer_price
|
||||
row.internal_cost_estimate = card.internal_cost_estimate
|
||||
row.name = card.name
|
||||
row.is_active = card.is_active
|
||||
await self.session.flush()
|
||||
return _rate_card_to_domain(row)
|
||||
return await self.create(card)
|
||||
|
||||
async def create(self, card: RateCard) -> RateCard:
|
||||
row = RateCardRow(
|
||||
id=card.id, name=card.name, unit=card.unit.value,
|
||||
customer_price=card.customer_price,
|
||||
internal_cost_estimate=card.internal_cost_estimate,
|
||||
is_active=card.is_active, created_at=card.created_at,
|
||||
)
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
return _rate_card_to_domain(row)
|
||||
@@ -0,0 +1,496 @@
|
||||
"""Repositories for Audit & Observability and the Usage Ledger."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import case, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.application.ports.audit import UsageAggregate, UsageBreakdown, UsageRecord
|
||||
from app.domain.ai_control.models import RateUnit
|
||||
from app.domain.audit.models import AuditEvent, ErrorEvent, QualityMetric, Span, Trace
|
||||
from app.infrastructure.db.models import (
|
||||
AuditEventRow,
|
||||
ErrorEventRow,
|
||||
QualityMetricRow,
|
||||
SpanRow,
|
||||
TraceRow,
|
||||
UsageEventRow,
|
||||
)
|
||||
|
||||
|
||||
def _audit_to_domain(row: AuditEventRow) -> AuditEvent:
|
||||
return AuditEvent(
|
||||
id=row.id,
|
||||
organization_id=row.organization_id,
|
||||
actor_user_id=row.actor_user_id,
|
||||
action=row.action,
|
||||
target_type=row.target_type,
|
||||
target_id=row.target_id,
|
||||
metadata_json=row.metadata_json or {},
|
||||
ip_address=row.ip_address,
|
||||
user_agent=row.user_agent,
|
||||
created_at=row.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _error_to_domain(row: ErrorEventRow) -> ErrorEvent:
|
||||
return ErrorEvent(
|
||||
id=row.id,
|
||||
organization_id=row.organization_id,
|
||||
run_id=row.run_id,
|
||||
kind=row.kind,
|
||||
message=row.message,
|
||||
stack=row.stack,
|
||||
metadata_json=row.metadata_json or {},
|
||||
source=row.source,
|
||||
severity=row.severity,
|
||||
error_type=row.error_type,
|
||||
fingerprint=row.fingerprint,
|
||||
status_code=row.status_code,
|
||||
method=row.method,
|
||||
path=row.path,
|
||||
user_id=row.user_id,
|
||||
request_id=row.request_id,
|
||||
ip=row.ip,
|
||||
user_agent=row.user_agent,
|
||||
resolved_at=row.resolved_at,
|
||||
resolved_note=row.resolved_note,
|
||||
created_at=row.created_at,
|
||||
)
|
||||
|
||||
|
||||
class AuditRepository:
|
||||
"""Concrete ``AuditPort``. Append-only."""
|
||||
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def record(self, *, event: AuditEvent) -> AuditEvent:
|
||||
row = AuditEventRow(
|
||||
id=event.id,
|
||||
organization_id=event.organization_id,
|
||||
actor_user_id=event.actor_user_id,
|
||||
action=event.action,
|
||||
target_type=event.target_type,
|
||||
target_id=event.target_id,
|
||||
metadata_json=event.metadata_json,
|
||||
ip_address=event.ip_address,
|
||||
user_agent=event.user_agent,
|
||||
created_at=event.created_at,
|
||||
)
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
return _audit_to_domain(row)
|
||||
|
||||
async def list_for_organization(
|
||||
self, *, organization_id: str, limit: int = 100, offset: int = 0
|
||||
) -> list[AuditEvent]:
|
||||
stmt = (
|
||||
select(AuditEventRow)
|
||||
.where(AuditEventRow.organization_id == organization_id)
|
||||
.order_by(AuditEventRow.created_at.desc())
|
||||
.limit(min(limit, 500))
|
||||
.offset(offset)
|
||||
)
|
||||
return [_audit_to_domain(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||
|
||||
async def list_platform(self, *, limit: int = 100, offset: int = 0) -> list[AuditEvent]:
|
||||
stmt = (
|
||||
select(AuditEventRow)
|
||||
.order_by(AuditEventRow.created_at.desc())
|
||||
.limit(min(limit, 500))
|
||||
.offset(offset)
|
||||
)
|
||||
return [_audit_to_domain(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||
|
||||
|
||||
class UsageLedgerRepository:
|
||||
"""Concrete ``UsageLedgerPort``. The ledger is the billing source of truth."""
|
||||
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def record(self, *, usage: UsageRecord) -> None:
|
||||
total = usage.total_price or (usage.quantity * usage.unit_price)
|
||||
row = UsageEventRow(
|
||||
organization_id=usage.organization_id,
|
||||
project_id=usage.project_id,
|
||||
user_id=usage.user_id,
|
||||
employee_id=usage.employee_id,
|
||||
run_id=usage.run_id,
|
||||
unit=usage.unit.value,
|
||||
quantity=usage.quantity,
|
||||
unit_price=usage.unit_price,
|
||||
internal_cost_estimate=usage.internal_cost_estimate,
|
||||
total_price=total,
|
||||
metadata_json=usage.metadata_json,
|
||||
)
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
|
||||
async def breakdown(
|
||||
self,
|
||||
*,
|
||||
organization_id: str,
|
||||
since: datetime | None = None,
|
||||
until: datetime | None = None,
|
||||
) -> UsageBreakdown:
|
||||
filters = [UsageEventRow.organization_id == organization_id]
|
||||
if since is not None:
|
||||
filters.append(UsageEventRow.created_at >= since)
|
||||
if until is not None:
|
||||
filters.append(UsageEventRow.created_at <= until)
|
||||
|
||||
by_unit_stmt = (
|
||||
select(
|
||||
UsageEventRow.unit,
|
||||
func.coalesce(func.sum(UsageEventRow.quantity), 0.0),
|
||||
func.coalesce(func.sum(UsageEventRow.total_price), 0.0),
|
||||
func.coalesce(func.sum(UsageEventRow.internal_cost_estimate), 0.0),
|
||||
)
|
||||
.where(*filters)
|
||||
.group_by(UsageEventRow.unit)
|
||||
)
|
||||
by_unit = [
|
||||
UsageAggregate(
|
||||
unit=RateUnit(unit),
|
||||
quantity=float(qty),
|
||||
total_price=float(price),
|
||||
internal_cost_estimate=float(cost),
|
||||
)
|
||||
for unit, qty, price, cost in (await self.session.execute(by_unit_stmt)).all()
|
||||
]
|
||||
|
||||
emp_stmt = (
|
||||
select(
|
||||
UsageEventRow.employee_id,
|
||||
func.coalesce(func.sum(UsageEventRow.total_price), 0.0),
|
||||
)
|
||||
.where(*filters, UsageEventRow.employee_id.is_not(None))
|
||||
.group_by(UsageEventRow.employee_id)
|
||||
)
|
||||
by_employee = {
|
||||
str(emp): float(total)
|
||||
for emp, total in (await self.session.execute(emp_stmt)).all()
|
||||
}
|
||||
|
||||
proj_stmt = (
|
||||
select(
|
||||
UsageEventRow.project_id,
|
||||
func.coalesce(func.sum(UsageEventRow.total_price), 0.0),
|
||||
)
|
||||
.where(*filters, UsageEventRow.project_id.is_not(None))
|
||||
.group_by(UsageEventRow.project_id)
|
||||
)
|
||||
by_project = {
|
||||
str(proj): float(total)
|
||||
for proj, total in (await self.session.execute(proj_stmt)).all()
|
||||
}
|
||||
|
||||
return UsageBreakdown(
|
||||
by_unit=by_unit,
|
||||
by_employee=by_employee,
|
||||
by_project=by_project,
|
||||
total_price=sum(a.total_price for a in by_unit),
|
||||
total_cost=sum(a.internal_cost_estimate for a in by_unit),
|
||||
)
|
||||
|
||||
async def list_events(
|
||||
self, *, organization_id: str, limit: int = 100, offset: int = 0
|
||||
) -> list[UsageEventRow]:
|
||||
stmt = (
|
||||
select(UsageEventRow)
|
||||
.where(UsageEventRow.organization_id == organization_id)
|
||||
.order_by(UsageEventRow.created_at.desc())
|
||||
.limit(min(limit, 500))
|
||||
.offset(offset)
|
||||
)
|
||||
return list((await self.session.execute(stmt)).scalars())
|
||||
|
||||
async def platform_totals(self) -> list[tuple[str, float, float]]:
|
||||
"""``(organization_id, quantity, total_price)`` across every tenant.
|
||||
|
||||
Platform-owner console only.
|
||||
"""
|
||||
stmt = (
|
||||
select(
|
||||
UsageEventRow.organization_id,
|
||||
func.coalesce(func.sum(UsageEventRow.quantity), 0.0),
|
||||
func.coalesce(func.sum(UsageEventRow.total_price), 0.0),
|
||||
)
|
||||
.group_by(UsageEventRow.organization_id)
|
||||
.order_by(func.sum(UsageEventRow.total_price).desc())
|
||||
)
|
||||
rows = (await self.session.execute(stmt)).all()
|
||||
return [(str(org), float(qty), float(price)) for org, qty, price in rows]
|
||||
|
||||
|
||||
class ObservabilityRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def create_trace(self, trace: Trace) -> Trace:
|
||||
self.session.add(
|
||||
TraceRow(
|
||||
id=trace.id, organization_id=trace.organization_id,
|
||||
run_id=trace.run_id, name=trace.name, created_at=trace.created_at,
|
||||
)
|
||||
)
|
||||
await self.session.flush()
|
||||
return trace
|
||||
|
||||
async def add_span(self, span: Span) -> Span:
|
||||
self.session.add(
|
||||
SpanRow(
|
||||
id=span.id, trace_id=span.trace_id, parent_span_id=span.parent_span_id,
|
||||
name=span.name, kind=span.kind, attributes_json=span.attributes_json,
|
||||
started_at=span.started_at, ended_at=span.ended_at,
|
||||
)
|
||||
)
|
||||
await self.session.flush()
|
||||
return span
|
||||
|
||||
async def record_error(self, event: ErrorEvent) -> None:
|
||||
self.session.add(
|
||||
ErrorEventRow(
|
||||
id=event.id,
|
||||
organization_id=event.organization_id,
|
||||
run_id=event.run_id,
|
||||
kind=event.kind,
|
||||
message=event.message,
|
||||
stack=event.stack,
|
||||
metadata_json=event.metadata_json,
|
||||
source=event.source,
|
||||
severity=event.severity,
|
||||
error_type=event.error_type,
|
||||
fingerprint=event.fingerprint,
|
||||
status_code=event.status_code,
|
||||
method=event.method,
|
||||
path=event.path,
|
||||
user_id=event.user_id,
|
||||
request_id=event.request_id,
|
||||
ip=event.ip,
|
||||
user_agent=event.user_agent,
|
||||
resolved_at=event.resolved_at,
|
||||
resolved_note=event.resolved_note,
|
||||
created_at=event.created_at,
|
||||
)
|
||||
)
|
||||
await self.session.flush()
|
||||
|
||||
async def record_metric(self, metric: QualityMetric) -> None:
|
||||
self.session.add(
|
||||
QualityMetricRow(
|
||||
id=metric.id, organization_id=metric.organization_id, name=metric.name,
|
||||
value=metric.value, dimensions_json=metric.dimensions_json,
|
||||
created_at=metric.created_at,
|
||||
)
|
||||
)
|
||||
await self.session.flush()
|
||||
|
||||
async def list_spans(self, *, trace_id: str) -> list[Span]:
|
||||
stmt = select(SpanRow).where(SpanRow.trace_id == trace_id).order_by(SpanRow.started_at)
|
||||
return [
|
||||
Span(
|
||||
id=r.id, trace_id=r.trace_id, parent_span_id=r.parent_span_id, name=r.name,
|
||||
kind=r.kind, attributes_json=r.attributes_json or {},
|
||||
started_at=r.started_at, ended_at=r.ended_at,
|
||||
)
|
||||
for r in (await self.session.execute(stmt)).scalars()
|
||||
]
|
||||
|
||||
# -- error console --------------------------------------------------------
|
||||
|
||||
def _error_filters(
|
||||
self,
|
||||
*,
|
||||
severity: str | None = None,
|
||||
source: str | None = None,
|
||||
status: str | None = None,
|
||||
kind: str | None = None,
|
||||
organization_id: str | None = None,
|
||||
fingerprint: str | None = None,
|
||||
since: datetime | None = None,
|
||||
search: str | None = None,
|
||||
) -> list:
|
||||
"""Build the WHERE clauses shared by the list and count queries."""
|
||||
clauses: list = []
|
||||
if severity:
|
||||
clauses.append(ErrorEventRow.severity == severity)
|
||||
if source:
|
||||
clauses.append(ErrorEventRow.source == source)
|
||||
if status == "open":
|
||||
clauses.append(ErrorEventRow.resolved_at.is_(None))
|
||||
elif status == "resolved":
|
||||
clauses.append(ErrorEventRow.resolved_at.is_not(None))
|
||||
if kind:
|
||||
clauses.append(ErrorEventRow.kind == kind)
|
||||
if organization_id:
|
||||
clauses.append(ErrorEventRow.organization_id == organization_id)
|
||||
if fingerprint:
|
||||
clauses.append(ErrorEventRow.fingerprint == fingerprint)
|
||||
if since is not None:
|
||||
clauses.append(ErrorEventRow.created_at >= since)
|
||||
if search:
|
||||
clauses.append(ErrorEventRow.message.ilike(f"%{search}%"))
|
||||
return clauses
|
||||
|
||||
async def list_errors(
|
||||
self,
|
||||
*,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
**filters,
|
||||
) -> list[ErrorEvent]:
|
||||
stmt = (
|
||||
select(ErrorEventRow)
|
||||
.where(*self._error_filters(**filters))
|
||||
# Newest first: the failure someone is reacting to is almost always
|
||||
# the most recent one.
|
||||
.order_by(ErrorEventRow.created_at.desc())
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
)
|
||||
return [_error_to_domain(row) for row in (await self.session.execute(stmt)).scalars()]
|
||||
|
||||
async def count_errors(self, **filters) -> int:
|
||||
stmt = select(func.count()).select_from(ErrorEventRow).where(
|
||||
*self._error_filters(**filters)
|
||||
)
|
||||
return int((await self.session.execute(stmt)).scalar_one())
|
||||
|
||||
async def get_error(self, error_id: str) -> ErrorEvent | None:
|
||||
row = (
|
||||
await self.session.execute(
|
||||
select(ErrorEventRow).where(ErrorEventRow.id == error_id)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
return _error_to_domain(row) if row is not None else None
|
||||
|
||||
async def list_error_groups(
|
||||
self,
|
||||
*,
|
||||
limit: int = 50,
|
||||
since: datetime | None = None,
|
||||
status: str | None = None,
|
||||
severity: str | None = None,
|
||||
source: str | None = None,
|
||||
organization_id: str | None = None,
|
||||
) -> list[dict]:
|
||||
"""One row per distinct bug, newest activity first.
|
||||
|
||||
This is the view that makes the console usable: a bug that fired 4 000
|
||||
times is one line with a count, not 4 000 lines. ``open_count`` is what
|
||||
tells you whether anyone has actually dealt with it.
|
||||
"""
|
||||
clauses: list = [ErrorEventRow.fingerprint != ""]
|
||||
if since is not None:
|
||||
clauses.append(ErrorEventRow.created_at >= since)
|
||||
if severity:
|
||||
clauses.append(ErrorEventRow.severity == severity)
|
||||
if source:
|
||||
clauses.append(ErrorEventRow.source == source)
|
||||
if organization_id:
|
||||
clauses.append(ErrorEventRow.organization_id == organization_id)
|
||||
|
||||
open_count = func.sum(
|
||||
case((ErrorEventRow.resolved_at.is_(None), 1), else_=0)
|
||||
).label("open_count")
|
||||
stmt = (
|
||||
select(
|
||||
ErrorEventRow.fingerprint,
|
||||
func.count().label("occurrences"),
|
||||
open_count,
|
||||
func.min(ErrorEventRow.created_at).label("first_seen"),
|
||||
func.max(ErrorEventRow.created_at).label("last_seen"),
|
||||
func.max(ErrorEventRow.kind).label("kind"),
|
||||
func.max(ErrorEventRow.error_type).label("error_type"),
|
||||
func.max(ErrorEventRow.severity).label("severity"),
|
||||
func.max(ErrorEventRow.source).label("source"),
|
||||
func.max(ErrorEventRow.path).label("path"),
|
||||
func.max(ErrorEventRow.message).label("sample_message"),
|
||||
)
|
||||
.where(*clauses)
|
||||
.group_by(ErrorEventRow.fingerprint)
|
||||
.order_by(func.max(ErrorEventRow.created_at).desc())
|
||||
.limit(limit)
|
||||
)
|
||||
rows = (await self.session.execute(stmt)).all()
|
||||
groups = [
|
||||
{
|
||||
"fingerprint": row.fingerprint,
|
||||
"occurrences": int(row.occurrences),
|
||||
"open_count": int(row.open_count or 0),
|
||||
"first_seen": row.first_seen,
|
||||
"last_seen": row.last_seen,
|
||||
"kind": row.kind or "",
|
||||
"error_type": row.error_type or "",
|
||||
"severity": row.severity or "",
|
||||
"source": row.source or "",
|
||||
"path": row.path or "",
|
||||
"sample_message": row.sample_message or "",
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
if status == "open":
|
||||
return [g for g in groups if g["open_count"] > 0]
|
||||
if status == "resolved":
|
||||
return [g for g in groups if g["open_count"] == 0]
|
||||
return groups
|
||||
|
||||
async def resolve_error(
|
||||
self, error_id: str, *, note: str, resolved_at: datetime
|
||||
) -> ErrorEvent | None:
|
||||
row = (
|
||||
await self.session.execute(
|
||||
select(ErrorEventRow).where(ErrorEventRow.id == error_id)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if row is None:
|
||||
return None
|
||||
row.resolved_at = resolved_at
|
||||
row.resolved_note = note
|
||||
await self.session.flush()
|
||||
return _error_to_domain(row)
|
||||
|
||||
async def resolve_error_group(
|
||||
self, fingerprint: str, *, note: str, resolved_at: datetime
|
||||
) -> int:
|
||||
"""Resolve every open occurrence of one bug. Returns how many changed."""
|
||||
stmt = select(ErrorEventRow).where(
|
||||
ErrorEventRow.fingerprint == fingerprint,
|
||||
ErrorEventRow.resolved_at.is_(None),
|
||||
)
|
||||
rows = list((await self.session.execute(stmt)).scalars())
|
||||
for row in rows:
|
||||
row.resolved_at = resolved_at
|
||||
row.resolved_note = note
|
||||
await self.session.flush()
|
||||
return len(rows)
|
||||
|
||||
async def reopen_error_group(self, fingerprint: str) -> int:
|
||||
"""Undo a resolution — for when the bug comes back."""
|
||||
stmt = select(ErrorEventRow).where(
|
||||
ErrorEventRow.fingerprint == fingerprint,
|
||||
ErrorEventRow.resolved_at.is_not(None),
|
||||
)
|
||||
rows = list((await self.session.execute(stmt)).scalars())
|
||||
for row in rows:
|
||||
row.resolved_at = None
|
||||
row.resolved_note = ""
|
||||
await self.session.flush()
|
||||
return len(rows)
|
||||
|
||||
async def count_open_by_severity(self) -> dict[str, int]:
|
||||
"""Unresolved counts per severity, for the console's header tiles."""
|
||||
stmt = (
|
||||
select(ErrorEventRow.severity, func.count())
|
||||
.where(ErrorEventRow.resolved_at.is_(None))
|
||||
.group_by(ErrorEventRow.severity)
|
||||
)
|
||||
return {
|
||||
str(severity): int(count)
|
||||
for severity, count in (await self.session.execute(stmt)).all()
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
"""Repositories for Billing: plans, subscriptions, wallets, invoices, payments.
|
||||
|
||||
The usage ledger lives in ``repositories/audit.py`` because it is shared with the
|
||||
observability layer; billing reads it but never writes to it directly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.domain.billing.models import (
|
||||
CreditWallet,
|
||||
Invoice,
|
||||
InvoiceStatus,
|
||||
Payment,
|
||||
PaymentStatus,
|
||||
Plan,
|
||||
Subscription,
|
||||
SubscriptionStatus,
|
||||
)
|
||||
from app.infrastructure.db.models import (
|
||||
CreditWalletRow,
|
||||
InvoiceRow,
|
||||
PaymentRow,
|
||||
PlanRow,
|
||||
SubscriptionRow,
|
||||
)
|
||||
|
||||
|
||||
def _plan(row: PlanRow) -> Plan:
|
||||
return Plan(
|
||||
id=row.id, name=row.name, monthly_price=row.monthly_price,
|
||||
included_credit=row.included_credit, features_json=dict(row.features_json or {}),
|
||||
is_active=row.is_active, created_at=row.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _subscription(row: SubscriptionRow) -> Subscription:
|
||||
return Subscription(
|
||||
id=row.id, organization_id=row.organization_id, plan_id=row.plan_id,
|
||||
status=SubscriptionStatus(row.status),
|
||||
external_subscription_id=row.external_subscription_id,
|
||||
current_period_end=row.current_period_end, created_at=row.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _wallet(row: CreditWalletRow) -> CreditWallet:
|
||||
return CreditWallet(
|
||||
id=row.id, organization_id=row.organization_id, balance=row.balance,
|
||||
currency=row.currency, low_balance_threshold=row.low_balance_threshold,
|
||||
updated_at=row.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _invoice(row: InvoiceRow) -> Invoice:
|
||||
return Invoice(
|
||||
id=row.id, organization_id=row.organization_id, amount=row.amount,
|
||||
currency=row.currency, status=InvoiceStatus(row.status),
|
||||
external_invoice_id=row.external_invoice_id, period_start=row.period_start,
|
||||
period_end=row.period_end, created_at=row.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _payment(row: PaymentRow) -> Payment:
|
||||
return Payment(
|
||||
id=row.id, organization_id=row.organization_id, amount=row.amount,
|
||||
status=PaymentStatus(row.status), provider=row.provider,
|
||||
external_payment_id=row.external_payment_id, invoice_id=row.invoice_id,
|
||||
created_at=row.created_at,
|
||||
)
|
||||
|
||||
|
||||
class PlanRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def list(self) -> list[Plan]:
|
||||
stmt = select(PlanRow).order_by(PlanRow.monthly_price)
|
||||
return [_plan(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||
|
||||
async def get(self, plan_id: str) -> Plan | None:
|
||||
row = await self.session.get(PlanRow, plan_id)
|
||||
return _plan(row) if row else None
|
||||
|
||||
|
||||
class SubscriptionRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def get_for_org(self, organization_id: str) -> Subscription | None:
|
||||
stmt = select(SubscriptionRow).where(
|
||||
SubscriptionRow.organization_id == organization_id
|
||||
)
|
||||
row = (await self.session.execute(stmt)).scalars().first()
|
||||
return _subscription(row) if row else None
|
||||
|
||||
async def upsert(self, subscription: Subscription) -> Subscription:
|
||||
existing = await self.get_for_org(subscription.organization_id)
|
||||
if existing:
|
||||
row = await self.session.get(SubscriptionRow, existing.id)
|
||||
assert row is not None
|
||||
row.plan_id = subscription.plan_id
|
||||
row.status = str(subscription.status)
|
||||
row.external_subscription_id = subscription.external_subscription_id
|
||||
row.current_period_end = subscription.current_period_end
|
||||
await self.session.flush()
|
||||
return _subscription(row)
|
||||
|
||||
row = SubscriptionRow(
|
||||
id=subscription.id, organization_id=subscription.organization_id,
|
||||
plan_id=subscription.plan_id, status=str(subscription.status),
|
||||
external_subscription_id=subscription.external_subscription_id,
|
||||
current_period_end=subscription.current_period_end,
|
||||
created_at=subscription.created_at,
|
||||
)
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
return _subscription(row)
|
||||
|
||||
|
||||
class CreditWalletRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def get_for_org(self, organization_id: str) -> CreditWallet | None:
|
||||
stmt = select(CreditWalletRow).where(
|
||||
CreditWalletRow.organization_id == organization_id
|
||||
)
|
||||
row = (await self.session.execute(stmt)).scalar_one_or_none()
|
||||
return _wallet(row) if row else None
|
||||
|
||||
async def ensure(self, organization_id: str) -> CreditWallet:
|
||||
"""Return the org's wallet, creating a zero-balance one if absent."""
|
||||
existing = await self.get_for_org(organization_id)
|
||||
if existing:
|
||||
return existing
|
||||
row = CreditWalletRow(organization_id=organization_id, balance=0.0, currency="USD")
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
return _wallet(row)
|
||||
|
||||
async def adjust(self, *, organization_id: str, delta: float) -> CreditWallet:
|
||||
"""Add (or subtract) credit. Callers must validate the delta's sign."""
|
||||
wallet = await self.ensure(organization_id)
|
||||
row = await self.session.get(CreditWalletRow, wallet.id)
|
||||
assert row is not None
|
||||
row.balance = float(row.balance) + float(delta)
|
||||
await self.session.flush()
|
||||
return _wallet(row)
|
||||
|
||||
|
||||
class InvoiceRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def list_for_org(self, organization_id: str) -> list[Invoice]:
|
||||
stmt = (
|
||||
select(InvoiceRow)
|
||||
.where(InvoiceRow.organization_id == organization_id)
|
||||
.order_by(InvoiceRow.created_at.desc())
|
||||
)
|
||||
return [_invoice(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||
|
||||
async def create(self, invoice: Invoice) -> Invoice:
|
||||
row = InvoiceRow(
|
||||
id=invoice.id, organization_id=invoice.organization_id,
|
||||
amount=invoice.amount, currency=invoice.currency,
|
||||
status=str(invoice.status),
|
||||
external_invoice_id=invoice.external_invoice_id,
|
||||
period_start=invoice.period_start, period_end=invoice.period_end,
|
||||
created_at=invoice.created_at,
|
||||
)
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
return _invoice(row)
|
||||
|
||||
|
||||
class PaymentRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def list_for_org(self, organization_id: str) -> list[Payment]:
|
||||
stmt = (
|
||||
select(PaymentRow)
|
||||
.where(PaymentRow.organization_id == organization_id)
|
||||
.order_by(PaymentRow.created_at.desc())
|
||||
)
|
||||
return [_payment(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||
|
||||
async def create(self, payment: Payment) -> Payment:
|
||||
row = PaymentRow(
|
||||
id=payment.id, organization_id=payment.organization_id,
|
||||
amount=payment.amount, status=str(payment.status),
|
||||
provider=payment.provider, external_payment_id=payment.external_payment_id,
|
||||
invoice_id=payment.invoice_id, created_at=payment.created_at,
|
||||
)
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
return _payment(row)
|
||||
|
||||
async def set_status(
|
||||
self, payment_id: str, status: PaymentStatus, *, external_id: str | None = None
|
||||
) -> Payment | None:
|
||||
row = await self.session.get(PaymentRow, payment_id)
|
||||
if row is None:
|
||||
return None
|
||||
row.status = str(status)
|
||||
if external_id is not None:
|
||||
row.external_payment_id = external_id
|
||||
await self.session.flush()
|
||||
return _payment(row)
|
||||
@@ -0,0 +1,269 @@
|
||||
"""Repositories for Identity & Access.
|
||||
|
||||
Each repository maps between the ORM rows and the pure domain dataclasses, so
|
||||
the domain layer never sees SQLAlchemy.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.domain.identity.models import (
|
||||
Invite,
|
||||
Membership,
|
||||
MembershipStatus,
|
||||
Organization,
|
||||
Role,
|
||||
User,
|
||||
)
|
||||
from app.infrastructure.db.models import (
|
||||
InviteRow,
|
||||
MembershipRow,
|
||||
OrganizationRow,
|
||||
UserRow,
|
||||
)
|
||||
|
||||
|
||||
def _user_to_domain(row: UserRow) -> User:
|
||||
return User(
|
||||
id=row.id,
|
||||
email=row.email,
|
||||
password_hash=row.password_hash,
|
||||
name=row.name,
|
||||
avatar_url=row.avatar_url,
|
||||
is_platform_owner=row.is_platform_owner,
|
||||
is_active=row.is_active,
|
||||
created_at=row.created_at,
|
||||
updated_at=row.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _org_to_domain(row: OrganizationRow) -> Organization:
|
||||
return Organization(
|
||||
id=row.id, name=row.name, slug=row.slug,
|
||||
owner_user_id=row.owner_user_id, created_at=row.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _membership_to_domain(row: MembershipRow) -> Membership:
|
||||
return Membership(
|
||||
id=row.id,
|
||||
organization_id=row.organization_id,
|
||||
user_id=row.user_id,
|
||||
role=Role(row.role),
|
||||
status=MembershipStatus(row.status),
|
||||
created_at=row.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _invite_to_domain(row: InviteRow) -> Invite:
|
||||
return Invite(
|
||||
id=row.id,
|
||||
organization_id=row.organization_id,
|
||||
email=row.email,
|
||||
role=Role(row.role),
|
||||
token_hash=row.token_hash,
|
||||
invited_by_user_id=row.invited_by_user_id,
|
||||
expires_at=row.expires_at,
|
||||
accepted_at=row.accepted_at,
|
||||
created_at=row.created_at,
|
||||
)
|
||||
|
||||
|
||||
class UserRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def get(self, user_id: str) -> User | None:
|
||||
row = await self.session.get(UserRow, user_id)
|
||||
return _user_to_domain(row) if row else None
|
||||
|
||||
async def get_by_email(self, email: str) -> User | None:
|
||||
stmt = select(UserRow).where(func.lower(UserRow.email) == email.strip().lower())
|
||||
row = (await self.session.execute(stmt)).scalar_one_or_none()
|
||||
return _user_to_domain(row) if row else None
|
||||
|
||||
async def create(self, user: User) -> User:
|
||||
row = UserRow(
|
||||
id=user.id,
|
||||
email=user.normalized_email(),
|
||||
password_hash=user.password_hash,
|
||||
name=user.name,
|
||||
avatar_url=user.avatar_url,
|
||||
is_platform_owner=user.is_platform_owner,
|
||||
is_active=user.is_active,
|
||||
created_at=user.created_at,
|
||||
updated_at=user.updated_at,
|
||||
)
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
return _user_to_domain(row)
|
||||
|
||||
async def update_password(self, user_id: str, password_hash: str) -> None:
|
||||
row = await self.session.get(UserRow, user_id)
|
||||
if row:
|
||||
row.password_hash = password_hash
|
||||
await self.session.flush()
|
||||
|
||||
async def count(self) -> int:
|
||||
return int((await self.session.execute(select(func.count(UserRow.id)))).scalar_one())
|
||||
|
||||
|
||||
class OrganizationRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def get(self, org_id: str) -> Organization | None:
|
||||
row = await self.session.get(OrganizationRow, org_id)
|
||||
return _org_to_domain(row) if row else None
|
||||
|
||||
async def get_by_slug(self, slug: str) -> Organization | None:
|
||||
stmt = select(OrganizationRow).where(OrganizationRow.slug == slug)
|
||||
row = (await self.session.execute(stmt)).scalar_one_or_none()
|
||||
return _org_to_domain(row) if row else None
|
||||
|
||||
async def list_all_slugs(self) -> set[str]:
|
||||
rows = (await self.session.execute(select(OrganizationRow.slug))).scalars().all()
|
||||
return set(rows)
|
||||
|
||||
async def create(self, org: Organization) -> Organization:
|
||||
row = OrganizationRow(
|
||||
id=org.id, name=org.name, slug=org.slug,
|
||||
owner_user_id=org.owner_user_id, created_at=org.created_at,
|
||||
)
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
return _org_to_domain(row)
|
||||
|
||||
async def list_for_user(self, user_id: str) -> list[tuple[Organization, Role]]:
|
||||
stmt = (
|
||||
select(OrganizationRow, MembershipRow.role)
|
||||
.join(MembershipRow, MembershipRow.organization_id == OrganizationRow.id)
|
||||
.where(MembershipRow.user_id == user_id, MembershipRow.status == "active")
|
||||
.order_by(OrganizationRow.created_at)
|
||||
)
|
||||
rows = (await self.session.execute(stmt)).all()
|
||||
return [(_org_to_domain(o), Role(r)) for o, r in rows]
|
||||
|
||||
async def list_all(self) -> list[Organization]:
|
||||
"""Every organization. Platform-owner console only — never expose this
|
||||
through a tenant-scoped endpoint."""
|
||||
stmt = select(OrganizationRow).order_by(OrganizationRow.created_at)
|
||||
return [_org_to_domain(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||
|
||||
async def delete(self, org_id: str) -> None:
|
||||
row = await self.session.get(OrganizationRow, org_id)
|
||||
if row:
|
||||
await self.session.delete(row)
|
||||
await self.session.flush()
|
||||
|
||||
async def count(self) -> int:
|
||||
return int(
|
||||
(await self.session.execute(select(func.count(OrganizationRow.id)))).scalar_one()
|
||||
)
|
||||
|
||||
|
||||
class MembershipRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def get(self, org_id: str, user_id: str) -> Membership | None:
|
||||
stmt = select(MembershipRow).where(
|
||||
MembershipRow.organization_id == org_id, MembershipRow.user_id == user_id
|
||||
)
|
||||
row = (await self.session.execute(stmt)).scalar_one_or_none()
|
||||
return _membership_to_domain(row) if row else None
|
||||
|
||||
async def get_by_id(self, membership_id: str) -> Membership | None:
|
||||
row = await self.session.get(MembershipRow, membership_id)
|
||||
return _membership_to_domain(row) if row else None
|
||||
|
||||
async def list_for_org(self, org_id: str) -> list[Membership]:
|
||||
stmt = (
|
||||
select(MembershipRow)
|
||||
.where(MembershipRow.organization_id == org_id)
|
||||
.order_by(MembershipRow.created_at)
|
||||
)
|
||||
return [_membership_to_domain(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||
|
||||
async def add(self, membership: Membership) -> Membership:
|
||||
row = MembershipRow(
|
||||
id=membership.id,
|
||||
organization_id=membership.organization_id,
|
||||
user_id=membership.user_id,
|
||||
role=membership.role.value,
|
||||
status=membership.status.value,
|
||||
created_at=membership.created_at,
|
||||
)
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
return _membership_to_domain(row)
|
||||
|
||||
async def set_role(self, membership_id: str, role: Role) -> None:
|
||||
row = await self.session.get(MembershipRow, membership_id)
|
||||
if row:
|
||||
row.role = role.value
|
||||
await self.session.flush()
|
||||
|
||||
async def set_status(self, membership_id: str, status: MembershipStatus) -> None:
|
||||
row = await self.session.get(MembershipRow, membership_id)
|
||||
if row:
|
||||
row.status = status.value
|
||||
await self.session.flush()
|
||||
|
||||
async def remove(self, membership_id: str) -> None:
|
||||
row = await self.session.get(MembershipRow, membership_id)
|
||||
if row:
|
||||
await self.session.delete(row)
|
||||
await self.session.flush()
|
||||
|
||||
async def count_owners(self, org_id: str) -> int:
|
||||
stmt = select(func.count(MembershipRow.id)).where(
|
||||
MembershipRow.organization_id == org_id,
|
||||
MembershipRow.role == Role.OWNER.value,
|
||||
MembershipRow.status == MembershipStatus.ACTIVE.value,
|
||||
)
|
||||
return int((await self.session.execute(stmt)).scalar_one())
|
||||
|
||||
|
||||
class InviteRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def get_by_token_hash(self, token_hash: str) -> Invite | None:
|
||||
stmt = select(InviteRow).where(InviteRow.token_hash == token_hash)
|
||||
row = (await self.session.execute(stmt)).scalar_one_or_none()
|
||||
return _invite_to_domain(row) if row else None
|
||||
|
||||
async def list_pending(self, org_id: str) -> list[Invite]:
|
||||
stmt = (
|
||||
select(InviteRow)
|
||||
.where(InviteRow.organization_id == org_id, InviteRow.accepted_at.is_(None))
|
||||
.order_by(InviteRow.created_at.desc())
|
||||
)
|
||||
return [_invite_to_domain(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||
|
||||
async def create(self, invite: Invite) -> Invite:
|
||||
row = InviteRow(
|
||||
id=invite.id,
|
||||
organization_id=invite.organization_id,
|
||||
email=invite.email.strip().lower(),
|
||||
role=invite.role.value,
|
||||
token_hash=invite.token_hash,
|
||||
invited_by_user_id=invite.invited_by_user_id,
|
||||
expires_at=invite.expires_at,
|
||||
accepted_at=invite.accepted_at,
|
||||
created_at=invite.created_at,
|
||||
)
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
return _invite_to_domain(row)
|
||||
|
||||
async def mark_accepted(self, invite_id: str, when: datetime) -> None:
|
||||
row = await self.session.get(InviteRow, invite_id)
|
||||
if row:
|
||||
row.accepted_at = when
|
||||
await self.session.flush()
|
||||
@@ -0,0 +1,285 @@
|
||||
"""Repositories for the Knowledge Base.
|
||||
|
||||
Retrieval is tenant-scoped by construction: every search joins
|
||||
``document_chunks -> documents -> knowledge_sources`` and filters on
|
||||
``knowledge_sources.organization_id``. There is no search entry point that takes
|
||||
a chunk id directly, so a caller cannot reach across tenants.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.domain.knowledge.models import (
|
||||
Document,
|
||||
DocumentChunk,
|
||||
EmbeddingJob,
|
||||
EmbeddingJobStatus,
|
||||
KnowledgeSource,
|
||||
RetrievalPolicy,
|
||||
SourceKind,
|
||||
)
|
||||
from app.infrastructure.db.models import (
|
||||
DocumentChunkRow,
|
||||
DocumentRow,
|
||||
EmbeddingJobRow,
|
||||
KnowledgeSourceRow,
|
||||
RetrievalPolicyRow,
|
||||
)
|
||||
|
||||
|
||||
def _source(row: KnowledgeSourceRow) -> KnowledgeSource:
|
||||
return KnowledgeSource(
|
||||
id=row.id, organization_id=row.organization_id, project_id=row.project_id,
|
||||
name=row.name, kind=SourceKind(row.kind), uri=row.uri, created_at=row.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _document(row: DocumentRow) -> Document:
|
||||
return Document(
|
||||
id=row.id, source_id=row.source_id, title=row.title, content=row.content,
|
||||
created_at=row.created_at,
|
||||
)
|
||||
|
||||
|
||||
class KnowledgeSourceRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def get(self, source_id: str) -> KnowledgeSource | None:
|
||||
row = await self.session.get(KnowledgeSourceRow, source_id)
|
||||
return _source(row) if row else None
|
||||
|
||||
async def get_for_org(self, organization_id: str, source_id: str) -> KnowledgeSource | None:
|
||||
stmt = select(KnowledgeSourceRow).where(
|
||||
KnowledgeSourceRow.id == source_id,
|
||||
KnowledgeSourceRow.organization_id == organization_id,
|
||||
)
|
||||
row = (await self.session.execute(stmt)).scalar_one_or_none()
|
||||
return _source(row) if row else None
|
||||
|
||||
async def list_for_org(
|
||||
self, organization_id: str, *, limit: int = 100, offset: int = 0
|
||||
) -> list[KnowledgeSource]:
|
||||
stmt = (
|
||||
select(KnowledgeSourceRow)
|
||||
.where(KnowledgeSourceRow.organization_id == organization_id)
|
||||
.order_by(KnowledgeSourceRow.created_at.desc())
|
||||
.limit(min(limit, 500))
|
||||
.offset(offset)
|
||||
)
|
||||
return [_source(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||
|
||||
async def create(self, source: KnowledgeSource) -> KnowledgeSource:
|
||||
row = KnowledgeSourceRow(
|
||||
id=source.id, organization_id=source.organization_id,
|
||||
project_id=source.project_id, name=source.name, kind=str(source.kind),
|
||||
uri=source.uri, created_at=source.created_at,
|
||||
)
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
return _source(row)
|
||||
|
||||
async def delete(self, source_id: str) -> None:
|
||||
row = await self.session.get(KnowledgeSourceRow, source_id)
|
||||
if row:
|
||||
await self.session.delete(row)
|
||||
await self.session.flush()
|
||||
|
||||
|
||||
class DocumentRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def list_for_source(self, source_id: str) -> list[Document]:
|
||||
stmt = (
|
||||
select(DocumentRow)
|
||||
.where(DocumentRow.source_id == source_id)
|
||||
.order_by(DocumentRow.created_at)
|
||||
)
|
||||
return [_document(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||
|
||||
async def create(self, document: Document) -> Document:
|
||||
row = DocumentRow(
|
||||
id=document.id, source_id=document.source_id, title=document.title,
|
||||
content=document.content, created_at=document.created_at,
|
||||
)
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
return _document(row)
|
||||
|
||||
|
||||
class DocumentChunkRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def create_many(self, chunks: list[DocumentChunk]) -> int:
|
||||
for chunk in chunks:
|
||||
self.session.add(
|
||||
DocumentChunkRow(
|
||||
id=chunk.id, document_id=chunk.document_id, index=chunk.index,
|
||||
content=chunk.content, embedding_json=list(chunk.embedding_json),
|
||||
)
|
||||
)
|
||||
await self.session.flush()
|
||||
return len(chunks)
|
||||
|
||||
async def count_for_source(self, source_id: str) -> int:
|
||||
stmt = (
|
||||
select(func.count())
|
||||
.select_from(DocumentChunkRow)
|
||||
.join(DocumentRow, DocumentRow.id == DocumentChunkRow.document_id)
|
||||
.where(DocumentRow.source_id == source_id)
|
||||
)
|
||||
return int((await self.session.execute(stmt)).scalar_one())
|
||||
|
||||
async def count_for_sources(self, source_ids: list[str]) -> dict[str, int]:
|
||||
"""Chunk counts for many sources in one grouped query.
|
||||
|
||||
Listing sources needs a count per row; doing that one source at a time
|
||||
is an N+1. ``GROUP BY`` collapses it into a single round trip, and
|
||||
sources with no chunks are simply absent from the result.
|
||||
"""
|
||||
if not source_ids:
|
||||
return {}
|
||||
stmt = (
|
||||
select(DocumentRow.source_id, func.count(DocumentChunkRow.id))
|
||||
.select_from(DocumentChunkRow)
|
||||
.join(DocumentRow, DocumentRow.id == DocumentChunkRow.document_id)
|
||||
.where(DocumentRow.source_id.in_(source_ids))
|
||||
.group_by(DocumentRow.source_id)
|
||||
)
|
||||
rows = (await self.session.execute(stmt)).all()
|
||||
return {str(source_id): int(count) for source_id, count in rows}
|
||||
|
||||
async def keyword_search(
|
||||
self,
|
||||
*,
|
||||
organization_id: str,
|
||||
query: str,
|
||||
project_id: str | None = None,
|
||||
limit: int = 5,
|
||||
) -> list[dict]:
|
||||
"""MVP retrieval: case-insensitive substring match over chunk text.
|
||||
|
||||
A real deployment swaps this for a vector search through
|
||||
``VectorStorePort``; the tenant scoping stays identical.
|
||||
"""
|
||||
needle = f"%{query.strip().lower()}%"
|
||||
if not query.strip():
|
||||
return []
|
||||
|
||||
stmt = (
|
||||
select(
|
||||
DocumentChunkRow.id,
|
||||
DocumentChunkRow.content,
|
||||
DocumentChunkRow.index,
|
||||
DocumentRow.title,
|
||||
KnowledgeSourceRow.id,
|
||||
KnowledgeSourceRow.name,
|
||||
KnowledgeSourceRow.project_id,
|
||||
)
|
||||
.join(DocumentRow, DocumentRow.id == DocumentChunkRow.document_id)
|
||||
.join(KnowledgeSourceRow, KnowledgeSourceRow.id == DocumentRow.source_id)
|
||||
.where(
|
||||
KnowledgeSourceRow.organization_id == organization_id,
|
||||
func.lower(DocumentChunkRow.content).like(needle),
|
||||
)
|
||||
.order_by(DocumentChunkRow.document_id, DocumentChunkRow.index)
|
||||
.limit(min(limit, 50))
|
||||
)
|
||||
if project_id is not None:
|
||||
# An org-wide source (project_id is NULL) is visible to every project.
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
KnowledgeSourceRow.project_id.is_(None),
|
||||
KnowledgeSourceRow.project_id == project_id,
|
||||
)
|
||||
)
|
||||
|
||||
rows = (await self.session.execute(stmt)).all()
|
||||
return [
|
||||
{
|
||||
"chunk_id": chunk_id,
|
||||
"content": content,
|
||||
"index": index,
|
||||
"document_title": title,
|
||||
"source_id": source_id,
|
||||
"source_name": source_name,
|
||||
"project_id": source_project_id,
|
||||
}
|
||||
for chunk_id, content, index, title, source_id, source_name, source_project_id in rows
|
||||
]
|
||||
|
||||
|
||||
class EmbeddingJobRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def create(self, job: EmbeddingJob) -> EmbeddingJob:
|
||||
row = EmbeddingJobRow(
|
||||
id=job.id, source_id=job.source_id, status=str(job.status),
|
||||
error=job.error, created_at=job.created_at,
|
||||
)
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
return EmbeddingJob(
|
||||
id=row.id, source_id=row.source_id,
|
||||
status=EmbeddingJobStatus(row.status), error=row.error,
|
||||
created_at=row.created_at,
|
||||
)
|
||||
|
||||
async def latest_for_source(self, source_id: str) -> EmbeddingJob | None:
|
||||
stmt = (
|
||||
select(EmbeddingJobRow)
|
||||
.where(EmbeddingJobRow.source_id == source_id)
|
||||
.order_by(EmbeddingJobRow.created_at.desc())
|
||||
)
|
||||
row = (await self.session.execute(stmt)).scalars().first()
|
||||
if row is None:
|
||||
return None
|
||||
return EmbeddingJob(
|
||||
id=row.id, source_id=row.source_id,
|
||||
status=EmbeddingJobStatus(row.status), error=row.error,
|
||||
created_at=row.created_at,
|
||||
)
|
||||
|
||||
async def set_status(
|
||||
self, job_id: str, status: EmbeddingJobStatus, *, error: str | None = None
|
||||
) -> None:
|
||||
row = await self.session.get(EmbeddingJobRow, job_id)
|
||||
if row:
|
||||
row.status = str(status)
|
||||
if error is not None:
|
||||
row.error = error
|
||||
await self.session.flush()
|
||||
|
||||
|
||||
class RetrievalPolicyRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def list_for_org(self, organization_id: str) -> list[RetrievalPolicy]:
|
||||
stmt = select(RetrievalPolicyRow).where(
|
||||
RetrievalPolicyRow.organization_id == organization_id
|
||||
)
|
||||
return [
|
||||
RetrievalPolicy(
|
||||
id=r.id, organization_id=r.organization_id, name=r.name,
|
||||
scope_json=dict(r.scope_json or {}), created_at=r.created_at,
|
||||
)
|
||||
for r in (await self.session.execute(stmt)).scalars()
|
||||
]
|
||||
|
||||
async def create(self, policy: RetrievalPolicy) -> RetrievalPolicy:
|
||||
row = RetrievalPolicyRow(
|
||||
id=policy.id, organization_id=policy.organization_id, name=policy.name,
|
||||
scope_json=dict(policy.scope_json), created_at=policy.created_at,
|
||||
)
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
return RetrievalPolicy(
|
||||
id=row.id, organization_id=row.organization_id, name=row.name,
|
||||
scope_json=dict(row.scope_json or {}), created_at=row.created_at,
|
||||
)
|
||||
@@ -0,0 +1,253 @@
|
||||
"""Repositories for Projects, their Git repositories, credentials and preview
|
||||
domains."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.domain.projects.models import (
|
||||
Project,
|
||||
ProjectGitCredential,
|
||||
ProjectGitRepo,
|
||||
)
|
||||
from app.infrastructure.db.models import (
|
||||
PreviewDomainRow,
|
||||
PreviewPortRow,
|
||||
ProjectGitCredentialRow,
|
||||
ProjectRepositoryRow,
|
||||
ProjectRow,
|
||||
)
|
||||
|
||||
|
||||
def _project_to_domain(row: ProjectRow) -> Project:
|
||||
return Project(
|
||||
id=row.id, organization_id=row.organization_id, name=row.name, slug=row.slug,
|
||||
description=row.description, created_by_user_id=row.created_by_user_id,
|
||||
default_branch=row.default_branch, created_at=row.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _repo_to_domain(row: ProjectRepositoryRow) -> ProjectGitRepo:
|
||||
return ProjectGitRepo(
|
||||
id=row.id, project_id=row.project_id, provider=row.provider, owner=row.owner,
|
||||
repo_name=row.repo_name, clone_url=row.clone_url, web_url=row.web_url,
|
||||
default_branch=row.default_branch, created_at=row.created_at,
|
||||
)
|
||||
|
||||
|
||||
class ProjectGitRepoRepository:
|
||||
"""Persistence for the Git repository attached to a project."""
|
||||
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def get_for_project(self, project_id: str) -> ProjectGitRepo | None:
|
||||
stmt = select(ProjectRepositoryRow).where(
|
||||
ProjectRepositoryRow.project_id == project_id
|
||||
)
|
||||
row = (await self.session.execute(stmt)).scalar_one_or_none()
|
||||
return _repo_to_domain(row) if row else None
|
||||
|
||||
async def upsert(self, repo: ProjectGitRepo) -> ProjectGitRepo:
|
||||
existing = await self.get_for_project(repo.project_id)
|
||||
if existing:
|
||||
row = await self.session.get(ProjectRepositoryRow, existing.id)
|
||||
assert row is not None
|
||||
row.provider = repo.provider
|
||||
row.owner = repo.owner
|
||||
row.repo_name = repo.repo_name
|
||||
row.clone_url = repo.clone_url
|
||||
row.web_url = repo.web_url
|
||||
row.default_branch = repo.default_branch
|
||||
await self.session.flush()
|
||||
return _repo_to_domain(row)
|
||||
|
||||
row = ProjectRepositoryRow(
|
||||
id=repo.id, project_id=repo.project_id, provider=repo.provider,
|
||||
owner=repo.owner, repo_name=repo.repo_name, clone_url=repo.clone_url,
|
||||
web_url=repo.web_url, default_branch=repo.default_branch,
|
||||
created_at=repo.created_at,
|
||||
)
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
return _repo_to_domain(row)
|
||||
|
||||
|
||||
class ProjectGitCredentialRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def get_for_project(self, project_id: str) -> ProjectGitCredential | None:
|
||||
stmt = select(ProjectGitCredentialRow).where(
|
||||
ProjectGitCredentialRow.project_id == project_id
|
||||
)
|
||||
row = (await self.session.execute(stmt)).scalar_one_or_none()
|
||||
if row is None:
|
||||
return None
|
||||
return ProjectGitCredential(
|
||||
id=row.id, project_id=row.project_id, username=row.username,
|
||||
encrypted_password=row.encrypted_password, created_at=row.created_at,
|
||||
)
|
||||
|
||||
async def upsert(self, credential: ProjectGitCredential) -> ProjectGitCredential:
|
||||
existing = await self.get_for_project(credential.project_id)
|
||||
if existing:
|
||||
row = await self.session.get(ProjectGitCredentialRow, existing.id)
|
||||
assert row is not None
|
||||
row.username = credential.username
|
||||
row.encrypted_password = credential.encrypted_password
|
||||
await self.session.flush()
|
||||
return ProjectGitCredential(
|
||||
id=row.id, project_id=row.project_id, username=row.username,
|
||||
encrypted_password=row.encrypted_password, created_at=row.created_at,
|
||||
)
|
||||
|
||||
row = ProjectGitCredentialRow(
|
||||
id=credential.id, project_id=credential.project_id,
|
||||
username=credential.username,
|
||||
encrypted_password=credential.encrypted_password,
|
||||
created_at=credential.created_at,
|
||||
)
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
return credential
|
||||
|
||||
|
||||
class ProjectRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def get(self, project_id: str) -> Project | None:
|
||||
row = await self.session.get(ProjectRow, project_id)
|
||||
return _project_to_domain(row) if row else None
|
||||
|
||||
async def list_for_org(
|
||||
self, organization_id: str, *, limit: int = 100, offset: int = 0
|
||||
) -> list[Project]:
|
||||
stmt = (
|
||||
select(ProjectRow)
|
||||
.where(ProjectRow.organization_id == organization_id)
|
||||
.order_by(ProjectRow.created_at.desc())
|
||||
.limit(min(limit, 500))
|
||||
.offset(offset)
|
||||
)
|
||||
return [_project_to_domain(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||
|
||||
async def list_slugs(self, organization_id: str) -> set[str]:
|
||||
stmt = select(ProjectRow.slug).where(ProjectRow.organization_id == organization_id)
|
||||
return set((await self.session.execute(stmt)).scalars().all())
|
||||
|
||||
async def create(self, project: Project) -> Project:
|
||||
row = ProjectRow(
|
||||
id=project.id, organization_id=project.organization_id, name=project.name,
|
||||
slug=project.slug, description=project.description,
|
||||
created_by_user_id=project.created_by_user_id,
|
||||
default_branch=project.default_branch, created_at=project.created_at,
|
||||
)
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
return _project_to_domain(row)
|
||||
|
||||
async def delete(self, project_id: str) -> None:
|
||||
row = await self.session.get(ProjectRow, project_id)
|
||||
if row:
|
||||
await self.session.delete(row)
|
||||
await self.session.flush()
|
||||
|
||||
|
||||
class PreviewDomainRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
# -- ports ---------------------------------------------------------------
|
||||
|
||||
async def get_port(self, project_id: str, service_name: str) -> int | None:
|
||||
stmt = select(PreviewPortRow.port).where(
|
||||
PreviewPortRow.project_id == project_id,
|
||||
PreviewPortRow.service_name == service_name,
|
||||
)
|
||||
return (await self.session.execute(stmt)).scalar_one_or_none()
|
||||
|
||||
async def list_used_ports(self, *, start: int, end: int) -> set[int]:
|
||||
stmt = select(PreviewPortRow.port).where(
|
||||
PreviewPortRow.port.between(start, end)
|
||||
)
|
||||
return set((await self.session.execute(stmt)).scalars().all())
|
||||
|
||||
async def add_port(
|
||||
self, *, organization_id: str, project_id: str, service_name: str, port: int
|
||||
) -> None:
|
||||
self.session.add(
|
||||
PreviewPortRow(
|
||||
organization_id=organization_id, project_id=project_id,
|
||||
service_name=service_name, port=port, status="active",
|
||||
)
|
||||
)
|
||||
await self.session.flush()
|
||||
|
||||
# -- domains -------------------------------------------------------------
|
||||
|
||||
async def get_by_host(self, host: str) -> dict | None:
|
||||
stmt = select(PreviewDomainRow).where(PreviewDomainRow.host == host)
|
||||
row = (await self.session.execute(stmt)).scalar_one_or_none()
|
||||
return self._row_to_dict(row) if row else None
|
||||
|
||||
async def get(self, domain_id: str) -> dict | None:
|
||||
row = await self.session.get(PreviewDomainRow, domain_id)
|
||||
return self._row_to_dict(row) if row else None
|
||||
|
||||
async def list_for_project(self, project_id: str) -> list[dict]:
|
||||
stmt = (
|
||||
select(PreviewDomainRow)
|
||||
.where(PreviewDomainRow.project_id == project_id)
|
||||
.order_by(PreviewDomainRow.created_at)
|
||||
)
|
||||
return [self._row_to_dict(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||
|
||||
async def count_for_project(self, project_id: str) -> int:
|
||||
return len(await self.list_for_project(project_id))
|
||||
|
||||
async def create(
|
||||
self,
|
||||
*,
|
||||
organization_id: str,
|
||||
project_id: str,
|
||||
host: str,
|
||||
port: int,
|
||||
created_by_user_id: str | None,
|
||||
) -> dict:
|
||||
row = PreviewDomainRow(
|
||||
organization_id=organization_id, project_id=project_id, host=host,
|
||||
port=port, status="active", protected=False,
|
||||
created_by_user_id=created_by_user_id,
|
||||
)
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
return self._row_to_dict(row)
|
||||
|
||||
async def set_status(self, domain_id: str, status: str) -> None:
|
||||
row = await self.session.get(PreviewDomainRow, domain_id)
|
||||
if row:
|
||||
row.status = status
|
||||
await self.session.flush()
|
||||
|
||||
async def delete(self, domain_id: str) -> None:
|
||||
row = await self.session.get(PreviewDomainRow, domain_id)
|
||||
if row:
|
||||
await self.session.delete(row)
|
||||
await self.session.flush()
|
||||
|
||||
@staticmethod
|
||||
def _row_to_dict(row: PreviewDomainRow) -> dict:
|
||||
return {
|
||||
"id": row.id,
|
||||
"organization_id": row.organization_id,
|
||||
"project_id": row.project_id,
|
||||
"host": row.host,
|
||||
"port": row.port,
|
||||
"status": row.status,
|
||||
"protected": row.protected,
|
||||
"created_by_user_id": row.created_by_user_id,
|
||||
"created_at": row.created_at,
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
"""Repositories for the Sandbox Gateway.
|
||||
|
||||
Every tenant-scoped read joins back to ``workspaces.organization_id`` so a
|
||||
workspace or session id from another tenant resolves to ``None``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.domain.base import utcnow
|
||||
from app.domain.sandbox.models import (
|
||||
CommandRun,
|
||||
NetworkPolicy,
|
||||
PreviewUrl,
|
||||
SandboxProviderKind,
|
||||
SandboxResourceLimit,
|
||||
SandboxSession,
|
||||
SandboxTemplate,
|
||||
SessionStatus,
|
||||
Snapshot,
|
||||
Workspace,
|
||||
WorkspaceStatus,
|
||||
)
|
||||
from app.infrastructure.db.models import (
|
||||
CommandRunRow,
|
||||
PreviewUrlRow,
|
||||
SandboxResourceLimitRow,
|
||||
SandboxSessionRow,
|
||||
SandboxTemplateRow,
|
||||
SnapshotRow,
|
||||
WorkspaceRow,
|
||||
)
|
||||
|
||||
|
||||
def _template(row: SandboxTemplateRow) -> SandboxTemplate:
|
||||
return SandboxTemplate(
|
||||
id=row.id, name=row.name, provider=row.provider, image=row.image,
|
||||
description=row.description, default_cpu=row.default_cpu,
|
||||
default_memory_mb=row.default_memory_mb, default_disk_mb=row.default_disk_mb,
|
||||
is_system=row.is_system, created_at=row.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _workspace(row: WorkspaceRow) -> Workspace:
|
||||
return Workspace(
|
||||
id=row.id, organization_id=row.organization_id, project_id=row.project_id,
|
||||
template_id=row.template_id, provider=row.provider,
|
||||
external_workspace_id=row.external_workspace_id,
|
||||
status=WorkspaceStatus(row.status), created_at=row.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _session(row: SandboxSessionRow) -> SandboxSession:
|
||||
return SandboxSession(
|
||||
id=row.id, workspace_id=row.workspace_id,
|
||||
external_session_id=row.external_session_id,
|
||||
status=SessionStatus(row.status), started_at=row.started_at,
|
||||
stopped_at=row.stopped_at, last_active_at=row.last_active_at,
|
||||
)
|
||||
|
||||
|
||||
class SandboxTemplateRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def list(self) -> list[SandboxTemplate]:
|
||||
stmt = select(SandboxTemplateRow).order_by(SandboxTemplateRow.name)
|
||||
return [_template(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||
|
||||
async def get(self, template_id: str) -> SandboxTemplate | None:
|
||||
row = await self.session.get(SandboxTemplateRow, template_id)
|
||||
return _template(row) if row else None
|
||||
|
||||
async def get_by_name(self, name: str) -> SandboxTemplate | None:
|
||||
stmt = select(SandboxTemplateRow).where(SandboxTemplateRow.name == name)
|
||||
row = (await self.session.execute(stmt)).scalar_one_or_none()
|
||||
return _template(row) if row else None
|
||||
|
||||
async def create(self, template: SandboxTemplate) -> SandboxTemplate:
|
||||
row = SandboxTemplateRow(
|
||||
id=template.id, name=template.name, provider=str(template.provider),
|
||||
image=template.image, description=template.description,
|
||||
default_cpu=template.default_cpu,
|
||||
default_memory_mb=template.default_memory_mb,
|
||||
default_disk_mb=template.default_disk_mb, is_system=template.is_system,
|
||||
created_at=template.created_at,
|
||||
)
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
return _template(row)
|
||||
|
||||
|
||||
class WorkspaceRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def get(self, workspace_id: str) -> Workspace | None:
|
||||
"""Unscoped lookup. Platform components only — tenant paths use
|
||||
:meth:`get_for_org` so a foreign id resolves to ``None``."""
|
||||
row = await self.session.get(WorkspaceRow, workspace_id)
|
||||
return _workspace(row) if row else None
|
||||
|
||||
async def get_for_org(self, organization_id: str, workspace_id: str) -> Workspace | None:
|
||||
stmt = select(WorkspaceRow).where(
|
||||
WorkspaceRow.id == workspace_id,
|
||||
WorkspaceRow.organization_id == organization_id,
|
||||
)
|
||||
row = (await self.session.execute(stmt)).scalar_one_or_none()
|
||||
return _workspace(row) if row else None
|
||||
|
||||
async def list_for_project(
|
||||
self, project_id: str, *, limit: int = 100, offset: int = 0
|
||||
) -> list[Workspace]:
|
||||
stmt = (
|
||||
select(WorkspaceRow)
|
||||
.where(WorkspaceRow.project_id == project_id)
|
||||
.order_by(WorkspaceRow.created_at.desc())
|
||||
.limit(min(limit, 500))
|
||||
.offset(offset)
|
||||
)
|
||||
return [_workspace(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||
|
||||
async def create(self, workspace: Workspace) -> Workspace:
|
||||
row = WorkspaceRow(
|
||||
id=workspace.id, organization_id=workspace.organization_id,
|
||||
project_id=workspace.project_id, template_id=workspace.template_id,
|
||||
provider=str(workspace.provider),
|
||||
external_workspace_id=workspace.external_workspace_id,
|
||||
status=str(workspace.status), created_at=workspace.created_at,
|
||||
)
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
return _workspace(row)
|
||||
|
||||
async def set_status(self, workspace_id: str, status: WorkspaceStatus) -> None:
|
||||
row = await self.session.get(WorkspaceRow, workspace_id)
|
||||
if row:
|
||||
row.status = str(status)
|
||||
await self.session.flush()
|
||||
|
||||
|
||||
class SandboxSessionRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def get(self, session_id: str) -> SandboxSession | None:
|
||||
row = await self.session.get(SandboxSessionRow, session_id)
|
||||
return _session(row) if row else None
|
||||
|
||||
async def get_for_org(self, organization_id: str, session_id: str) -> SandboxSession | None:
|
||||
"""Resolve a session through its workspace, which carries the tenant."""
|
||||
stmt = (
|
||||
select(SandboxSessionRow)
|
||||
.join(WorkspaceRow, WorkspaceRow.id == SandboxSessionRow.workspace_id)
|
||||
.where(
|
||||
SandboxSessionRow.id == session_id,
|
||||
WorkspaceRow.organization_id == organization_id,
|
||||
)
|
||||
)
|
||||
row = (await self.session.execute(stmt)).scalar_one_or_none()
|
||||
return _session(row) if row else None
|
||||
|
||||
async def list_for_workspace(self, workspace_id: str) -> list[SandboxSession]:
|
||||
stmt = (
|
||||
select(SandboxSessionRow)
|
||||
.where(SandboxSessionRow.workspace_id == workspace_id)
|
||||
.order_by(SandboxSessionRow.started_at.desc())
|
||||
)
|
||||
return [_session(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||
|
||||
async def list_stale_active(
|
||||
self, *, idle_before: datetime, limit: int = 200
|
||||
) -> list[SandboxSession]:
|
||||
"""Active sessions untouched since ``idle_before``.
|
||||
|
||||
The gateway reaper's query. Only ``starting``/``running``/``paused``
|
||||
count as active — a stopped session is already gone and must not be
|
||||
stopped twice.
|
||||
"""
|
||||
stmt = (
|
||||
select(SandboxSessionRow)
|
||||
.where(
|
||||
SandboxSessionRow.status.in_(
|
||||
[
|
||||
str(SessionStatus.STARTING),
|
||||
str(SessionStatus.RUNNING),
|
||||
str(SessionStatus.PAUSED),
|
||||
]
|
||||
),
|
||||
SandboxSessionRow.last_active_at < idle_before,
|
||||
)
|
||||
.order_by(SandboxSessionRow.last_active_at.asc())
|
||||
.limit(min(limit, 500))
|
||||
)
|
||||
return [_session(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||
|
||||
async def create(self, sandbox_session: SandboxSession) -> SandboxSession:
|
||||
row = SandboxSessionRow(
|
||||
id=sandbox_session.id, workspace_id=sandbox_session.workspace_id,
|
||||
external_session_id=sandbox_session.external_session_id,
|
||||
status=str(sandbox_session.status), started_at=sandbox_session.started_at,
|
||||
stopped_at=sandbox_session.stopped_at,
|
||||
last_active_at=sandbox_session.last_active_at,
|
||||
)
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
return _session(row)
|
||||
|
||||
async def set_status(
|
||||
self, session_id: str, status: SessionStatus, *, stopped: bool = False
|
||||
) -> None:
|
||||
row = await self.session.get(SandboxSessionRow, session_id)
|
||||
if row:
|
||||
row.status = str(status)
|
||||
row.last_active_at = utcnow()
|
||||
if stopped:
|
||||
row.stopped_at = utcnow()
|
||||
await self.session.flush()
|
||||
|
||||
|
||||
class CommandRunRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def list_for_session(self, session_id: str) -> list[CommandRun]:
|
||||
stmt = (
|
||||
select(CommandRunRow)
|
||||
.where(CommandRunRow.session_id == session_id)
|
||||
.order_by(CommandRunRow.created_at)
|
||||
)
|
||||
return [
|
||||
CommandRun(
|
||||
id=r.id, session_id=r.session_id, command=r.command,
|
||||
exit_code=r.exit_code, stdout_ref=r.stdout_ref, stderr_ref=r.stderr_ref,
|
||||
duration_ms=r.duration_ms, truncated=r.truncated, created_at=r.created_at,
|
||||
)
|
||||
for r in (await self.session.execute(stmt)).scalars()
|
||||
]
|
||||
|
||||
async def create(self, run: CommandRun) -> CommandRun:
|
||||
row = CommandRunRow(
|
||||
id=run.id, session_id=run.session_id, command=run.command,
|
||||
exit_code=run.exit_code, stdout_ref=run.stdout_ref, stderr_ref=run.stderr_ref,
|
||||
duration_ms=run.duration_ms, truncated=run.truncated, created_at=run.created_at,
|
||||
)
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
return run
|
||||
|
||||
|
||||
class SnapshotRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def list_for_workspace(self, workspace_id: str) -> list[Snapshot]:
|
||||
stmt = (
|
||||
select(SnapshotRow)
|
||||
.where(SnapshotRow.workspace_id == workspace_id)
|
||||
.order_by(SnapshotRow.created_at.desc())
|
||||
)
|
||||
return [
|
||||
Snapshot(
|
||||
id=r.id, workspace_id=r.workspace_id, name=r.name,
|
||||
external_snapshot_id=r.external_snapshot_id, created_at=r.created_at,
|
||||
)
|
||||
for r in (await self.session.execute(stmt)).scalars()
|
||||
]
|
||||
|
||||
async def create(self, snapshot: Snapshot) -> Snapshot:
|
||||
row = SnapshotRow(
|
||||
id=snapshot.id, workspace_id=snapshot.workspace_id, name=snapshot.name,
|
||||
external_snapshot_id=snapshot.external_snapshot_id,
|
||||
created_at=snapshot.created_at,
|
||||
)
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
return snapshot
|
||||
|
||||
|
||||
class PreviewUrlRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def list_for_workspace(self, workspace_id: str) -> list[PreviewUrl]:
|
||||
stmt = select(PreviewUrlRow).where(PreviewUrlRow.workspace_id == workspace_id)
|
||||
return [
|
||||
PreviewUrl(
|
||||
id=r.id, workspace_id=r.workspace_id, port=r.port, url=r.url,
|
||||
created_at=r.created_at,
|
||||
)
|
||||
for r in (await self.session.execute(stmt)).scalars()
|
||||
]
|
||||
|
||||
async def create(self, preview: PreviewUrl) -> PreviewUrl:
|
||||
row = PreviewUrlRow(
|
||||
id=preview.id, workspace_id=preview.workspace_id, port=preview.port,
|
||||
url=preview.url, created_at=preview.created_at,
|
||||
)
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
return preview
|
||||
|
||||
|
||||
class ResourceLimitRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def get_for_scope(self, scope: str, scope_id: str | None) -> SandboxResourceLimit | None:
|
||||
stmt = select(SandboxResourceLimitRow).where(
|
||||
SandboxResourceLimitRow.scope == scope,
|
||||
SandboxResourceLimitRow.scope_id == scope_id,
|
||||
)
|
||||
row = (await self.session.execute(stmt)).scalar_one_or_none()
|
||||
if row is None:
|
||||
return None
|
||||
return SandboxResourceLimit(
|
||||
id=row.id, scope=row.scope, scope_id=row.scope_id, cpu=row.cpu,
|
||||
memory_mb=row.memory_mb, disk_mb=row.disk_mb,
|
||||
timeout_seconds=row.timeout_seconds, max_output_bytes=row.max_output_bytes,
|
||||
network_policy=NetworkPolicy(row.network_policy),
|
||||
)
|
||||
|
||||
async def upsert(self, limit: SandboxResourceLimit) -> SandboxResourceLimit:
|
||||
stmt = select(SandboxResourceLimitRow).where(
|
||||
SandboxResourceLimitRow.scope == limit.scope,
|
||||
SandboxResourceLimitRow.scope_id == limit.scope_id,
|
||||
)
|
||||
row = (await self.session.execute(stmt)).scalar_one_or_none()
|
||||
if row is None:
|
||||
row = SandboxResourceLimitRow(
|
||||
id=limit.id, scope=limit.scope, scope_id=limit.scope_id, cpu=limit.cpu,
|
||||
memory_mb=limit.memory_mb, disk_mb=limit.disk_mb,
|
||||
timeout_seconds=limit.timeout_seconds,
|
||||
max_output_bytes=limit.max_output_bytes,
|
||||
network_policy=str(limit.network_policy),
|
||||
)
|
||||
self.session.add(row)
|
||||
else:
|
||||
row.cpu = limit.cpu
|
||||
row.memory_mb = limit.memory_mb
|
||||
row.disk_mb = limit.disk_mb
|
||||
row.timeout_seconds = limit.timeout_seconds
|
||||
row.max_output_bytes = limit.max_output_bytes
|
||||
row.network_policy = str(limit.network_policy)
|
||||
await self.session.flush()
|
||||
return limit
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CommandRunRepository",
|
||||
"PreviewUrlRepository",
|
||||
"ResourceLimitRepository",
|
||||
"SandboxProviderKind",
|
||||
"SandboxSessionRepository",
|
||||
"SandboxTemplateRepository",
|
||||
"SnapshotRepository",
|
||||
"WorkspaceRepository",
|
||||
]
|
||||
@@ -0,0 +1,684 @@
|
||||
"""Repositories for the AI Workforce bounded context.
|
||||
|
||||
Covers agent templates, employees, prompt versions, tool permissions, and the
|
||||
whole Manager-Chat chain: threads -> messages -> tasks -> runs -> steps ->
|
||||
tool calls -> artifacts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.domain.workforce.models import (
|
||||
AgentTemplate,
|
||||
Artifact,
|
||||
Employee,
|
||||
EmployeeStatus,
|
||||
EmployeeToolPermission,
|
||||
Message,
|
||||
MessageRole,
|
||||
PromptVersion,
|
||||
RoleKey,
|
||||
Run,
|
||||
RunStatus,
|
||||
RunStep,
|
||||
Task,
|
||||
TaskStatus,
|
||||
Thread,
|
||||
ToolCall,
|
||||
ToolCallStatus,
|
||||
)
|
||||
from app.infrastructure.db.models import (
|
||||
AgentTemplateRow,
|
||||
ArtifactRow,
|
||||
EmployeeRow,
|
||||
EmployeeToolPermissionRow,
|
||||
MessageRow,
|
||||
PromptVersionRow,
|
||||
RunRow,
|
||||
RunStepRow,
|
||||
TaskRow,
|
||||
ThreadRow,
|
||||
ToolCallRow,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mappers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _template(row: AgentTemplateRow) -> AgentTemplate:
|
||||
return AgentTemplate(
|
||||
id=row.id, name=row.name, role_key=RoleKey(row.role_key),
|
||||
description=row.description, default_prompt=row.default_prompt,
|
||||
default_tools_json=list(row.default_tools_json or []),
|
||||
default_routing_policy_id=row.default_routing_policy_id,
|
||||
is_system=row.is_system, created_at=row.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _employee(row: EmployeeRow) -> Employee:
|
||||
return Employee(
|
||||
id=row.id, organization_id=row.organization_id, project_id=row.project_id,
|
||||
template_id=row.template_id, name=row.name, role_key=RoleKey(row.role_key),
|
||||
status=EmployeeStatus(row.status), routing_policy_id=row.routing_policy_id,
|
||||
budget_limit_json=dict(row.budget_limit_json or {}), created_at=row.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _thread(row: ThreadRow) -> Thread:
|
||||
return Thread(
|
||||
id=row.id, organization_id=row.organization_id, project_id=row.project_id,
|
||||
title=row.title, created_by_user_id=row.created_by_user_id,
|
||||
created_at=row.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _message(row: MessageRow) -> Message:
|
||||
return Message(
|
||||
id=row.id, thread_id=row.thread_id, role=MessageRole(row.role),
|
||||
content=row.content, employee_id=row.employee_id, created_at=row.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _task(row: TaskRow) -> Task:
|
||||
return Task(
|
||||
id=row.id, organization_id=row.organization_id, project_id=row.project_id,
|
||||
thread_id=row.thread_id, title=row.title, description=row.description,
|
||||
status=TaskStatus(row.status),
|
||||
assigned_employee_id=row.assigned_employee_id,
|
||||
created_by_user_id=row.created_by_user_id,
|
||||
created_at=row.created_at, updated_at=row.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _run(row: RunRow) -> Run:
|
||||
return Run(
|
||||
id=row.id, task_id=row.task_id, employee_id=row.employee_id,
|
||||
status=RunStatus(row.status), error=row.error, started_at=row.started_at,
|
||||
finished_at=row.finished_at, created_at=row.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _run_step(row: RunStepRow) -> RunStep:
|
||||
return RunStep(
|
||||
id=row.id, run_id=row.run_id, index=row.index, kind=row.kind,
|
||||
title=row.title, payload_json=dict(row.payload_json or {}),
|
||||
created_at=row.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _tool_call(row: ToolCallRow) -> ToolCall:
|
||||
return ToolCall(
|
||||
id=row.id, run_id=row.run_id, tool_key=row.tool_key,
|
||||
arguments_json=dict(row.arguments_json or {}),
|
||||
result_json=dict(row.result_json or {}),
|
||||
status=ToolCallStatus(row.status), requires_approval=row.requires_approval,
|
||||
approved_by_user_id=row.approved_by_user_id, created_at=row.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _artifact(row: ArtifactRow) -> Artifact:
|
||||
return Artifact(
|
||||
id=row.id, organization_id=row.organization_id, project_id=row.project_id,
|
||||
run_id=row.run_id, name=row.name, kind=row.kind, content_ref=row.content_ref,
|
||||
size_bytes=row.size_bytes, created_at=row.created_at,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Templates & employees
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class AgentTemplateRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def list(self) -> list[AgentTemplate]:
|
||||
stmt = select(AgentTemplateRow).order_by(AgentTemplateRow.role_key)
|
||||
return [_template(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||
|
||||
async def get(self, template_id: str) -> AgentTemplate | None:
|
||||
row = await self.session.get(AgentTemplateRow, template_id)
|
||||
return _template(row) if row else None
|
||||
|
||||
async def get_by_name(self, name: str) -> AgentTemplate | None:
|
||||
stmt = select(AgentTemplateRow).where(AgentTemplateRow.name == name)
|
||||
row = (await self.session.execute(stmt)).scalar_one_or_none()
|
||||
return _template(row) if row else None
|
||||
|
||||
async def create(self, template: AgentTemplate) -> AgentTemplate:
|
||||
row = AgentTemplateRow(
|
||||
id=template.id, name=template.name, role_key=str(template.role_key),
|
||||
description=template.description, default_prompt=template.default_prompt,
|
||||
default_tools_json=list(template.default_tools_json),
|
||||
default_routing_policy_id=template.default_routing_policy_id,
|
||||
is_system=template.is_system, created_at=template.created_at,
|
||||
)
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
return _template(row)
|
||||
|
||||
|
||||
class EmployeeRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def get(self, employee_id: str) -> Employee | None:
|
||||
row = await self.session.get(EmployeeRow, employee_id)
|
||||
return _employee(row) if row else None
|
||||
|
||||
async def get_for_org(self, organization_id: str, employee_id: str) -> Employee | None:
|
||||
"""Tenant-scoped lookup: an id from another org resolves to ``None``."""
|
||||
stmt = select(EmployeeRow).where(
|
||||
EmployeeRow.id == employee_id,
|
||||
EmployeeRow.organization_id == organization_id,
|
||||
)
|
||||
row = (await self.session.execute(stmt)).scalar_one_or_none()
|
||||
return _employee(row) if row else None
|
||||
|
||||
async def list_for_org(
|
||||
self, organization_id: str, *, limit: int = 100, offset: int = 0
|
||||
) -> list[Employee]:
|
||||
stmt = (
|
||||
select(EmployeeRow)
|
||||
.where(EmployeeRow.organization_id == organization_id)
|
||||
.order_by(EmployeeRow.created_at.desc())
|
||||
.limit(min(limit, 500))
|
||||
.offset(offset)
|
||||
)
|
||||
return [_employee(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||
|
||||
async def list_for_project(self, project_id: str) -> list[Employee]:
|
||||
stmt = (
|
||||
select(EmployeeRow)
|
||||
.where(EmployeeRow.project_id == project_id)
|
||||
.order_by(EmployeeRow.created_at.desc())
|
||||
)
|
||||
return [_employee(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||
|
||||
async def count_for_org(self, organization_id: str) -> int:
|
||||
stmt = select(func.count()).select_from(EmployeeRow).where(
|
||||
EmployeeRow.organization_id == organization_id
|
||||
)
|
||||
return int((await self.session.execute(stmt)).scalar_one())
|
||||
|
||||
async def create(self, employee: Employee) -> Employee:
|
||||
row = EmployeeRow(
|
||||
id=employee.id, organization_id=employee.organization_id,
|
||||
project_id=employee.project_id, template_id=employee.template_id,
|
||||
name=employee.name, role_key=str(employee.role_key),
|
||||
status=str(employee.status),
|
||||
routing_policy_id=employee.routing_policy_id,
|
||||
budget_limit_json=dict(employee.budget_limit_json),
|
||||
created_at=employee.created_at,
|
||||
)
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
return _employee(row)
|
||||
|
||||
async def update(self, employee: Employee) -> Employee:
|
||||
row = await self.session.get(EmployeeRow, employee.id)
|
||||
if row is None:
|
||||
raise LookupError("employee not found")
|
||||
row.name = employee.name
|
||||
row.role_key = str(employee.role_key)
|
||||
row.status = str(employee.status)
|
||||
row.project_id = employee.project_id
|
||||
row.routing_policy_id = employee.routing_policy_id
|
||||
row.budget_limit_json = dict(employee.budget_limit_json)
|
||||
await self.session.flush()
|
||||
return _employee(row)
|
||||
|
||||
|
||||
class PromptVersionRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def list_for_employee(self, employee_id: str) -> list[PromptVersion]:
|
||||
stmt = (
|
||||
select(PromptVersionRow)
|
||||
.where(PromptVersionRow.employee_id == employee_id)
|
||||
.order_by(PromptVersionRow.version.desc())
|
||||
)
|
||||
return [
|
||||
PromptVersion(
|
||||
id=r.id, employee_id=r.employee_id, version=r.version,
|
||||
content=r.content, created_by_user_id=r.created_by_user_id,
|
||||
created_at=r.created_at,
|
||||
)
|
||||
for r in (await self.session.execute(stmt)).scalars()
|
||||
]
|
||||
|
||||
async def latest(self, employee_id: str) -> PromptVersion | None:
|
||||
versions = await self.list_for_employee(employee_id)
|
||||
return versions[0] if versions else None
|
||||
|
||||
async def next_version_number(self, employee_id: str) -> int:
|
||||
stmt = select(func.max(PromptVersionRow.version)).where(
|
||||
PromptVersionRow.employee_id == employee_id
|
||||
)
|
||||
current = (await self.session.execute(stmt)).scalar_one_or_none()
|
||||
return int(current or 0) + 1
|
||||
|
||||
async def create(self, version: PromptVersion) -> PromptVersion:
|
||||
row = PromptVersionRow(
|
||||
id=version.id, employee_id=version.employee_id, version=version.version,
|
||||
content=version.content, created_by_user_id=version.created_by_user_id,
|
||||
created_at=version.created_at,
|
||||
)
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
return PromptVersion(
|
||||
id=row.id, employee_id=row.employee_id, version=row.version,
|
||||
content=row.content, created_by_user_id=row.created_by_user_id,
|
||||
created_at=row.created_at,
|
||||
)
|
||||
|
||||
|
||||
class EmployeeToolPermissionRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def list_for_employee(self, employee_id: str) -> list[EmployeeToolPermission]:
|
||||
stmt = select(EmployeeToolPermissionRow).where(
|
||||
EmployeeToolPermissionRow.employee_id == employee_id
|
||||
)
|
||||
return [
|
||||
EmployeeToolPermission(
|
||||
id=r.id, employee_id=r.employee_id, tool_key=r.tool_key,
|
||||
allowed=r.allowed, policy_json=dict(r.policy_json or {}),
|
||||
)
|
||||
for r in (await self.session.execute(stmt)).scalars()
|
||||
]
|
||||
|
||||
async def is_allowed(self, employee_id: str, tool_key: str) -> bool:
|
||||
stmt = select(EmployeeToolPermissionRow.allowed).where(
|
||||
EmployeeToolPermissionRow.employee_id == employee_id,
|
||||
EmployeeToolPermissionRow.tool_key == tool_key,
|
||||
)
|
||||
value = (await self.session.execute(stmt)).scalar_one_or_none()
|
||||
# Deny by default: an absent row means "not permitted".
|
||||
return bool(value)
|
||||
|
||||
async def set_permission(
|
||||
self, *, employee_id: str, tool_key: str, allowed: bool, policy: dict | None = None
|
||||
) -> EmployeeToolPermission:
|
||||
stmt = select(EmployeeToolPermissionRow).where(
|
||||
EmployeeToolPermissionRow.employee_id == employee_id,
|
||||
EmployeeToolPermissionRow.tool_key == tool_key,
|
||||
)
|
||||
row = (await self.session.execute(stmt)).scalar_one_or_none()
|
||||
if row is None:
|
||||
row = EmployeeToolPermissionRow(
|
||||
employee_id=employee_id, tool_key=tool_key, allowed=allowed,
|
||||
policy_json=dict(policy or {}),
|
||||
)
|
||||
self.session.add(row)
|
||||
else:
|
||||
row.allowed = allowed
|
||||
row.policy_json = dict(policy or {})
|
||||
await self.session.flush()
|
||||
return EmployeeToolPermission(
|
||||
id=row.id, employee_id=row.employee_id, tool_key=row.tool_key,
|
||||
allowed=row.allowed, policy_json=dict(row.policy_json or {}),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Chat: threads & messages
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ThreadRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def get(self, thread_id: str) -> Thread | None:
|
||||
row = await self.session.get(ThreadRow, thread_id)
|
||||
return _thread(row) if row else None
|
||||
|
||||
async def get_for_org(self, organization_id: str, thread_id: str) -> Thread | None:
|
||||
stmt = select(ThreadRow).where(
|
||||
ThreadRow.id == thread_id, ThreadRow.organization_id == organization_id
|
||||
)
|
||||
row = (await self.session.execute(stmt)).scalar_one_or_none()
|
||||
return _thread(row) if row else None
|
||||
|
||||
async def list_for_org(
|
||||
self, organization_id: str, *, limit: int = 100, offset: int = 0
|
||||
) -> list[Thread]:
|
||||
stmt = (
|
||||
select(ThreadRow)
|
||||
.where(ThreadRow.organization_id == organization_id)
|
||||
.order_by(ThreadRow.created_at.desc())
|
||||
.limit(min(limit, 500))
|
||||
.offset(offset)
|
||||
)
|
||||
return [_thread(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||
|
||||
async def create(self, thread: Thread) -> Thread:
|
||||
row = ThreadRow(
|
||||
id=thread.id, organization_id=thread.organization_id,
|
||||
project_id=thread.project_id, title=thread.title,
|
||||
created_by_user_id=thread.created_by_user_id, created_at=thread.created_at,
|
||||
)
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
return _thread(row)
|
||||
|
||||
|
||||
class MessageRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def list_for_thread(self, thread_id: str) -> list[Message]:
|
||||
stmt = (
|
||||
select(MessageRow)
|
||||
.where(MessageRow.thread_id == thread_id)
|
||||
.order_by(MessageRow.created_at)
|
||||
)
|
||||
return [_message(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||
|
||||
async def count_for_thread(self, thread_id: str) -> int:
|
||||
stmt = select(func.count()).select_from(MessageRow).where(
|
||||
MessageRow.thread_id == thread_id
|
||||
)
|
||||
return int((await self.session.execute(stmt)).scalar_one())
|
||||
|
||||
async def create(self, message: Message) -> Message:
|
||||
row = MessageRow(
|
||||
id=message.id, thread_id=message.thread_id, role=str(message.role),
|
||||
content=message.content, employee_id=message.employee_id,
|
||||
created_at=message.created_at,
|
||||
)
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
return _message(row)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Work: tasks, runs, steps, tool calls, artifacts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TaskRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def get(self, task_id: str) -> Task | None:
|
||||
row = await self.session.get(TaskRow, task_id)
|
||||
return _task(row) if row else None
|
||||
|
||||
async def get_for_org(self, organization_id: str, task_id: str) -> Task | None:
|
||||
stmt = select(TaskRow).where(
|
||||
TaskRow.id == task_id, TaskRow.organization_id == organization_id
|
||||
)
|
||||
row = (await self.session.execute(stmt)).scalar_one_or_none()
|
||||
return _task(row) if row else None
|
||||
|
||||
async def list_for_org(
|
||||
self,
|
||||
organization_id: str,
|
||||
*,
|
||||
status: TaskStatus | None = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
) -> list[Task]:
|
||||
stmt = select(TaskRow).where(TaskRow.organization_id == organization_id)
|
||||
if status is not None:
|
||||
stmt = stmt.where(TaskRow.status == str(status))
|
||||
stmt = stmt.order_by(TaskRow.created_at.desc()).limit(min(limit, 500)).offset(offset)
|
||||
return [_task(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||
|
||||
async def list_for_employee(self, employee_id: str) -> list[Task]:
|
||||
stmt = (
|
||||
select(TaskRow)
|
||||
.where(TaskRow.assigned_employee_id == employee_id)
|
||||
.order_by(TaskRow.created_at.desc())
|
||||
)
|
||||
return [_task(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||
|
||||
async def list_runnable(self, *, limit: int = 10) -> list[Task]:
|
||||
"""Tasks the runtime worker should pick up, oldest first.
|
||||
|
||||
Cross-tenant by design: the worker is a platform component, not a
|
||||
tenant. Only ``new``/``triaged`` qualify — anything else is either
|
||||
already executing or closed, and re-running it would double-bill.
|
||||
"""
|
||||
stmt = (
|
||||
select(TaskRow)
|
||||
.where(TaskRow.status.in_([str(TaskStatus.NEW), str(TaskStatus.TRIAGED)]))
|
||||
.order_by(TaskRow.created_at.asc())
|
||||
.limit(min(limit, 100))
|
||||
)
|
||||
return [_task(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||
|
||||
async def count_open_for_employee(self, employee_id: str) -> int:
|
||||
stmt = (
|
||||
select(func.count())
|
||||
.select_from(TaskRow)
|
||||
.where(
|
||||
TaskRow.assigned_employee_id == employee_id,
|
||||
TaskRow.status.notin_(
|
||||
[str(TaskStatus.DONE), str(TaskStatus.FAILED), str(TaskStatus.CANCELLED)]
|
||||
),
|
||||
)
|
||||
)
|
||||
return int((await self.session.execute(stmt)).scalar_one())
|
||||
|
||||
async def create(self, task: Task) -> Task:
|
||||
row = TaskRow(
|
||||
id=task.id, organization_id=task.organization_id,
|
||||
project_id=task.project_id, thread_id=task.thread_id,
|
||||
title=task.title, description=task.description, status=str(task.status),
|
||||
assigned_employee_id=task.assigned_employee_id,
|
||||
created_by_user_id=task.created_by_user_id,
|
||||
created_at=task.created_at, updated_at=task.updated_at,
|
||||
)
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
return _task(row)
|
||||
|
||||
async def set_status(self, task_id: str, status: TaskStatus) -> Task | None:
|
||||
row = await self.session.get(TaskRow, task_id)
|
||||
if row is None:
|
||||
return None
|
||||
row.status = str(status)
|
||||
await self.session.flush()
|
||||
return _task(row)
|
||||
|
||||
|
||||
class RunRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def get(self, run_id: str) -> Run | None:
|
||||
row = await self.session.get(RunRow, run_id)
|
||||
return _run(row) if row else None
|
||||
|
||||
async def list_for_task(self, task_id: str) -> list[Run]:
|
||||
stmt = (
|
||||
select(RunRow)
|
||||
.where(RunRow.task_id == task_id)
|
||||
.order_by(RunRow.created_at.desc())
|
||||
)
|
||||
return [_run(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||
|
||||
async def create(self, run: Run) -> Run:
|
||||
row = RunRow(
|
||||
id=run.id, task_id=run.task_id, employee_id=run.employee_id,
|
||||
status=str(run.status), error=run.error, started_at=run.started_at,
|
||||
finished_at=run.finished_at, created_at=run.created_at,
|
||||
)
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
return _run(row)
|
||||
|
||||
async def set_status(
|
||||
self,
|
||||
run_id: str,
|
||||
status: RunStatus,
|
||||
*,
|
||||
error: str | None = None,
|
||||
started_at=None,
|
||||
finished_at=None,
|
||||
) -> Run | None:
|
||||
row = await self.session.get(RunRow, run_id)
|
||||
if row is None:
|
||||
return None
|
||||
row.status = str(status)
|
||||
if error is not None:
|
||||
row.error = error
|
||||
if started_at is not None:
|
||||
row.started_at = started_at
|
||||
if finished_at is not None:
|
||||
row.finished_at = finished_at
|
||||
await self.session.flush()
|
||||
return _run(row)
|
||||
|
||||
|
||||
class RunStepRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def list_for_run(self, run_id: str) -> list[RunStep]:
|
||||
stmt = (
|
||||
select(RunStepRow)
|
||||
.where(RunStepRow.run_id == run_id)
|
||||
.order_by(RunStepRow.index)
|
||||
)
|
||||
return [_run_step(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||
|
||||
async def next_index(self, run_id: str) -> int:
|
||||
stmt = select(func.max(RunStepRow.index)).where(RunStepRow.run_id == run_id)
|
||||
current = (await self.session.execute(stmt)).scalar_one_or_none()
|
||||
return int(current if current is not None else -1) + 1
|
||||
|
||||
async def append(
|
||||
self, *, run_id: str, kind: str, title: str = "", payload: dict | None = None
|
||||
) -> RunStep:
|
||||
step = RunStep(
|
||||
run_id=run_id, index=await self.next_index(run_id), kind=kind, title=title,
|
||||
payload_json=dict(payload or {}),
|
||||
)
|
||||
row = RunStepRow(
|
||||
id=step.id, run_id=step.run_id, index=step.index, kind=step.kind,
|
||||
title=step.title, payload_json=step.payload_json, created_at=step.created_at,
|
||||
)
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
return _run_step(row)
|
||||
|
||||
|
||||
class ToolCallRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def get(self, call_id: str) -> ToolCall | None:
|
||||
row = await self.session.get(ToolCallRow, call_id)
|
||||
return _tool_call(row) if row else None
|
||||
|
||||
async def get_for_org(self, organization_id: str, call_id: str) -> ToolCall | None:
|
||||
"""Tenant-scoped lookup, resolved through run -> task -> organization."""
|
||||
stmt = (
|
||||
select(ToolCallRow)
|
||||
.join(RunRow, RunRow.id == ToolCallRow.run_id)
|
||||
.join(TaskRow, TaskRow.id == RunRow.task_id)
|
||||
.where(ToolCallRow.id == call_id, TaskRow.organization_id == organization_id)
|
||||
)
|
||||
row = (await self.session.execute(stmt)).scalar_one_or_none()
|
||||
return _tool_call(row) if row else None
|
||||
|
||||
async def list_for_run(self, run_id: str) -> list[ToolCall]:
|
||||
stmt = (
|
||||
select(ToolCallRow)
|
||||
.where(ToolCallRow.run_id == run_id)
|
||||
.order_by(ToolCallRow.created_at)
|
||||
)
|
||||
return [_tool_call(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||
|
||||
async def list_pending_approvals_for_org(
|
||||
self, organization_id: str
|
||||
) -> list[ToolCall]:
|
||||
"""Tool calls awaiting approval, scoped through the owning run -> task -> org."""
|
||||
stmt = (
|
||||
select(ToolCallRow)
|
||||
.join(RunRow, RunRow.id == ToolCallRow.run_id)
|
||||
.join(TaskRow, TaskRow.id == RunRow.task_id)
|
||||
.where(
|
||||
TaskRow.organization_id == organization_id,
|
||||
ToolCallRow.status == str(ToolCallStatus.AWAITING_APPROVAL),
|
||||
)
|
||||
.order_by(ToolCallRow.created_at)
|
||||
)
|
||||
return [_tool_call(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||
|
||||
async def create(self, call: ToolCall) -> ToolCall:
|
||||
row = ToolCallRow(
|
||||
id=call.id, run_id=call.run_id, tool_key=call.tool_key,
|
||||
arguments_json=dict(call.arguments_json),
|
||||
result_json=dict(call.result_json), status=str(call.status),
|
||||
requires_approval=call.requires_approval,
|
||||
approved_by_user_id=call.approved_by_user_id, created_at=call.created_at,
|
||||
)
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
return _tool_call(row)
|
||||
|
||||
async def set_status(
|
||||
self,
|
||||
call_id: str,
|
||||
status: ToolCallStatus,
|
||||
*,
|
||||
result: dict | None = None,
|
||||
approved_by_user_id: str | None = None,
|
||||
) -> ToolCall | None:
|
||||
row = await self.session.get(ToolCallRow, call_id)
|
||||
if row is None:
|
||||
return None
|
||||
row.status = str(status)
|
||||
if result is not None:
|
||||
row.result_json = dict(result)
|
||||
if approved_by_user_id is not None:
|
||||
row.approved_by_user_id = approved_by_user_id
|
||||
await self.session.flush()
|
||||
return _tool_call(row)
|
||||
|
||||
|
||||
class ArtifactRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def list_for_org(
|
||||
self, organization_id: str, *, limit: int = 100, offset: int = 0
|
||||
) -> list[Artifact]:
|
||||
stmt = (
|
||||
select(ArtifactRow)
|
||||
.where(ArtifactRow.organization_id == organization_id)
|
||||
.order_by(ArtifactRow.created_at.desc())
|
||||
.limit(min(limit, 500))
|
||||
.offset(offset)
|
||||
)
|
||||
return [_artifact(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||
|
||||
async def list_for_run(self, run_id: str) -> list[Artifact]:
|
||||
stmt = (
|
||||
select(ArtifactRow)
|
||||
.where(ArtifactRow.run_id == run_id)
|
||||
.order_by(ArtifactRow.created_at)
|
||||
)
|
||||
return [_artifact(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||
|
||||
async def create(self, artifact: Artifact) -> Artifact:
|
||||
row = ArtifactRow(
|
||||
id=artifact.id, organization_id=artifact.organization_id,
|
||||
project_id=artifact.project_id, run_id=artifact.run_id,
|
||||
name=artifact.name, kind=artifact.kind, content_ref=artifact.content_ref,
|
||||
size_bytes=artifact.size_bytes, created_at=artifact.created_at,
|
||||
)
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
return _artifact(row)
|
||||
@@ -0,0 +1,197 @@
|
||||
"""FastAPI dependencies: authentication, tenant resolution and capability checks.
|
||||
|
||||
The single most important rule of this file: **every** organization-scoped
|
||||
endpoint resolves its tenant through ``get_org_context``, which refuses to
|
||||
return anything unless the caller has an active membership. There is no
|
||||
"trust the org_id in the path" path.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from fastapi import Depends, Request
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_session
|
||||
from app.core.errors import AuthenticationError, NotFoundError, PermissionDeniedError
|
||||
from app.core.security import decode_access_token
|
||||
from app.domain.identity.models import Membership, Organization, Role, User
|
||||
from app.infrastructure.repositories.identity import (
|
||||
MembershipRepository,
|
||||
OrganizationRepository,
|
||||
UserRepository,
|
||||
)
|
||||
from app.interfaces.http.request_context import note_actor
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.application.services.billing_service import BillingService
|
||||
from app.application.services.knowledge_service import KnowledgeService
|
||||
from app.application.services.project_service import ProjectService
|
||||
from app.application.services.sandbox_service import SandboxService
|
||||
from app.application.services.workforce_service import WorkforceService
|
||||
|
||||
_bearer = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class OrgContext:
|
||||
"""Resolved tenant context for an organization-scoped request."""
|
||||
|
||||
organization: Organization
|
||||
membership: Membership
|
||||
user: User
|
||||
|
||||
@property
|
||||
def org_id(self) -> str:
|
||||
return self.organization.id
|
||||
|
||||
def require(self, capability: str) -> None:
|
||||
if not self.membership.has(capability):
|
||||
raise PermissionDeniedError("شما دسترسی لازم برای این عملیات را ندارید.")
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
request: Request,
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(_bearer),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> User:
|
||||
if credentials is None or not credentials.credentials:
|
||||
raise AuthenticationError("برای این عملیات باید وارد شوید.")
|
||||
payload = decode_access_token(credentials.credentials)
|
||||
user_id = payload.get("sub")
|
||||
if not user_id:
|
||||
raise AuthenticationError("توکن نامعتبر است.")
|
||||
user = await UserRepository(session).get(str(user_id))
|
||||
if user is None or not user.is_active:
|
||||
raise AuthenticationError("حساب کاربری یافت نشد یا غیرفعال است.")
|
||||
# Record the verified actor so any later failure is attributable. Uses the
|
||||
# id from the database, not the one in the token.
|
||||
note_actor(request, user_id=user.id)
|
||||
return user
|
||||
|
||||
|
||||
async def get_optional_user(
|
||||
request: Request,
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(_bearer),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> User | None:
|
||||
if credentials is None or not credentials.credentials:
|
||||
return None
|
||||
try:
|
||||
return await get_current_user(request, credentials, session)
|
||||
except AuthenticationError:
|
||||
return None
|
||||
|
||||
|
||||
async def require_platform_owner(user: User = Depends(get_current_user)) -> User:
|
||||
if not user.is_platform_owner:
|
||||
raise PermissionDeniedError("این بخش فقط برای مالک سامانه قابل دسترسی است.")
|
||||
return user
|
||||
|
||||
|
||||
async def get_org_context(
|
||||
request: Request,
|
||||
org_id: str,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> OrgContext:
|
||||
"""Resolve ``org_id`` into an authorized tenant context.
|
||||
|
||||
Returns 404 (not 403) when the caller has no membership, so the API does not
|
||||
leak the existence of other organizations.
|
||||
"""
|
||||
organization = await OrganizationRepository(session).get(org_id)
|
||||
if organization is None:
|
||||
raise NotFoundError("سازمان یافت نشد.")
|
||||
membership = await MembershipRepository(session).get(org_id, user.id)
|
||||
if membership is None or not membership.is_active:
|
||||
raise NotFoundError("سازمان یافت نشد.")
|
||||
# The tenant is only known *after* the membership check passes, so this is
|
||||
# the earliest point at which it is safe to attribute a failure to an org.
|
||||
note_actor(request, organization_id=organization.id, user_id=user.id)
|
||||
return OrgContext(organization=organization, membership=membership, user=user)
|
||||
|
||||
|
||||
def require_capability(capability: str):
|
||||
"""Dependency factory: ``Depends(require_capability(CAN_MANAGE_PROJECTS))``."""
|
||||
|
||||
async def _checker(context: OrgContext = Depends(get_org_context)) -> OrgContext:
|
||||
context.require(capability)
|
||||
return context
|
||||
|
||||
return _checker
|
||||
|
||||
|
||||
def require_role_at_least(minimum: Role):
|
||||
from app.domain.identity.models import ROLE_RANK
|
||||
|
||||
async def _checker(context: OrgContext = Depends(get_org_context)) -> OrgContext:
|
||||
if ROLE_RANK[context.membership.role] < ROLE_RANK[minimum]:
|
||||
raise PermissionDeniedError("سطح دسترسی شما برای این عملیات کافی نیست.")
|
||||
return context
|
||||
|
||||
return _checker
|
||||
|
||||
|
||||
def client_meta(request: Request) -> tuple[str, str]:
|
||||
"""``(ip, user_agent)`` for the audit trail."""
|
||||
forwarded = request.headers.get("x-forwarded-for", "")
|
||||
ip = (
|
||||
forwarded.split(",")[0].strip()
|
||||
if forwarded
|
||||
else (request.client.host if request.client else "")
|
||||
)
|
||||
return ip, request.headers.get("user-agent", "")[:255]
|
||||
|
||||
|
||||
# The builders themselves live in ``app.application.container`` so that the
|
||||
# standalone workers compose services exactly the way the API does. These
|
||||
# functions are thin FastAPI dependency wrappers around them.
|
||||
|
||||
|
||||
async def get_project_service(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ProjectService:
|
||||
"""Build the project/domain service with the configured adapters."""
|
||||
from app.application.container import build_project_service
|
||||
|
||||
return build_project_service(session)
|
||||
|
||||
|
||||
async def get_workforce_service(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> WorkforceService:
|
||||
"""Build the workforce/chat/run service with the configured adapters."""
|
||||
from app.application.container import build_workforce_service
|
||||
|
||||
return build_workforce_service(session)
|
||||
|
||||
|
||||
async def get_billing_service(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> BillingService:
|
||||
"""Build the billing service with the configured payment adapter."""
|
||||
from app.application.container import build_billing_service
|
||||
|
||||
return build_billing_service(session)
|
||||
|
||||
|
||||
async def get_knowledge_service(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> KnowledgeService:
|
||||
"""Build the knowledge service with the configured vector adapter."""
|
||||
from app.application.container import build_knowledge_service
|
||||
|
||||
return build_knowledge_service(session)
|
||||
|
||||
|
||||
async def get_sandbox_service(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> SandboxService:
|
||||
"""Build the sandbox gateway with the configured execution adapter."""
|
||||
from app.application.container import build_sandbox_service
|
||||
|
||||
return build_sandbox_service(session)
|
||||
@@ -0,0 +1,213 @@
|
||||
"""Translate domain errors into HTTP responses — and record them.
|
||||
|
||||
The inner layers never import FastAPI; this module is the only place that knows
|
||||
about status codes. It is also the single choke point through which every
|
||||
request-scoped failure passes, which makes it the right place to persist them.
|
||||
|
||||
What gets recorded, and why:
|
||||
|
||||
* **Unhandled exceptions and 5xx ``AppError``s** — always. A provider being down
|
||||
or a quota check failing is an operational fault someone must see.
|
||||
* **A bare ``ValueError`` escaping to the handler** — always, at ``warning``.
|
||||
The codebase has a typed ``ValidationError`` for expected bad input and uses
|
||||
it 30 times, so a bare ``ValueError`` reaching here is usually a typing gap
|
||||
worth reviewing. The set of call sites is small and bounded.
|
||||
* **Other 4xx** — only when ``ERROR_LOG_CLIENT_ERRORS`` is on. Most 4xx are a
|
||||
caller sending bad input, and a public API receives a steady stream of 404s
|
||||
from scanners; recording all of them by default would bury the real bugs.
|
||||
Recorded at ``warning`` so they never look like faults.
|
||||
|
||||
Every error body carries the request id, and every response carries it in the
|
||||
``X-Request-ID`` header, so a user can quote one string and land on exactly one
|
||||
row in the console.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.errors import AppError
|
||||
from app.domain.audit.models import ErrorSeverity, ErrorSource
|
||||
from app.infrastructure.error_log import record_error
|
||||
from app.interfaces.http.request_context import REQUEST_ID_HEADER, context_of
|
||||
|
||||
logger = logging.getLogger("agy.api")
|
||||
|
||||
#: Statuses at or above this are "the server is broken".
|
||||
SERVER_ERROR_THRESHOLD = 500
|
||||
|
||||
#: Cap on the per-field validation detail echoed back and stored. A caller
|
||||
#: sending a 10 000-element array should not produce a 10 000-line error row.
|
||||
MAX_VALIDATION_ERRORS = 20
|
||||
|
||||
|
||||
def _request_id(request: Request) -> str:
|
||||
context = context_of(request)
|
||||
return context.request_id if context else ""
|
||||
|
||||
|
||||
def _error_response(
|
||||
*,
|
||||
status_code: int,
|
||||
code: str,
|
||||
message: str,
|
||||
request_id: str,
|
||||
details: object = None,
|
||||
) -> JSONResponse:
|
||||
"""The one place an error response is built.
|
||||
|
||||
The request id goes in the header as well as the body, and it has to be set
|
||||
*here* rather than by the middleware: an unhandled exception is turned into a
|
||||
response by Starlette's ``ServerErrorMiddleware``, which sits outside every
|
||||
user middleware, so the middleware never sees the response to add a header
|
||||
to. The body alone would leave an HTTP client that only reads headers with
|
||||
no way to correlate the failure.
|
||||
"""
|
||||
error: dict = {"code": code, "message": message, "request_id": request_id}
|
||||
if details is not None:
|
||||
error["details"] = details
|
||||
headers = {REQUEST_ID_HEADER: request_id} if request_id else None
|
||||
return JSONResponse(
|
||||
status_code=status_code, content={"error": error}, headers=headers
|
||||
)
|
||||
|
||||
|
||||
async def _record(
|
||||
request: Request,
|
||||
*,
|
||||
kind: str,
|
||||
message: str,
|
||||
status_code: int | None,
|
||||
exc: BaseException | None = None,
|
||||
severity: str = ErrorSeverity.ERROR,
|
||||
metadata: dict | None = None,
|
||||
) -> None:
|
||||
"""Persist a request-scoped failure. Never raises."""
|
||||
context = context_of(request)
|
||||
try:
|
||||
await record_error(
|
||||
kind=kind,
|
||||
message=message,
|
||||
exc=exc,
|
||||
source=ErrorSource.HTTP,
|
||||
severity=severity,
|
||||
status_code=status_code,
|
||||
method=context.method if context else request.method,
|
||||
path=context.path if context else request.url.path,
|
||||
organization_id=context.organization_id if context else None,
|
||||
user_id=context.user_id if context else None,
|
||||
run_id=context.run_id if context else None,
|
||||
request_id=context.request_id if context else "",
|
||||
ip=context.ip if context else "",
|
||||
user_agent=context.user_agent if context else "",
|
||||
metadata=metadata,
|
||||
)
|
||||
except Exception: # pragma: no cover - the recorder is already total
|
||||
logger.exception("error-log: unexpected failure while recording an error")
|
||||
|
||||
|
||||
def register_exception_handlers(app: FastAPI) -> None:
|
||||
@app.exception_handler(AppError)
|
||||
async def _app_error_handler(request: Request, exc: AppError) -> JSONResponse:
|
||||
is_server_fault = exc.status_code >= SERVER_ERROR_THRESHOLD
|
||||
if settings.error_log_enabled and (is_server_fault or settings.error_log_client_errors):
|
||||
await _record(
|
||||
request,
|
||||
kind=exc.code,
|
||||
message=exc.message,
|
||||
status_code=exc.status_code,
|
||||
# Pass the exception so the stack is captured: a
|
||||
# ProviderUnavailable raised three layers down is far easier to
|
||||
# fix with the traceback than with the message alone.
|
||||
exc=exc,
|
||||
severity=ErrorSeverity.ERROR if is_server_fault else ErrorSeverity.WARNING,
|
||||
metadata={"details": exc.details} if exc.details is not None else None,
|
||||
)
|
||||
|
||||
return _error_response(
|
||||
status_code=exc.status_code,
|
||||
code=exc.code,
|
||||
message=exc.message,
|
||||
request_id=_request_id(request),
|
||||
details=exc.details,
|
||||
)
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def _validation_error_handler(
|
||||
request: Request, exc: RequestValidationError
|
||||
) -> JSONResponse:
|
||||
"""Reject malformed input in the standard envelope.
|
||||
|
||||
FastAPI's default body is ``{"detail": [...]}``, which the web client
|
||||
cannot read — it would surface every validation failure as a generic
|
||||
"server error (422)". Same status, useful shape.
|
||||
"""
|
||||
errors = [
|
||||
{
|
||||
"loc": [str(part) for part in err.get("loc", ())],
|
||||
"msg": str(err.get("msg", "")),
|
||||
}
|
||||
for err in exc.errors()[:MAX_VALIDATION_ERRORS]
|
||||
]
|
||||
if settings.error_log_enabled and settings.error_log_client_errors:
|
||||
await _record(
|
||||
request,
|
||||
kind="request_validation_error",
|
||||
message="; ".join(f"{'.'.join(e['loc'])}: {e['msg']}" for e in errors),
|
||||
status_code=422,
|
||||
severity=ErrorSeverity.WARNING,
|
||||
)
|
||||
return _error_response(
|
||||
status_code=422,
|
||||
code="validation_error",
|
||||
message="دادههای ارسالی نامعتبر است.",
|
||||
request_id=_request_id(request),
|
||||
details=errors,
|
||||
)
|
||||
|
||||
@app.exception_handler(ValueError)
|
||||
async def _value_error_handler(request: Request, exc: ValueError) -> JSONResponse:
|
||||
if settings.error_log_enabled:
|
||||
await _record(
|
||||
request,
|
||||
kind="value_error",
|
||||
message=str(exc),
|
||||
status_code=422,
|
||||
exc=exc,
|
||||
severity=ErrorSeverity.WARNING,
|
||||
)
|
||||
return _error_response(
|
||||
status_code=422,
|
||||
code="validation_error",
|
||||
message=str(exc),
|
||||
request_id=_request_id(request),
|
||||
)
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def _unhandled_handler(request: Request, exc: Exception) -> JSONResponse:
|
||||
"""The last line of defence: an unexpected failure.
|
||||
|
||||
Recorded unconditionally when logging is enabled — this is precisely the
|
||||
class of error the console exists for.
|
||||
"""
|
||||
logger.exception("Unhandled error on %s %s", request.method, request.url.path)
|
||||
if settings.error_log_enabled:
|
||||
await _record(
|
||||
request,
|
||||
kind="unhandled_exception",
|
||||
message=str(exc) or type(exc).__name__,
|
||||
status_code=500,
|
||||
exc=exc,
|
||||
severity=ErrorSeverity.CRITICAL,
|
||||
)
|
||||
return _error_response(
|
||||
status_code=500,
|
||||
code="internal_error",
|
||||
message="خطای داخلی سرور. لطفاً دوباره تلاش کنید.",
|
||||
request_id=_request_id(request),
|
||||
)
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Per-request correlation and actor context.
|
||||
|
||||
Every request gets an id before it reaches a route. That id is returned in the
|
||||
``X-Request-ID`` response header, included in every error body, and written onto
|
||||
every error record — so a user reporting "it broke" can hand over one string
|
||||
that points at exactly one row in the error console.
|
||||
|
||||
The same object carries the resolved tenant and user. Authentication happens in
|
||||
dependencies, long after the middleware has run, so the middleware seeds the
|
||||
context with what it knows (method, path, ip, user agent) and the auth
|
||||
dependencies fill in the rest via :func:`note_actor`. Error handlers then read
|
||||
one object instead of re-deriving anything.
|
||||
|
||||
Deliberately stored on ``request.state`` rather than in a ``contextvar``: it is
|
||||
the same object the handler already has in hand, it survives every task
|
||||
boundary Starlette may introduce, and it cannot leak between requests.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
|
||||
from fastapi import Request
|
||||
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
||||
from starlette.responses import Response
|
||||
|
||||
#: Header used to both accept a caller-supplied id and echo it back. Accepting
|
||||
#: an upstream id matters when the API sits behind a reverse proxy or a
|
||||
#: frontend BFF that already correlates its own logs.
|
||||
REQUEST_ID_HEADER = "X-Request-ID"
|
||||
|
||||
#: Long enough to be unique, short enough to paste into a bug report.
|
||||
REQUEST_ID_LENGTH = 16
|
||||
|
||||
_STATE_ATTR = "error_context"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class RequestContext:
|
||||
"""What we know about the request an error happened on."""
|
||||
|
||||
request_id: str
|
||||
method: str
|
||||
path: str
|
||||
ip: str = ""
|
||||
user_agent: str = ""
|
||||
#: Filled in by the auth dependencies once the caller is authenticated.
|
||||
organization_id: str | None = None
|
||||
user_id: str | None = None
|
||||
#: Set when a failure happens inside a specific agent run.
|
||||
run_id: str | None = None
|
||||
#: Query string, kept separate from ``path`` so fingerprints group across
|
||||
#: different filters on the same endpoint.
|
||||
query: str = ""
|
||||
|
||||
|
||||
def new_request_id() -> str:
|
||||
return uuid.uuid4().hex[:REQUEST_ID_LENGTH]
|
||||
|
||||
|
||||
def client_ip(request: Request) -> str:
|
||||
"""Real client address, honouring one proxy hop.
|
||||
|
||||
Same rule as ``deps.client_meta``: a trusted reverse proxy sets
|
||||
``X-Forwarded-For``, and the left-most entry is the original caller.
|
||||
"""
|
||||
forwarded = request.headers.get("x-forwarded-for", "")
|
||||
if forwarded:
|
||||
return forwarded.split(",")[0].strip()
|
||||
return request.client.host if request.client else ""
|
||||
|
||||
|
||||
def context_of(request: Request) -> RequestContext | None:
|
||||
"""The context for this request, or ``None`` outside the middleware."""
|
||||
return getattr(request.state, _STATE_ATTR, None)
|
||||
|
||||
|
||||
def note_actor(
|
||||
request: Request,
|
||||
*,
|
||||
user_id: str | None = None,
|
||||
organization_id: str | None = None,
|
||||
run_id: str | None = None,
|
||||
) -> None:
|
||||
"""Record who/which tenant this request belongs to.
|
||||
|
||||
Called from the auth dependencies. Silent when there is no context (a
|
||||
direct unit-test call to a dependency, for instance) — correlation is
|
||||
telemetry, and telemetry must never be the reason a request fails.
|
||||
"""
|
||||
context = context_of(request)
|
||||
if context is None:
|
||||
return
|
||||
if user_id:
|
||||
context.user_id = user_id
|
||||
if organization_id:
|
||||
context.organization_id = organization_id
|
||||
if run_id:
|
||||
context.run_id = run_id
|
||||
|
||||
|
||||
class RequestContextMiddleware(BaseHTTPMiddleware):
|
||||
"""Attach a :class:`RequestContext` to every request and echo its id."""
|
||||
|
||||
async def dispatch(
|
||||
self, request: Request, call_next: RequestResponseEndpoint
|
||||
) -> Response:
|
||||
incoming = request.headers.get(REQUEST_ID_HEADER, "").strip()
|
||||
# Cap the accepted value: an unbounded header would let a caller write
|
||||
# arbitrarily long ids into the error table.
|
||||
request_id = incoming[:64] if incoming else new_request_id()
|
||||
|
||||
setattr( # noqa: B010 - the attribute name is a module constant
|
||||
request.state,
|
||||
_STATE_ATTR,
|
||||
RequestContext(
|
||||
request_id=request_id,
|
||||
method=request.method,
|
||||
path=request.url.path,
|
||||
query=request.url.query or "",
|
||||
ip=client_ip(request),
|
||||
user_agent=request.headers.get("user-agent", "")[:255],
|
||||
),
|
||||
)
|
||||
response = await call_next(request)
|
||||
response.headers[REQUEST_ID_HEADER] = request_id
|
||||
return response
|
||||
|
||||
|
||||
__all__ = [
|
||||
"REQUEST_ID_HEADER",
|
||||
"RequestContext",
|
||||
"RequestContextMiddleware",
|
||||
"client_ip",
|
||||
"context_of",
|
||||
"new_request_id",
|
||||
"note_actor",
|
||||
]
|
||||
@@ -0,0 +1,715 @@
|
||||
"""Platform owner console: providers, models, routing policies and rate cards.
|
||||
|
||||
Everything in this router is invisible to customers. Two rules are enforced
|
||||
here and tested:
|
||||
|
||||
1. Only a platform owner may call any of it.
|
||||
2. A provider API key is **never** returned — not masked-and-then-leaked, not in
|
||||
a nested object. Only ``masked_key`` leaves this module.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request, status
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.application.services.audit_service import record_audit
|
||||
from app.core.crypto import encrypt, mask
|
||||
from app.core.database import get_session
|
||||
from app.core.errors import ConflictError, NotFoundError, ValidationError
|
||||
from app.domain.ai_control.models import (
|
||||
AiModel,
|
||||
Capability,
|
||||
ModelCredential,
|
||||
Provider,
|
||||
ProviderType,
|
||||
RateCard,
|
||||
RateUnit,
|
||||
RoutingMode,
|
||||
RoutingPolicy,
|
||||
)
|
||||
from app.domain.audit.models import AuditAction
|
||||
from app.domain.base import utcnow
|
||||
from app.domain.identity.models import User
|
||||
from app.infrastructure.repositories.ai_control import (
|
||||
AiModelRepository,
|
||||
ModelCredentialRepository,
|
||||
ProviderRepository,
|
||||
RateCardRepository,
|
||||
RoutingPolicyRepository,
|
||||
)
|
||||
from app.infrastructure.repositories.audit import (
|
||||
ObservabilityRepository,
|
||||
UsageLedgerRepository,
|
||||
)
|
||||
from app.infrastructure.repositories.identity import (
|
||||
MembershipRepository,
|
||||
OrganizationRepository,
|
||||
)
|
||||
from app.interfaces.http.deps import client_meta, require_platform_owner
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/admin",
|
||||
tags=["admin"],
|
||||
# Deny-by-default for the whole console. This is a router-level dependency on
|
||||
# purpose: an endpoint added later is protected without its author having to
|
||||
# remember. Relying on per-endpoint checks alone already caused a real
|
||||
# privilege-escalation bug — every GET here once shipped without the check,
|
||||
# letting any authenticated customer list all providers and every
|
||||
# organization on the platform.
|
||||
#
|
||||
# Endpoints that also declare `owner: User = Depends(require_platform_owner)`
|
||||
# do so because they *use* the object (for the audit actor); FastAPI caches
|
||||
# the dependency within a request, so it is not evaluated twice.
|
||||
dependencies=[Depends(require_platform_owner)],
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ProviderOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
name: str
|
||||
type: str
|
||||
base_url: str
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class CreateProviderRequest(BaseModel):
|
||||
name: str = Field(min_length=2, max_length=120)
|
||||
type: ProviderType = ProviderType.MOCK
|
||||
base_url: str = Field(default="", max_length=512)
|
||||
|
||||
|
||||
class CredentialOut(BaseModel):
|
||||
"""Deliberately has no field capable of holding the plaintext key."""
|
||||
|
||||
id: str
|
||||
provider_id: str
|
||||
name: str
|
||||
masked_key: str
|
||||
is_active: bool
|
||||
|
||||
|
||||
class CreateCredentialRequest(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=120)
|
||||
api_key: str = Field(min_length=4, max_length=512)
|
||||
|
||||
|
||||
class ModelOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
provider_id: str
|
||||
internal_name: str
|
||||
display_name_for_admin: str
|
||||
capabilities: list[str]
|
||||
input_price_per_1m: float
|
||||
output_price_per_1m: float
|
||||
is_active: bool
|
||||
|
||||
|
||||
class CreateModelRequest(BaseModel):
|
||||
provider_id: str
|
||||
internal_name: str = Field(min_length=1, max_length=160)
|
||||
display_name_for_admin: str = Field(min_length=1, max_length=160)
|
||||
capabilities: list[Capability] = Field(default_factory=list)
|
||||
input_price_per_1m: float = Field(default=0.0, ge=0)
|
||||
output_price_per_1m: float = Field(default=0.0, ge=0)
|
||||
|
||||
|
||||
class RoutingPolicyOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
name: str
|
||||
description: str
|
||||
mode: str
|
||||
is_default: bool
|
||||
is_active: bool
|
||||
|
||||
|
||||
class CreateRoutingPolicyRequest(BaseModel):
|
||||
name: str = Field(min_length=2, max_length=120)
|
||||
mode: RoutingMode = RoutingMode.BALANCED
|
||||
description: str = Field(default="", max_length=500)
|
||||
is_default: bool = False
|
||||
budget_limits_json: dict = Field(default_factory=dict)
|
||||
fallback_policy_json: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
class RateCardOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
name: str
|
||||
unit: str
|
||||
customer_price: float
|
||||
internal_cost_estimate: float
|
||||
is_active: bool
|
||||
|
||||
|
||||
class UpsertRateCardRequest(BaseModel):
|
||||
unit: RateUnit
|
||||
name: str = Field(default="", max_length=120)
|
||||
customer_price: float = Field(ge=0)
|
||||
internal_cost_estimate: float = Field(default=0.0, ge=0)
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class AdminOrganizationOut(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
slug: str
|
||||
member_count: int
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class AdminUsageRow(BaseModel):
|
||||
organization_id: str
|
||||
quantity: float
|
||||
total_price: float
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Providers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/providers", response_model=list[ProviderOut])
|
||||
async def list_providers(session: AsyncSession = Depends(get_session)) -> list[ProviderOut]:
|
||||
providers = await ProviderRepository(session).list()
|
||||
return [
|
||||
ProviderOut(
|
||||
id=p.id, name=p.name, type=p.type.value, base_url=p.base_url,
|
||||
is_active=p.is_active, created_at=p.created_at,
|
||||
)
|
||||
for p in providers
|
||||
]
|
||||
|
||||
|
||||
@router.post("/providers", response_model=ProviderOut, status_code=status.HTTP_201_CREATED)
|
||||
async def create_provider(
|
||||
payload: CreateProviderRequest,
|
||||
request: Request,
|
||||
owner: User = Depends(require_platform_owner),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ProviderOut:
|
||||
repo = ProviderRepository(session)
|
||||
if await repo.get_by_name(payload.name) is not None:
|
||||
raise ConflictError("ارائهدهندهای با این نام وجود دارد.")
|
||||
provider = await repo.create(
|
||||
Provider(name=payload.name, type=payload.type, base_url=payload.base_url)
|
||||
)
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session, action=AuditAction.PROVIDER_CHANGE, actor_user_id=owner.id,
|
||||
target_type="provider", target_id=provider.id,
|
||||
metadata={"name": provider.name, "type": provider.type.value},
|
||||
ip=ip, user_agent=ua,
|
||||
)
|
||||
return ProviderOut(
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/providers/{provider_id}/credentials",
|
||||
response_model=CredentialOut,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_credential(
|
||||
provider_id: str,
|
||||
payload: CreateCredentialRequest,
|
||||
request: Request,
|
||||
owner: User = Depends(require_platform_owner),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> CredentialOut:
|
||||
provider = await ProviderRepository(session).get(provider_id)
|
||||
if provider is None:
|
||||
raise NotFoundError("ارائهدهنده یافت نشد.")
|
||||
|
||||
credential = await ModelCredentialRepository(session).create(
|
||||
ModelCredential(
|
||||
provider_id=provider_id,
|
||||
name=payload.name,
|
||||
# Encrypted at rest; the plaintext exists only inside this request.
|
||||
encrypted_api_key=encrypt(payload.api_key),
|
||||
)
|
||||
)
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session, action=AuditAction.PROVIDER_CHANGE, actor_user_id=owner.id,
|
||||
target_type="credential", target_id=credential.id,
|
||||
metadata={"provider_id": provider_id, "name": credential.name},
|
||||
ip=ip, user_agent=ua,
|
||||
)
|
||||
return CredentialOut(
|
||||
id=credential.id, provider_id=credential.provider_id, name=credential.name,
|
||||
masked_key=mask(payload.api_key), is_active=credential.is_active,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/providers/{provider_id}/credentials", response_model=list[CredentialOut])
|
||||
async def list_credentials(
|
||||
provider_id: str, session: AsyncSession = Depends(get_session)
|
||||
) -> list[CredentialOut]:
|
||||
credentials = await ModelCredentialRepository(session).list_for_provider(provider_id)
|
||||
return [
|
||||
CredentialOut(
|
||||
id=c.id, provider_id=c.provider_id, name=c.name,
|
||||
# We cannot recover the plaintext to mask it — and we must not try.
|
||||
masked_key="••••••",
|
||||
is_active=c.is_active,
|
||||
)
|
||||
for c in credentials
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/models", response_model=list[ModelOut])
|
||||
async def list_models(
|
||||
provider_id: str | None = Query(default=None),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[ModelOut]:
|
||||
models = await AiModelRepository(session).list(provider_id=provider_id)
|
||||
return [
|
||||
ModelOut(
|
||||
id=m.id, provider_id=m.provider_id, internal_name=m.internal_name,
|
||||
display_name_for_admin=m.display_name_for_admin,
|
||||
capabilities=list(m.capabilities),
|
||||
input_price_per_1m=m.input_price_per_1m,
|
||||
output_price_per_1m=m.output_price_per_1m, is_active=m.is_active,
|
||||
)
|
||||
for m in models
|
||||
]
|
||||
|
||||
|
||||
@router.post("/models", response_model=ModelOut, status_code=status.HTTP_201_CREATED)
|
||||
async def create_model(
|
||||
payload: CreateModelRequest,
|
||||
request: Request,
|
||||
owner: User = Depends(require_platform_owner),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ModelOut:
|
||||
if await ProviderRepository(session).get(payload.provider_id) is None:
|
||||
raise NotFoundError("ارائهدهنده یافت نشد.")
|
||||
|
||||
model = await AiModelRepository(session).create(
|
||||
AiModel(
|
||||
provider_id=payload.provider_id,
|
||||
internal_name=payload.internal_name,
|
||||
display_name_for_admin=payload.display_name_for_admin,
|
||||
capabilities=[str(c) for c in payload.capabilities],
|
||||
input_price_per_1m=payload.input_price_per_1m,
|
||||
output_price_per_1m=payload.output_price_per_1m,
|
||||
)
|
||||
)
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session, action=AuditAction.MODEL_CHANGE, actor_user_id=owner.id,
|
||||
target_type="model", target_id=model.id,
|
||||
metadata={"internal_name": model.internal_name},
|
||||
ip=ip, user_agent=ua,
|
||||
)
|
||||
return ModelOut(
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routing policies
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/routing-policies", response_model=list[RoutingPolicyOut])
|
||||
async def list_routing_policies(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[RoutingPolicyOut]:
|
||||
policies = await RoutingPolicyRepository(session).list()
|
||||
return [
|
||||
RoutingPolicyOut(
|
||||
id=p.id, name=p.name, description=p.description, mode=p.mode.value,
|
||||
is_default=p.is_default, is_active=p.is_active,
|
||||
)
|
||||
for p in policies
|
||||
]
|
||||
|
||||
|
||||
@router.post(
|
||||
"/routing-policies", response_model=RoutingPolicyOut, status_code=status.HTTP_201_CREATED
|
||||
)
|
||||
async def create_routing_policy(
|
||||
payload: CreateRoutingPolicyRequest,
|
||||
request: Request,
|
||||
owner: User = Depends(require_platform_owner),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> RoutingPolicyOut:
|
||||
policy = await RoutingPolicyRepository(session).create(
|
||||
RoutingPolicy(
|
||||
name=payload.name, mode=payload.mode, description=payload.description,
|
||||
is_default=payload.is_default,
|
||||
budget_limits_json=payload.budget_limits_json,
|
||||
fallback_policy_json=payload.fallback_policy_json,
|
||||
)
|
||||
)
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session, action=AuditAction.ROUTING_CHANGE, actor_user_id=owner.id,
|
||||
target_type="routing_policy", target_id=policy.id,
|
||||
metadata={"name": policy.name, "mode": policy.mode.value},
|
||||
ip=ip, user_agent=ua,
|
||||
)
|
||||
return RoutingPolicyOut(
|
||||
id=policy.id, name=policy.name, description=policy.description,
|
||||
mode=policy.mode.value, is_default=policy.is_default, is_active=policy.is_active,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rate cards
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/rate-cards", response_model=list[RateCardOut])
|
||||
async def list_rate_cards(session: AsyncSession = Depends(get_session)) -> list[RateCardOut]:
|
||||
cards = await RateCardRepository(session).list()
|
||||
return [
|
||||
RateCardOut(
|
||||
id=c.id, name=c.name, unit=c.unit.value, customer_price=c.customer_price,
|
||||
internal_cost_estimate=c.internal_cost_estimate, is_active=c.is_active,
|
||||
)
|
||||
for c in cards
|
||||
]
|
||||
|
||||
|
||||
@router.put("/rate-cards", response_model=RateCardOut)
|
||||
async def upsert_rate_card(
|
||||
payload: UpsertRateCardRequest,
|
||||
request: Request,
|
||||
owner: User = Depends(require_platform_owner),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> RateCardOut:
|
||||
card = await RateCardRepository(session).upsert(
|
||||
RateCard(
|
||||
name=payload.name or payload.unit.value.replace("_", " "),
|
||||
unit=payload.unit,
|
||||
customer_price=payload.customer_price,
|
||||
internal_cost_estimate=payload.internal_cost_estimate,
|
||||
is_active=payload.is_active,
|
||||
)
|
||||
)
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session, action=AuditAction.BILLING_CREDIT, actor_user_id=owner.id,
|
||||
target_type="rate_card", target_id=card.id,
|
||||
metadata={"unit": card.unit.value, "customer_price": card.customer_price},
|
||||
ip=ip, user_agent=ua,
|
||||
)
|
||||
return RateCardOut(
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cross-organization visibility
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/organizations", response_model=list[AdminOrganizationOut])
|
||||
async def list_all_organizations(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[AdminOrganizationOut]:
|
||||
organizations = await OrganizationRepository(session).list_all()
|
||||
memberships = MembershipRepository(session)
|
||||
out: list[AdminOrganizationOut] = []
|
||||
for org in organizations:
|
||||
members = await memberships.list_for_org(org.id)
|
||||
out.append(
|
||||
AdminOrganizationOut(
|
||||
id=org.id, name=org.name, slug=org.slug,
|
||||
member_count=len(members), created_at=org.created_at,
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/usage", response_model=list[AdminUsageRow])
|
||||
async def platform_usage(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[AdminUsageRow]:
|
||||
rows = await UsageLedgerRepository(session).platform_totals()
|
||||
return [
|
||||
AdminUsageRow(
|
||||
organization_id=org_id, quantity=quantity, total_price=total_price
|
||||
)
|
||||
for org_id, quantity, total_price in rows
|
||||
]
|
||||
|
||||
|
||||
@router.post("/validate-model-choice")
|
||||
async def validate_model_choice(
|
||||
mode: RoutingMode,
|
||||
_: User = Depends(require_platform_owner),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict:
|
||||
"""Dry-run of routing: which model would this mode pick, and why?"""
|
||||
model = await AiModelRepository(session).pick_for_mode(mode)
|
||||
if model is None:
|
||||
raise ValidationError("هیچ مدل فعالی برای این حالت مسیریابی وجود ندارد.")
|
||||
return {
|
||||
"mode": mode.value,
|
||||
"model_id": model.id,
|
||||
"internal_name": model.internal_name,
|
||||
"capabilities": list(model.capabilities),
|
||||
"estimated_input_price_per_1m": model.input_price_per_1m,
|
||||
"estimated_output_price_per_1m": model.output_price_per_1m,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Error console
|
||||
#
|
||||
# Every failure the platform catches lands in ``error_events``. This is where a
|
||||
# platform owner reads them: grouped by fingerprint so one recurring bug is one
|
||||
# line, with the full record (stack, request id, tenant, actor) one click away.
|
||||
#
|
||||
# Resolution is *per fingerprint* for the group view — fixing a bug means the
|
||||
# whole group is dealt with — and per row for a single occurrence.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ErrorEventOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
kind: str
|
||||
message: str
|
||||
stack: str
|
||||
severity: str
|
||||
source: str
|
||||
error_type: str
|
||||
fingerprint: str
|
||||
status_code: int | None
|
||||
method: str
|
||||
path: str
|
||||
organization_id: str | None
|
||||
user_id: str | None
|
||||
run_id: str | None
|
||||
request_id: str
|
||||
ip: str
|
||||
user_agent: str
|
||||
metadata_json: dict
|
||||
resolved_at: datetime | None
|
||||
resolved_note: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class ErrorGroupOut(BaseModel):
|
||||
fingerprint: str
|
||||
occurrences: int
|
||||
open_count: int
|
||||
first_seen: datetime
|
||||
last_seen: datetime
|
||||
kind: str
|
||||
error_type: str
|
||||
severity: str
|
||||
source: str
|
||||
path: str
|
||||
sample_message: str
|
||||
|
||||
|
||||
class ErrorSummaryOut(BaseModel):
|
||||
open_by_severity: dict[str, int]
|
||||
total_open: int
|
||||
|
||||
|
||||
class ErrorListOut(BaseModel):
|
||||
items: list[ErrorEventOut]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
|
||||
class ResolveErrorIn(BaseModel):
|
||||
note: str = Field(default="", max_length=2000)
|
||||
|
||||
|
||||
class ResolveErrorOut(BaseModel):
|
||||
fingerprint: str
|
||||
resolved: int
|
||||
|
||||
|
||||
#: Newest-first page size ceiling. An unbounded ``limit`` would let one request
|
||||
#: pull the entire table — including every stack trace — into memory.
|
||||
MAX_ERROR_PAGE_SIZE = 200
|
||||
|
||||
|
||||
@router.get("/errors", response_model=ErrorListOut)
|
||||
async def list_errors(
|
||||
severity: str | None = Query(default=None),
|
||||
source: str | None = Query(default=None),
|
||||
status_filter: str | None = Query(default=None, alias="status"),
|
||||
kind: str | None = Query(default=None),
|
||||
organization_id: str | None = Query(default=None),
|
||||
fingerprint: str | None = Query(default=None),
|
||||
search: str | None = Query(default=None, max_length=200),
|
||||
since: datetime | None = Query(default=None),
|
||||
limit: int = Query(default=50, ge=1, le=MAX_ERROR_PAGE_SIZE),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ErrorListOut:
|
||||
"""Individual occurrences, newest first."""
|
||||
repo = ObservabilityRepository(session)
|
||||
filters = {
|
||||
"severity": severity,
|
||||
"source": source,
|
||||
"status": status_filter,
|
||||
"kind": kind,
|
||||
"organization_id": organization_id,
|
||||
"fingerprint": fingerprint,
|
||||
"search": search,
|
||||
"since": since,
|
||||
}
|
||||
items = await repo.list_errors(limit=limit, offset=offset, **filters)
|
||||
total = await repo.count_errors(**filters)
|
||||
return ErrorListOut(
|
||||
items=[ErrorEventOut.model_validate(item) for item in items],
|
||||
total=total,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/errors/summary", response_model=ErrorSummaryOut)
|
||||
async def error_summary(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ErrorSummaryOut:
|
||||
"""Unresolved counts, for the console's header tiles."""
|
||||
by_severity = await ObservabilityRepository(session).count_open_by_severity()
|
||||
return ErrorSummaryOut(
|
||||
open_by_severity=by_severity, total_open=sum(by_severity.values())
|
||||
)
|
||||
|
||||
|
||||
# Declared before ``/errors/{error_id}`` on purpose: FastAPI matches routes in
|
||||
# declaration order, so the other way round "groups" would be captured as an id.
|
||||
@router.get("/errors/groups", response_model=list[ErrorGroupOut])
|
||||
async def list_error_groups(
|
||||
status_filter: str | None = Query(default=None, alias="status"),
|
||||
severity: str | None = Query(default=None),
|
||||
source: str | None = Query(default=None),
|
||||
organization_id: str | None = Query(default=None),
|
||||
since: datetime | None = Query(default=None),
|
||||
limit: int = Query(default=50, ge=1, le=MAX_ERROR_PAGE_SIZE),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[ErrorGroupOut]:
|
||||
"""One row per distinct bug, most recently active first."""
|
||||
groups = await ObservabilityRepository(session).list_error_groups(
|
||||
limit=limit,
|
||||
since=since,
|
||||
status=status_filter,
|
||||
severity=severity,
|
||||
source=source,
|
||||
organization_id=organization_id,
|
||||
)
|
||||
return [ErrorGroupOut(**group) for group in groups]
|
||||
|
||||
|
||||
@router.get("/errors/{error_id}", response_model=ErrorEventOut)
|
||||
async def get_error(
|
||||
error_id: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ErrorEventOut:
|
||||
event = await ObservabilityRepository(session).get_error(error_id)
|
||||
if event is None:
|
||||
raise NotFoundError("خطای مورد نظر یافت نشد.")
|
||||
return ErrorEventOut.model_validate(event)
|
||||
|
||||
|
||||
@router.post("/errors/{error_id}/resolve", response_model=ResolveErrorOut)
|
||||
async def resolve_error(
|
||||
error_id: str,
|
||||
payload: ResolveErrorIn,
|
||||
request: Request,
|
||||
owner: User = Depends(require_platform_owner),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ResolveErrorOut:
|
||||
"""Mark one occurrence as dealt with."""
|
||||
repo = ObservabilityRepository(session)
|
||||
event = await repo.resolve_error(error_id, note=payload.note, resolved_at=utcnow())
|
||||
if event is None:
|
||||
raise NotFoundError("خطای مورد نظر یافت نشد.")
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session,
|
||||
action=AuditAction.ERROR_RESOLVE,
|
||||
actor_user_id=owner.id,
|
||||
target_type="error_event",
|
||||
target_id=error_id,
|
||||
metadata={"fingerprint": event.fingerprint, "note": payload.note[:200]},
|
||||
ip=ip,
|
||||
user_agent=ua,
|
||||
)
|
||||
return ResolveErrorOut(fingerprint=event.fingerprint, resolved=1)
|
||||
|
||||
|
||||
@router.post("/errors/groups/{fingerprint}/resolve", response_model=ResolveErrorOut)
|
||||
async def resolve_error_group(
|
||||
fingerprint: str,
|
||||
payload: ResolveErrorIn,
|
||||
request: Request,
|
||||
owner: User = Depends(require_platform_owner),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ResolveErrorOut:
|
||||
"""Mark every open occurrence of one bug as dealt with."""
|
||||
repo = ObservabilityRepository(session)
|
||||
resolved = await repo.resolve_error_group(
|
||||
fingerprint, note=payload.note, resolved_at=utcnow()
|
||||
)
|
||||
if resolved == 0:
|
||||
raise NotFoundError("موردی برای بستن پیدا نشد؛ ممکن است قبلاً بسته شده باشد.")
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session,
|
||||
action=AuditAction.ERROR_RESOLVE,
|
||||
actor_user_id=owner.id,
|
||||
target_type="error_group",
|
||||
target_id=fingerprint,
|
||||
metadata={"resolved": resolved, "note": payload.note[:200]},
|
||||
ip=ip,
|
||||
user_agent=ua,
|
||||
)
|
||||
return ResolveErrorOut(fingerprint=fingerprint, resolved=resolved)
|
||||
|
||||
|
||||
@router.post("/errors/groups/{fingerprint}/reopen", response_model=ResolveErrorOut)
|
||||
async def reopen_error_group(
|
||||
fingerprint: str,
|
||||
_: User = Depends(require_platform_owner),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ResolveErrorOut:
|
||||
"""Undo a resolution — for when the bug comes back."""
|
||||
resolved = await ObservabilityRepository(session).reopen_error_group(fingerprint)
|
||||
return ResolveErrorOut(fingerprint=fingerprint, resolved=resolved)
|
||||
@@ -0,0 +1,182 @@
|
||||
"""Authentication endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, Request, status
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.application.services.audit_service import record_audit
|
||||
from app.core.database import get_session
|
||||
from app.core.errors import AuthenticationError, ConflictError
|
||||
from app.core.security import create_access_token, hash_password, verify_password
|
||||
from app.domain.audit.models import AuditAction
|
||||
from app.domain.identity.models import User
|
||||
from app.infrastructure.repositories.identity import (
|
||||
MembershipRepository,
|
||||
OrganizationRepository,
|
||||
UserRepository,
|
||||
)
|
||||
from app.interfaces.http.deps import client_meta, get_current_user
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
class SignupRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str = Field(min_length=8, max_length=128)
|
||||
name: str = Field(default="", max_length=160)
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
|
||||
class UserOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
email: str
|
||||
name: str
|
||||
avatar_url: str | None = None
|
||||
is_platform_owner: bool = False
|
||||
|
||||
|
||||
class OrganizationSummary(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
slug: str
|
||||
role: str
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
user: UserOut
|
||||
|
||||
|
||||
class MeResponse(BaseModel):
|
||||
user: UserOut
|
||||
organizations: list[OrganizationSummary]
|
||||
|
||||
|
||||
def _to_user_out(user: User) -> UserOut:
|
||||
return UserOut(
|
||||
id=user.id,
|
||||
email=user.email,
|
||||
name=user.display_name,
|
||||
avatar_url=user.avatar_url,
|
||||
is_platform_owner=user.is_platform_owner,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/signup", response_model=TokenResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def signup(
|
||||
payload: SignupRequest,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> TokenResponse:
|
||||
users = UserRepository(session)
|
||||
if await users.get_by_email(payload.email) is not None:
|
||||
raise ConflictError("این ایمیل قبلاً ثبت شده است.")
|
||||
|
||||
user = await users.create(
|
||||
User(
|
||||
email=payload.email,
|
||||
password_hash=hash_password(payload.password),
|
||||
name=payload.name.strip(),
|
||||
)
|
||||
)
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session,
|
||||
action=AuditAction.SIGNUP,
|
||||
actor_user_id=user.id,
|
||||
target_type="user",
|
||||
target_id=user.id,
|
||||
ip=ip,
|
||||
user_agent=ua,
|
||||
)
|
||||
return TokenResponse(access_token=create_access_token(user.id), user=_to_user_out(user))
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
async def login(
|
||||
payload: LoginRequest,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> TokenResponse:
|
||||
user = await UserRepository(session).get_by_email(payload.email)
|
||||
if user is None or not verify_password(payload.password, user.password_hash):
|
||||
raise AuthenticationError("ایمیل یا رمز عبور نادرست است.")
|
||||
if not user.is_active:
|
||||
raise AuthenticationError("حساب کاربری غیرفعال است.")
|
||||
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session,
|
||||
action=AuditAction.LOGIN,
|
||||
actor_user_id=user.id,
|
||||
target_type="user",
|
||||
target_id=user.id,
|
||||
ip=ip,
|
||||
user_agent=ua,
|
||||
)
|
||||
return TokenResponse(access_token=create_access_token(user.id), user=_to_user_out(user))
|
||||
|
||||
|
||||
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def logout(
|
||||
request: Request,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> None:
|
||||
"""JWTs are stateless; logout is an audit marker today and a token-revocation
|
||||
hook once a denylist (Redis) is wired in."""
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session,
|
||||
action=AuditAction.LOGOUT,
|
||||
actor_user_id=user.id,
|
||||
target_type="user",
|
||||
target_id=user.id,
|
||||
ip=ip,
|
||||
user_agent=ua,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me", response_model=MeResponse)
|
||||
async def me(
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> MeResponse:
|
||||
orgs = await OrganizationRepository(session).list_for_user(user.id)
|
||||
return MeResponse(
|
||||
user=_to_user_out(user),
|
||||
organizations=[
|
||||
OrganizationSummary(id=org.id, name=org.name, slug=org.slug, role=role.value)
|
||||
for org, role in orgs
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me/memberships")
|
||||
async def my_memberships(
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[dict]:
|
||||
orgs = await OrganizationRepository(session).list_for_user(user.id)
|
||||
memberships = MembershipRepository(session)
|
||||
out: list[dict] = []
|
||||
for org, role in orgs:
|
||||
membership = await memberships.get(org.id, user.id)
|
||||
out.append(
|
||||
{
|
||||
"organization_id": org.id,
|
||||
"organization_name": org.name,
|
||||
"role": role.value,
|
||||
"status": membership.status.value if membership else "unknown",
|
||||
}
|
||||
)
|
||||
return out
|
||||
@@ -0,0 +1,294 @@
|
||||
"""Billing endpoints: summary, usage ledger, invoices, credit and checkout.
|
||||
|
||||
Reading billing requires ``billing.view``; changing it requires
|
||||
``billing.manage``. A tenant can therefore never see — or move — another
|
||||
organization's money.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request, status
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.application.services.audit_service import record_audit
|
||||
from app.application.services.billing_service import BillingService
|
||||
from app.core.database import get_session
|
||||
from app.domain.audit.models import AuditAction
|
||||
from app.domain.identity.models import CAN_MANAGE_BILLING, CAN_VIEW_BILLING
|
||||
from app.interfaces.http.deps import (
|
||||
OrgContext,
|
||||
client_meta,
|
||||
get_billing_service,
|
||||
get_org_context,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/organizations/{org_id}/billing", tags=["billing"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class UnitAggregateOut(BaseModel):
|
||||
unit: str
|
||||
quantity: float
|
||||
total_price: float
|
||||
|
||||
|
||||
class BillingSummaryOut(BaseModel):
|
||||
balance: float
|
||||
currency: str
|
||||
low_balance: bool
|
||||
low_balance_threshold: float
|
||||
total_spend: float
|
||||
by_unit: list[UnitAggregateOut]
|
||||
by_employee: dict[str, float]
|
||||
by_project: dict[str, float]
|
||||
|
||||
|
||||
class UsageEventOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
unit: str
|
||||
quantity: float
|
||||
unit_price: float
|
||||
total_price: float
|
||||
project_id: str | None
|
||||
employee_id: str | None
|
||||
run_id: str | None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class InvoiceOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
amount: float
|
||||
currency: str
|
||||
status: str
|
||||
period_start: datetime | None
|
||||
period_end: datetime | None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class PaymentOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
amount: float
|
||||
currency: str
|
||||
status: str
|
||||
provider: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class AddCreditRequest(BaseModel):
|
||||
amount: float = Field(description="Positive to top up; negative is platform-owner only.")
|
||||
|
||||
|
||||
class WalletOut(BaseModel):
|
||||
balance: float
|
||||
currency: str
|
||||
low_balance: bool
|
||||
|
||||
|
||||
class CheckoutRequest(BaseModel):
|
||||
amount: float = Field(gt=0)
|
||||
success_url: str = Field(default="", max_length=512)
|
||||
cancel_url: str = Field(default="", max_length=512)
|
||||
|
||||
|
||||
class CheckoutResponse(BaseModel):
|
||||
payment_id: str
|
||||
checkout_url: str
|
||||
amount: float
|
||||
|
||||
|
||||
class SettlePaymentRequest(BaseModel):
|
||||
succeeded: bool = True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reads
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/summary", response_model=BillingSummaryOut)
|
||||
async def billing_summary(
|
||||
since: datetime | None = Query(default=None),
|
||||
until: datetime | None = Query(default=None),
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: BillingService = Depends(get_billing_service),
|
||||
) -> BillingSummaryOut:
|
||||
context.require(CAN_VIEW_BILLING)
|
||||
summary = await service.summary(
|
||||
organization_id=context.org_id, since=since, until=until
|
||||
)
|
||||
return BillingSummaryOut(
|
||||
balance=summary["balance"],
|
||||
currency=summary["currency"],
|
||||
low_balance=summary["low_balance"],
|
||||
low_balance_threshold=summary["low_balance_threshold"],
|
||||
total_spend=summary["total_spend"],
|
||||
by_unit=[
|
||||
UnitAggregateOut(
|
||||
unit=str(a.unit), quantity=a.quantity, total_price=a.total_price
|
||||
)
|
||||
for a in summary["by_unit"]
|
||||
],
|
||||
by_employee=summary["by_employee"],
|
||||
by_project=summary["by_project"],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/usage-events", response_model=list[UsageEventOut])
|
||||
async def list_usage_events(
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: BillingService = Depends(get_billing_service),
|
||||
) -> list[UsageEventOut]:
|
||||
context.require(CAN_VIEW_BILLING)
|
||||
rows = await service.list_usage_events(
|
||||
organization_id=context.org_id, limit=limit, offset=offset
|
||||
)
|
||||
return [
|
||||
UsageEventOut(
|
||||
id=r.id, unit=r.unit, quantity=r.quantity, unit_price=r.unit_price,
|
||||
total_price=r.total_price, project_id=r.project_id,
|
||||
employee_id=r.employee_id, run_id=r.run_id, created_at=r.created_at,
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
@router.get("/invoices", response_model=list[InvoiceOut])
|
||||
async def list_invoices(
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: BillingService = Depends(get_billing_service),
|
||||
) -> list[InvoiceOut]:
|
||||
context.require(CAN_VIEW_BILLING)
|
||||
invoices = await service.list_invoices(context.org_id)
|
||||
return [
|
||||
InvoiceOut(
|
||||
id=i.id, amount=i.amount, currency=i.currency, status=str(i.status),
|
||||
period_start=i.period_start, period_end=i.period_end, created_at=i.created_at,
|
||||
)
|
||||
for i in invoices
|
||||
]
|
||||
|
||||
|
||||
@router.get("/payments", response_model=list[PaymentOut])
|
||||
async def list_payments(
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: BillingService = Depends(get_billing_service),
|
||||
) -> list[PaymentOut]:
|
||||
context.require(CAN_VIEW_BILLING)
|
||||
wallet = await service.wallet_for(context.org_id)
|
||||
payments = await service.list_payments(context.org_id)
|
||||
return [
|
||||
PaymentOut(
|
||||
id=p.id, amount=p.amount, currency=wallet.currency, status=str(p.status),
|
||||
provider=p.provider, created_at=p.created_at,
|
||||
)
|
||||
for p in payments
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Writes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/add-credit", response_model=WalletOut)
|
||||
async def add_credit(
|
||||
payload: AddCreditRequest,
|
||||
request: Request,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: BillingService = Depends(get_billing_service),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> WalletOut:
|
||||
# A negative delta is a platform-owner-only clawback.
|
||||
if payload.amount < 0:
|
||||
if not context.user.is_platform_owner:
|
||||
context.require(CAN_MANAGE_BILLING) # will raise PermissionDeniedError
|
||||
from app.core.errors import PermissionDeniedError
|
||||
|
||||
raise PermissionDeniedError("کسر اعتبار فقط توسط مالک سامانه انجام میشود.")
|
||||
else:
|
||||
context.require(CAN_MANAGE_BILLING)
|
||||
|
||||
wallet = await service.add_credit(
|
||||
organization_id=context.org_id,
|
||||
amount=payload.amount,
|
||||
allow_negative=context.user.is_platform_owner,
|
||||
)
|
||||
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session, action=AuditAction.BILLING_CREDIT, organization_id=context.org_id,
|
||||
actor_user_id=context.user.id, target_type="credit_wallet",
|
||||
target_id=wallet.id,
|
||||
metadata={"amount": payload.amount, "balance": wallet.balance},
|
||||
ip=ip, user_agent=ua,
|
||||
)
|
||||
return WalletOut(
|
||||
balance=wallet.balance, currency=wallet.currency, low_balance=wallet.is_low
|
||||
)
|
||||
|
||||
|
||||
@router.post("/checkout", response_model=CheckoutResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def start_checkout(
|
||||
payload: CheckoutRequest,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: BillingService = Depends(get_billing_service),
|
||||
) -> CheckoutResponse:
|
||||
context.require(CAN_MANAGE_BILLING)
|
||||
payment, url = await service.start_checkout(
|
||||
organization_id=context.org_id,
|
||||
amount=payload.amount,
|
||||
success_url=payload.success_url,
|
||||
cancel_url=payload.cancel_url,
|
||||
)
|
||||
return CheckoutResponse(
|
||||
payment_id=payment.id, checkout_url=url, amount=payment.amount
|
||||
)
|
||||
|
||||
|
||||
@router.post("/payments/{payment_id}/settle", response_model=PaymentOut)
|
||||
async def settle_payment(
|
||||
payment_id: str,
|
||||
payload: SettlePaymentRequest,
|
||||
request: Request,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: BillingService = Depends(get_billing_service),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> PaymentOut:
|
||||
"""Finalise a payment.
|
||||
|
||||
In production the payment provider calls a webhook; this endpoint is the
|
||||
manual/dev equivalent. It is the only path that converts money into credit.
|
||||
"""
|
||||
context.require(CAN_MANAGE_BILLING)
|
||||
payment = await service.settle_payment(
|
||||
organization_id=context.org_id, payment_id=payment_id, succeeded=payload.succeeded
|
||||
)
|
||||
wallet = await service.wallet_for(context.org_id)
|
||||
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session, action=AuditAction.BILLING_PAYMENT, organization_id=context.org_id,
|
||||
actor_user_id=context.user.id, target_type="payment", target_id=payment.id,
|
||||
metadata={"status": str(payment.status), "amount": payment.amount},
|
||||
ip=ip, user_agent=ua,
|
||||
)
|
||||
return PaymentOut(
|
||||
id=payment.id, amount=payment.amount, currency=wallet.currency,
|
||||
status=str(payment.status), provider=payment.provider,
|
||||
created_at=payment.created_at,
|
||||
)
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Health and readiness endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import get_session
|
||||
from app.infrastructure.adapters.registry import get_model_provider
|
||||
|
||||
router = APIRouter(tags=["health"])
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health() -> dict:
|
||||
"""Liveness. Must stay dependency-free so it answers during a DB outage."""
|
||||
return {"status": "ok", "app": settings.app_name, "env": settings.app_env}
|
||||
|
||||
|
||||
@router.get("/health/ready")
|
||||
async def readiness(session: AsyncSession = Depends(get_session)) -> dict:
|
||||
"""Readiness: verifies the database and reports which adapters are active."""
|
||||
checks: dict[str, object] = {}
|
||||
try:
|
||||
await session.execute(text("SELECT 1"))
|
||||
checks["database"] = "ok"
|
||||
except Exception as exc: # noqa: BLE001 - reported, not raised
|
||||
checks["database"] = f"error: {exc.__class__.__name__}"
|
||||
|
||||
try:
|
||||
model_health = await get_model_provider().health()
|
||||
checks["model_provider"] = "ok" if model_health.ok else f"degraded: {model_health.detail}"
|
||||
except Exception as exc: # noqa: BLE001
|
||||
checks["model_provider"] = f"error: {exc.__class__.__name__}"
|
||||
|
||||
checks["adapters"] = {
|
||||
"ai": settings.ai_default_provider,
|
||||
"git": settings.git_provider,
|
||||
"domain_router": settings.domain_router,
|
||||
"sandbox": settings.sandbox_provider,
|
||||
"payment": settings.payment_provider,
|
||||
}
|
||||
healthy = checks.get("database") == "ok"
|
||||
return {"status": "ok" if healthy else "degraded", "checks": checks}
|
||||
@@ -0,0 +1,251 @@
|
||||
"""Knowledge Base endpoints.
|
||||
|
||||
Ingestion and deletion require ``employees.manage`` (the same trust level as
|
||||
editing an employee's prompt); search only requires ``tasks.run``, because
|
||||
reading your own organization's knowledge is part of doing work.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.application.services.audit_service import record_audit
|
||||
from app.application.services.knowledge_service import KnowledgeService
|
||||
from app.core.database import get_session
|
||||
from app.domain.identity.models import CAN_MANAGE_EMPLOYEES, CAN_RUN_TASKS
|
||||
from app.interfaces.http.deps import (
|
||||
OrgContext,
|
||||
client_meta,
|
||||
get_knowledge_service,
|
||||
get_org_context,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/organizations/{org_id}/knowledge", tags=["knowledge"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class KnowledgeSourceOut(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
kind: str
|
||||
project_id: str | None
|
||||
created_at: datetime
|
||||
chunk_count: int = 0
|
||||
|
||||
|
||||
class CreateTextSourceRequest(BaseModel):
|
||||
name: str = Field(min_length=2, max_length=255)
|
||||
content: str = Field(min_length=1, max_length=400_000)
|
||||
title: str = Field(default="", max_length=255)
|
||||
project_id: str | None = None
|
||||
|
||||
|
||||
class DocumentOut(BaseModel):
|
||||
id: str
|
||||
title: str
|
||||
content: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class EmbeddingJobOut(BaseModel):
|
||||
id: str
|
||||
status: str
|
||||
error: str | None
|
||||
|
||||
|
||||
class KnowledgeSourceDetailOut(KnowledgeSourceOut):
|
||||
documents: list[DocumentOut]
|
||||
job: EmbeddingJobOut | None
|
||||
|
||||
|
||||
class SearchHitOut(BaseModel):
|
||||
chunk_id: str
|
||||
content: str
|
||||
index: int
|
||||
document_title: str
|
||||
source_id: str
|
||||
source_name: str
|
||||
project_id: str | None
|
||||
|
||||
|
||||
class CreateRetrievalPolicyRequest(BaseModel):
|
||||
name: str = Field(min_length=2, max_length=120)
|
||||
scope: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
class RetrievalPolicyOut(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
scope_json: dict
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sources
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post(
|
||||
"/sources", response_model=KnowledgeSourceOut, status_code=status.HTTP_201_CREATED
|
||||
)
|
||||
async def create_text_source(
|
||||
payload: CreateTextSourceRequest,
|
||||
request: Request,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: KnowledgeService = Depends(get_knowledge_service),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> KnowledgeSourceOut:
|
||||
context.require(CAN_MANAGE_EMPLOYEES)
|
||||
|
||||
source, chunk_count = await service.create_text_source(
|
||||
organization_id=context.org_id,
|
||||
name=payload.name,
|
||||
content=payload.content,
|
||||
project_id=payload.project_id,
|
||||
title=payload.title,
|
||||
)
|
||||
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session, action="knowledge.source_create", organization_id=context.org_id,
|
||||
actor_user_id=context.user.id, target_type="knowledge_source",
|
||||
target_id=source.id, metadata={"name": source.name, "chunks": chunk_count},
|
||||
ip=ip, user_agent=ua,
|
||||
)
|
||||
return KnowledgeSourceOut(
|
||||
id=source.id, name=source.name, kind=str(source.kind),
|
||||
project_id=source.project_id, created_at=source.created_at,
|
||||
chunk_count=chunk_count,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/sources", response_model=list[KnowledgeSourceOut])
|
||||
async def list_sources(
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: KnowledgeService = Depends(get_knowledge_service),
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
) -> list[KnowledgeSourceOut]:
|
||||
sources = await service.list_sources(context.org_id, limit=limit, offset=offset)
|
||||
# One aggregate query for every source's chunk count. Asking per source
|
||||
# inside the loop was an N+1: 100 sources meant 101 round trips.
|
||||
chunk_counts = await service.chunk_counts([s.id for s in sources])
|
||||
return [
|
||||
KnowledgeSourceOut(
|
||||
id=source.id, name=source.name, kind=str(source.kind),
|
||||
project_id=source.project_id, created_at=source.created_at,
|
||||
chunk_count=chunk_counts.get(source.id, 0),
|
||||
)
|
||||
for source in sources
|
||||
]
|
||||
|
||||
|
||||
@router.get("/sources/{source_id}", response_model=KnowledgeSourceDetailOut)
|
||||
async def get_source(
|
||||
source_id: str,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: KnowledgeService = Depends(get_knowledge_service),
|
||||
) -> KnowledgeSourceDetailOut:
|
||||
source = await service.get_source_or_404(
|
||||
organization_id=context.org_id, source_id=source_id
|
||||
)
|
||||
detail = await service.source_detail(source_id=source.id)
|
||||
job = detail["job"]
|
||||
return KnowledgeSourceDetailOut(
|
||||
id=source.id, name=source.name, kind=str(source.kind),
|
||||
project_id=source.project_id, created_at=source.created_at,
|
||||
chunk_count=detail["chunk_count"],
|
||||
documents=[
|
||||
DocumentOut(
|
||||
id=d.id, title=d.title, content=d.content, created_at=d.created_at
|
||||
)
|
||||
for d in detail["documents"]
|
||||
],
|
||||
job=(
|
||||
EmbeddingJobOut(id=job.id, status=str(job.status), error=job.error)
|
||||
if job is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/sources/{source_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_source(
|
||||
source_id: str,
|
||||
request: Request,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: KnowledgeService = Depends(get_knowledge_service),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> None:
|
||||
context.require(CAN_MANAGE_EMPLOYEES)
|
||||
await service.delete_source(organization_id=context.org_id, source_id=source_id)
|
||||
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session, action="knowledge.source_delete", organization_id=context.org_id,
|
||||
actor_user_id=context.user.id, target_type="knowledge_source",
|
||||
target_id=source_id, ip=ip, user_agent=ua,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Search
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/search", response_model=list[SearchHitOut])
|
||||
async def search_knowledge(
|
||||
q: str = Query(min_length=1, max_length=300),
|
||||
project_id: str | None = Query(default=None),
|
||||
limit: int = Query(default=5, ge=1, le=50),
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: KnowledgeService = Depends(get_knowledge_service),
|
||||
) -> list[SearchHitOut]:
|
||||
context.require(CAN_RUN_TASKS)
|
||||
hits = await service.search(
|
||||
organization_id=context.org_id, query=q, project_id=project_id, limit=limit
|
||||
)
|
||||
return [SearchHitOut(**hit) for hit in hits]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Retrieval policies
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/retrieval-policies", response_model=list[RetrievalPolicyOut])
|
||||
async def list_retrieval_policies(
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: KnowledgeService = Depends(get_knowledge_service),
|
||||
) -> list[RetrievalPolicyOut]:
|
||||
policies = await service.list_policies(context.org_id)
|
||||
return [
|
||||
RetrievalPolicyOut(id=p.id, name=p.name, scope_json=dict(p.scope_json))
|
||||
for p in policies
|
||||
]
|
||||
|
||||
|
||||
@router.post(
|
||||
"/retrieval-policies",
|
||||
response_model=RetrievalPolicyOut,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_retrieval_policy(
|
||||
payload: CreateRetrievalPolicyRequest,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: KnowledgeService = Depends(get_knowledge_service),
|
||||
) -> RetrievalPolicyOut:
|
||||
context.require(CAN_MANAGE_EMPLOYEES)
|
||||
policy = await service.create_policy(
|
||||
organization_id=context.org_id, name=payload.name, scope=payload.scope
|
||||
)
|
||||
return RetrievalPolicyOut(
|
||||
id=policy.id, name=policy.name, scope_json=dict(policy.scope_json)
|
||||
)
|
||||
@@ -0,0 +1,358 @@
|
||||
"""Organization, membership and invite endpoints.
|
||||
|
||||
Every route below the organization prefix resolves its tenant through
|
||||
``get_org_context``, so a caller can never read or mutate another tenant's data
|
||||
by guessing an id.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request, status
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.application.services.audit_service import record_audit
|
||||
from app.core.database import get_session
|
||||
from app.core.errors import NotFoundError, PermissionDeniedError, ValidationError
|
||||
from app.core.security import generate_token, hash_token
|
||||
from app.domain.audit.models import AuditAction
|
||||
from app.domain.base import ensure_unique_slug, slugify
|
||||
from app.domain.identity.models import (
|
||||
CAN_MANAGE_MEMBERS,
|
||||
CAN_VIEW_AUDIT,
|
||||
ROLE_RANK,
|
||||
Invite,
|
||||
Membership,
|
||||
MembershipStatus,
|
||||
Organization,
|
||||
Role,
|
||||
User,
|
||||
)
|
||||
from app.infrastructure.db.models import CreditWalletRow
|
||||
from app.infrastructure.repositories.audit import AuditRepository
|
||||
from app.infrastructure.repositories.identity import (
|
||||
InviteRepository,
|
||||
MembershipRepository,
|
||||
OrganizationRepository,
|
||||
UserRepository,
|
||||
)
|
||||
from app.interfaces.http.deps import OrgContext, client_meta, get_current_user, get_org_context
|
||||
|
||||
router = APIRouter(prefix="/organizations", tags=["organizations"])
|
||||
|
||||
INVITE_TTL_DAYS = 14
|
||||
|
||||
|
||||
class CreateOrganizationRequest(BaseModel):
|
||||
name: str = Field(min_length=2, max_length=160)
|
||||
|
||||
|
||||
class OrganizationOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
name: str
|
||||
slug: str
|
||||
owner_user_id: str
|
||||
|
||||
|
||||
class OrganizationWithRole(OrganizationOut):
|
||||
role: str
|
||||
|
||||
|
||||
class MemberOut(BaseModel):
|
||||
id: str
|
||||
user_id: str
|
||||
email: str = ""
|
||||
name: str = ""
|
||||
role: str
|
||||
status: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class InviteRequest(BaseModel):
|
||||
email: EmailStr
|
||||
role: Role = Role.MEMBER
|
||||
|
||||
|
||||
class InviteOut(BaseModel):
|
||||
id: str
|
||||
email: str
|
||||
role: str
|
||||
expires_at: datetime
|
||||
invite_token: str
|
||||
invite_url: str
|
||||
|
||||
|
||||
class UpdateMemberRequest(BaseModel):
|
||||
role: Role | None = None
|
||||
status: MembershipStatus | None = None
|
||||
|
||||
|
||||
class AuditEventOut(BaseModel):
|
||||
id: str
|
||||
action: str
|
||||
actor_user_id: str | None
|
||||
target_type: str
|
||||
target_id: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
def _org_out(org: Organization) -> OrganizationOut:
|
||||
return OrganizationOut(
|
||||
id=org.id, name=org.name, slug=org.slug, owner_user_id=org.owner_user_id
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=OrganizationWithRole, status_code=status.HTTP_201_CREATED)
|
||||
async def create_organization(
|
||||
payload: CreateOrganizationRequest,
|
||||
request: Request,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> OrganizationWithRole:
|
||||
orgs = OrganizationRepository(session)
|
||||
base = slugify(payload.name)
|
||||
slug = ensure_unique_slug(base, await orgs.list_all_slugs())
|
||||
|
||||
org = await orgs.create(
|
||||
Organization(name=payload.name.strip(), slug=slug, owner_user_id=user.id)
|
||||
)
|
||||
await MembershipRepository(session).add(
|
||||
Membership(
|
||||
organization_id=org.id,
|
||||
user_id=user.id,
|
||||
role=Role.OWNER,
|
||||
status=MembershipStatus.ACTIVE,
|
||||
)
|
||||
)
|
||||
# Every organization gets a wallet up front so billing views are never empty.
|
||||
session.add(CreditWalletRow(organization_id=org.id, balance=0.0, currency="USD"))
|
||||
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session,
|
||||
action=AuditAction.ORG_CREATE,
|
||||
organization_id=org.id,
|
||||
actor_user_id=user.id,
|
||||
target_type="organization",
|
||||
target_id=org.id,
|
||||
metadata={"name": org.name, "slug": org.slug},
|
||||
ip=ip,
|
||||
user_agent=ua,
|
||||
)
|
||||
await session.flush()
|
||||
return OrganizationWithRole(
|
||||
id=org.id, name=org.name, slug=org.slug,
|
||||
owner_user_id=org.owner_user_id, role=Role.OWNER.value,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[OrganizationWithRole])
|
||||
async def list_organizations(
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[OrganizationWithRole]:
|
||||
orgs = await OrganizationRepository(session).list_for_user(user.id)
|
||||
return [
|
||||
OrganizationWithRole(
|
||||
id=org.id, name=org.name, slug=org.slug,
|
||||
owner_user_id=org.owner_user_id, role=role.value,
|
||||
)
|
||||
for org, role in orgs
|
||||
]
|
||||
|
||||
|
||||
@router.get("/{org_id}", response_model=OrganizationWithRole)
|
||||
async def get_organization(context: OrgContext = Depends(get_org_context)) -> OrganizationWithRole:
|
||||
return OrganizationWithRole(
|
||||
id=context.organization.id,
|
||||
name=context.organization.name,
|
||||
slug=context.organization.slug,
|
||||
owner_user_id=context.organization.owner_user_id,
|
||||
role=context.membership.role.value,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{org_id}/members", response_model=list[MemberOut])
|
||||
async def list_members(
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[MemberOut]:
|
||||
memberships = await MembershipRepository(session).list_for_org(context.org_id)
|
||||
users = UserRepository(session)
|
||||
out: list[MemberOut] = []
|
||||
for membership in memberships:
|
||||
member_user = await users.get(membership.user_id)
|
||||
out.append(
|
||||
MemberOut(
|
||||
id=membership.id,
|
||||
user_id=membership.user_id,
|
||||
email=member_user.email if member_user else "",
|
||||
name=member_user.display_name if member_user else "",
|
||||
role=membership.role.value,
|
||||
status=membership.status.value,
|
||||
created_at=membership.created_at,
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{org_id}/invites", response_model=InviteOut, status_code=status.HTTP_201_CREATED
|
||||
)
|
||||
async def create_invite(
|
||||
payload: InviteRequest,
|
||||
request: Request,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> InviteOut:
|
||||
if not context.membership.has(CAN_MANAGE_MEMBERS):
|
||||
raise PermissionDeniedError("دعوت عضو نیازمند دسترسی مدیریت اعضا است.")
|
||||
if ROLE_RANK[payload.role] >= ROLE_RANK[Role.OWNER]:
|
||||
raise ValidationError("نقش owner از طریق دعوت قابل واگذاری نیست.")
|
||||
|
||||
raw_token = generate_token()
|
||||
invite = await InviteRepository(session).create(
|
||||
Invite(
|
||||
organization_id=context.org_id,
|
||||
email=str(payload.email),
|
||||
role=payload.role,
|
||||
token_hash=hash_token(raw_token),
|
||||
invited_by_user_id=context.user.id,
|
||||
expires_at=datetime.now(UTC) + timedelta(days=INVITE_TTL_DAYS),
|
||||
)
|
||||
)
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session,
|
||||
action=AuditAction.MEMBER_INVITE,
|
||||
organization_id=context.org_id,
|
||||
actor_user_id=context.user.id,
|
||||
target_type="invite",
|
||||
target_id=invite.id,
|
||||
metadata={"email": invite.email, "role": invite.role.value},
|
||||
ip=ip,
|
||||
user_agent=ua,
|
||||
)
|
||||
return InviteOut(
|
||||
id=invite.id,
|
||||
email=invite.email,
|
||||
role=invite.role.value,
|
||||
expires_at=invite.expires_at,
|
||||
invite_token=raw_token,
|
||||
invite_url=f"/accept-invite?token={raw_token}",
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{org_id}/members/{member_id}", response_model=MemberOut)
|
||||
async def update_member(
|
||||
member_id: str,
|
||||
payload: UpdateMemberRequest,
|
||||
request: Request,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> MemberOut:
|
||||
if not context.membership.has(CAN_MANAGE_MEMBERS):
|
||||
raise PermissionDeniedError("تغییر نقش عضو نیازمند دسترسی مدیریت اعضا است.")
|
||||
|
||||
memberships = MembershipRepository(session)
|
||||
target = await memberships.get_by_id(member_id)
|
||||
if target is None or target.organization_id != context.org_id:
|
||||
raise NotFoundError("عضو یافت نشد.")
|
||||
|
||||
# An admin may not outrank themselves or promote anyone to owner.
|
||||
if target.role == Role.OWNER and context.user.id != target.user_id:
|
||||
raise PermissionDeniedError("نقش owner فقط توسط خودش قابل تغییر است.")
|
||||
if payload.role is not None and ROLE_RANK[payload.role] > ROLE_RANK[context.membership.role]:
|
||||
raise PermissionDeniedError("نمیتوانید نقشی بالاتر از نقش خود واگذار کنید.")
|
||||
|
||||
if payload.role is not None:
|
||||
if target.role == Role.OWNER and await memberships.count_owners(context.org_id) <= 1:
|
||||
raise ValidationError("آخرین owner سازمان را نمیتوان تنزل داد.")
|
||||
await memberships.set_role(member_id, payload.role)
|
||||
if payload.status is not None:
|
||||
await memberships.set_status(member_id, payload.status)
|
||||
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session,
|
||||
action=AuditAction.MEMBER_ROLE_CHANGE,
|
||||
organization_id=context.org_id,
|
||||
actor_user_id=context.user.id,
|
||||
target_type="membership",
|
||||
target_id=member_id,
|
||||
metadata={
|
||||
"role": payload.role.value if payload.role else None,
|
||||
"status": payload.status.value if payload.status else None,
|
||||
},
|
||||
ip=ip,
|
||||
user_agent=ua,
|
||||
)
|
||||
|
||||
updated = await memberships.get_by_id(member_id)
|
||||
assert updated is not None
|
||||
member_user = await UserRepository(session).get(updated.user_id)
|
||||
return MemberOut(
|
||||
id=updated.id,
|
||||
user_id=updated.user_id,
|
||||
email=member_user.email if member_user else "",
|
||||
name=member_user.display_name if member_user else "",
|
||||
role=updated.role.value,
|
||||
status=updated.status.value,
|
||||
created_at=updated.created_at,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{org_id}/members/{member_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def remove_member(
|
||||
member_id: str,
|
||||
request: Request,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> None:
|
||||
if not context.membership.has(CAN_MANAGE_MEMBERS):
|
||||
raise PermissionDeniedError("حذف عضو نیازمند دسترسی مدیریت اعضا است.")
|
||||
|
||||
memberships = MembershipRepository(session)
|
||||
target = await memberships.get_by_id(member_id)
|
||||
if target is None or target.organization_id != context.org_id:
|
||||
raise NotFoundError("عضو یافت نشد.")
|
||||
if target.role == Role.OWNER:
|
||||
raise ValidationError("owner سازمان را نمیتوان حذف کرد؛ ابتدا نقش را تغییر دهید.")
|
||||
|
||||
await memberships.remove(member_id)
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session,
|
||||
action=AuditAction.MEMBER_REMOVE,
|
||||
organization_id=context.org_id,
|
||||
actor_user_id=context.user.id,
|
||||
target_type="membership",
|
||||
target_id=member_id,
|
||||
ip=ip,
|
||||
user_agent=ua,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{org_id}/audit", response_model=list[AuditEventOut])
|
||||
async def list_audit(
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[AuditEventOut]:
|
||||
if not context.membership.has(CAN_VIEW_AUDIT):
|
||||
raise PermissionDeniedError("مشاهدهٔ audit log نیازمند دسترسی مدیر است.")
|
||||
events = await AuditRepository(session).list_for_organization(
|
||||
organization_id=context.org_id, limit=limit, offset=offset
|
||||
)
|
||||
return [
|
||||
AuditEventOut(
|
||||
id=e.id, action=e.action, actor_user_id=e.actor_user_id,
|
||||
target_type=e.target_type, target_id=e.target_id, created_at=e.created_at,
|
||||
)
|
||||
for e in events
|
||||
]
|
||||
@@ -0,0 +1,375 @@
|
||||
"""Project, repository and preview-domain endpoints.
|
||||
|
||||
All routes are nested under ``/organizations/{org_id}/projects`` so the tenant
|
||||
context is resolved by ``get_org_context`` before any handler body runs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request, status
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.application.services.audit_service import record_audit
|
||||
from app.application.services.project_service import ProjectService
|
||||
from app.core.database import get_session
|
||||
from app.core.errors import NotFoundError
|
||||
from app.domain.audit.models import AuditAction
|
||||
from app.domain.identity.models import CAN_MANAGE_PROJECTS
|
||||
from app.interfaces.http.deps import OrgContext, client_meta, get_org_context, get_project_service
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/organizations/{org_id}/projects", tags=["projects"]
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ProjectOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
organization_id: str
|
||||
name: str
|
||||
slug: str
|
||||
description: str
|
||||
default_branch: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class CreateProjectRequest(BaseModel):
|
||||
name: str = Field(min_length=2, max_length=160)
|
||||
description: str = Field(default="", max_length=2000)
|
||||
|
||||
|
||||
class RepoOut(BaseModel):
|
||||
id: str
|
||||
provider: str
|
||||
owner: str
|
||||
repo_name: str
|
||||
clone_url: str
|
||||
web_url: str
|
||||
default_branch: str
|
||||
private: bool = True
|
||||
|
||||
|
||||
class RepoStatusOut(BaseModel):
|
||||
exists: bool
|
||||
private: bool
|
||||
default_branch: str
|
||||
branches: list[str]
|
||||
open_pull_requests: int
|
||||
|
||||
|
||||
class CreateBranchRequest(BaseModel):
|
||||
branch_name: str = Field(min_length=1, max_length=120)
|
||||
from_branch: str = Field(default="main", max_length=120)
|
||||
|
||||
|
||||
class BranchOut(BaseModel):
|
||||
name: str
|
||||
sha: str
|
||||
|
||||
|
||||
class OpenPullRequestRequest(BaseModel):
|
||||
head: str = Field(min_length=1, max_length=120)
|
||||
base: str = Field(default="main", max_length=120)
|
||||
title: str = Field(min_length=1, max_length=255)
|
||||
body: str = Field(default="", max_length=10_000)
|
||||
|
||||
|
||||
class PullRequestOut(BaseModel):
|
||||
number: int
|
||||
url: str
|
||||
state: str
|
||||
|
||||
|
||||
class PreviewDomainOut(BaseModel):
|
||||
id: str
|
||||
host: str
|
||||
port: int
|
||||
status: str
|
||||
protected: bool
|
||||
url: str
|
||||
|
||||
|
||||
class CreatePreviewDomainRequest(BaseModel):
|
||||
subdomain: str = Field(min_length=3, max_length=40)
|
||||
port: int | None = Field(default=None, ge=1, le=65535)
|
||||
|
||||
|
||||
class AllocatePortRequest(BaseModel):
|
||||
service_name: str = Field(default="web", max_length=80)
|
||||
|
||||
|
||||
class PortOut(BaseModel):
|
||||
port: int
|
||||
service_name: str
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Projects
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("", response_model=ProjectOut, status_code=status.HTTP_201_CREATED)
|
||||
async def create_project(
|
||||
payload: CreateProjectRequest,
|
||||
request: Request,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: ProjectService = Depends(get_project_service),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ProjectOut:
|
||||
context.require(CAN_MANAGE_PROJECTS)
|
||||
|
||||
project = await service.create_project(
|
||||
organization_id=context.org_id,
|
||||
organization_slug=context.organization.slug,
|
||||
created_by_user_id=context.user.id,
|
||||
name=payload.name,
|
||||
description=payload.description,
|
||||
)
|
||||
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session, action=AuditAction.PROJECT_CREATE, organization_id=context.org_id,
|
||||
actor_user_id=context.user.id, target_type="project", target_id=project.id,
|
||||
metadata={"name": project.name, "slug": project.slug}, ip=ip, user_agent=ua,
|
||||
)
|
||||
return ProjectOut(
|
||||
id=project.id, organization_id=project.organization_id, name=project.name,
|
||||
slug=project.slug, description=project.description,
|
||||
default_branch=project.default_branch, created_at=project.created_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[ProjectOut])
|
||||
async def list_projects(
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: ProjectService = Depends(get_project_service),
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
) -> list[ProjectOut]:
|
||||
projects = await service.projects.list_for_org(context.org_id, limit=limit, offset=offset)
|
||||
return [
|
||||
ProjectOut(
|
||||
id=p.id, organization_id=p.organization_id, name=p.name, slug=p.slug,
|
||||
description=p.description, default_branch=p.default_branch,
|
||||
created_at=p.created_at,
|
||||
)
|
||||
for p in projects
|
||||
]
|
||||
|
||||
|
||||
@router.get("/{project_id}", response_model=ProjectOut)
|
||||
async def get_project(
|
||||
project_id: str,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: ProjectService = Depends(get_project_service),
|
||||
) -> ProjectOut:
|
||||
project = await service.get_or_404(organization_id=context.org_id, project_id=project_id)
|
||||
return ProjectOut(
|
||||
id=project.id, organization_id=project.organization_id, name=project.name,
|
||||
slug=project.slug, description=project.description,
|
||||
default_branch=project.default_branch, created_at=project.created_at,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Repository
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/{project_id}/repo/ensure", response_model=RepoOut)
|
||||
async def ensure_repo(
|
||||
project_id: str,
|
||||
request: Request,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: ProjectService = Depends(get_project_service),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> RepoOut:
|
||||
context.require(CAN_MANAGE_PROJECTS)
|
||||
project = await service.get_or_404(organization_id=context.org_id, project_id=project_id)
|
||||
|
||||
repo = await service.ensure_repository(
|
||||
organization_slug=context.organization.slug, project=project
|
||||
)
|
||||
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session, action=AuditAction.REPO_CREATE, organization_id=context.org_id,
|
||||
actor_user_id=context.user.id, target_type="repository", target_id=repo.id,
|
||||
metadata={"repo_name": repo.repo_name, "provider": repo.provider},
|
||||
ip=ip, user_agent=ua,
|
||||
)
|
||||
return RepoOut(
|
||||
id=repo.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, private=True,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{project_id}/repo", response_model=RepoOut)
|
||||
async def get_repo(
|
||||
project_id: str,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: ProjectService = Depends(get_project_service),
|
||||
) -> RepoOut:
|
||||
project = await service.get_or_404(organization_id=context.org_id, project_id=project_id)
|
||||
repo = await service.repos.get_for_project(project.id)
|
||||
if repo is None:
|
||||
raise NotFoundError("مخزنی برای این پروژه ثبت نشده است.")
|
||||
# clone_url is a plain https URL; credentials are never included.
|
||||
return RepoOut(
|
||||
id=repo.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, private=True,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{project_id}/repo/status", response_model=RepoStatusOut)
|
||||
async def get_repo_status(
|
||||
project_id: str,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: ProjectService = Depends(get_project_service),
|
||||
) -> RepoStatusOut:
|
||||
project = await service.get_or_404(organization_id=context.org_id, project_id=project_id)
|
||||
status_result = await service.repo_status(project=project)
|
||||
return RepoStatusOut(
|
||||
exists=status_result.exists,
|
||||
private=status_result.private,
|
||||
default_branch=status_result.default_branch,
|
||||
branches=list(status_result.branches),
|
||||
open_pull_requests=status_result.open_pull_requests,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{project_id}/repo/branches", response_model=BranchOut)
|
||||
async def create_branch(
|
||||
project_id: str,
|
||||
payload: CreateBranchRequest,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: ProjectService = Depends(get_project_service),
|
||||
) -> BranchOut:
|
||||
context.require(CAN_MANAGE_PROJECTS)
|
||||
project = await service.get_or_404(organization_id=context.org_id, project_id=project_id)
|
||||
branch = await service.git.create_branch(
|
||||
project_id=project.id, branch_name=payload.branch_name, from_branch=payload.from_branch
|
||||
)
|
||||
return BranchOut(name=branch.name, sha=branch.sha)
|
||||
|
||||
|
||||
@router.post("/{project_id}/repo/pull-requests", response_model=PullRequestOut)
|
||||
async def open_pull_request(
|
||||
project_id: str,
|
||||
payload: OpenPullRequestRequest,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: ProjectService = Depends(get_project_service),
|
||||
) -> PullRequestOut:
|
||||
context.require(CAN_MANAGE_PROJECTS)
|
||||
project = await service.get_or_404(organization_id=context.org_id, project_id=project_id)
|
||||
pr = await service.git.open_pull_request(
|
||||
project_id=project.id, head=payload.head, base=payload.base,
|
||||
title=payload.title, body=payload.body,
|
||||
)
|
||||
return PullRequestOut(number=pr.number, url=pr.url, state=pr.state)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Preview domains
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/{project_id}/preview/ports", response_model=PortOut)
|
||||
async def allocate_port(
|
||||
project_id: str,
|
||||
payload: AllocatePortRequest,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: ProjectService = Depends(get_project_service),
|
||||
) -> PortOut:
|
||||
context.require(CAN_MANAGE_PROJECTS)
|
||||
project = await service.get_or_404(organization_id=context.org_id, project_id=project_id)
|
||||
port = await service.allocate_port(project=project, service_name=payload.service_name)
|
||||
return PortOut(port=port, service_name=payload.service_name)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{project_id}/preview/domains",
|
||||
response_model=PreviewDomainOut,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_preview_domain(
|
||||
project_id: str,
|
||||
payload: CreatePreviewDomainRequest,
|
||||
request: Request,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: ProjectService = Depends(get_project_service),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> PreviewDomainOut:
|
||||
context.require(CAN_MANAGE_PROJECTS)
|
||||
project = await service.get_or_404(organization_id=context.org_id, project_id=project_id)
|
||||
|
||||
record = await service.create_preview_domain(
|
||||
project=project,
|
||||
requested_subdomain=payload.subdomain,
|
||||
created_by_user_id=context.user.id,
|
||||
port=payload.port,
|
||||
)
|
||||
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session, action=AuditAction.DOMAIN_CREATE, organization_id=context.org_id,
|
||||
actor_user_id=context.user.id, target_type="preview_domain",
|
||||
target_id=record["id"],
|
||||
metadata={"host": record["host"], "port": record["port"]},
|
||||
ip=ip, user_agent=ua,
|
||||
)
|
||||
return PreviewDomainOut(
|
||||
id=record["id"], host=record["host"], port=record["port"],
|
||||
status=record["status"], protected=record["protected"], url=record["url"],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{project_id}/preview/domains", response_model=list[PreviewDomainOut])
|
||||
async def list_preview_domains(
|
||||
project_id: str,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: ProjectService = Depends(get_project_service),
|
||||
) -> list[PreviewDomainOut]:
|
||||
project = await service.get_or_404(organization_id=context.org_id, project_id=project_id)
|
||||
records = await service.list_preview_domains(project=project)
|
||||
return [
|
||||
PreviewDomainOut(
|
||||
id=r["id"], host=r["host"], port=r["port"], status=r["status"],
|
||||
protected=r["protected"], url=r["url"],
|
||||
)
|
||||
for r in records
|
||||
]
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{project_id}/preview/domains/{domain_id}", status_code=status.HTTP_204_NO_CONTENT
|
||||
)
|
||||
async def delete_preview_domain(
|
||||
project_id: str,
|
||||
domain_id: str,
|
||||
request: Request,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: ProjectService = Depends(get_project_service),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> None:
|
||||
context.require(CAN_MANAGE_PROJECTS)
|
||||
project = await service.get_or_404(organization_id=context.org_id, project_id=project_id)
|
||||
await service.remove_preview_domain(project=project, domain_id=domain_id)
|
||||
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session, action=AuditAction.DOMAIN_DELETE, organization_id=context.org_id,
|
||||
actor_user_id=context.user.id, target_type="preview_domain", target_id=domain_id,
|
||||
ip=ip, user_agent=ua,
|
||||
)
|
||||
@@ -0,0 +1,449 @@
|
||||
"""Sandbox Gateway endpoints.
|
||||
|
||||
Note what is **absent**: there is no route that accepts a shell command and runs
|
||||
it on the host. Every execution goes to ``SandboxProviderPort``, is bounded by a
|
||||
policy timeout and output cap, and is metered into the usage ledger.
|
||||
|
||||
Running a command requires ``sandbox.use``; tearing a workspace down requires
|
||||
``projects.manage`` because it destroys work.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.application.services.audit_service import record_audit
|
||||
from app.application.services.sandbox_service import SandboxService
|
||||
from app.core.database import get_session
|
||||
from app.domain.audit.models import AuditAction
|
||||
from app.domain.identity.models import CAN_MANAGE_PROJECTS, CAN_USE_SANDBOX
|
||||
from app.domain.sandbox.models import NetworkPolicy
|
||||
from app.interfaces.http.deps import (
|
||||
OrgContext,
|
||||
client_meta,
|
||||
get_org_context,
|
||||
get_sandbox_service,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/organizations/{org_id}/sandbox", tags=["sandbox"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class SandboxTemplateOut(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
provider: str
|
||||
image: str
|
||||
description: str
|
||||
default_cpu: float
|
||||
default_memory_mb: int
|
||||
default_disk_mb: int
|
||||
|
||||
|
||||
class WorkspaceOut(BaseModel):
|
||||
id: str
|
||||
project_id: str
|
||||
template_id: str
|
||||
provider: str
|
||||
status: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class CreateWorkspaceRequest(BaseModel):
|
||||
project_id: str
|
||||
template_id: str
|
||||
|
||||
|
||||
class SessionOut(BaseModel):
|
||||
id: str
|
||||
workspace_id: str
|
||||
status: str
|
||||
started_at: datetime
|
||||
last_active_at: datetime
|
||||
|
||||
|
||||
class StartSessionRequest(BaseModel):
|
||||
workspace_id: str
|
||||
|
||||
|
||||
class CommandRunOut(BaseModel):
|
||||
id: str
|
||||
command: str
|
||||
exit_code: int | None
|
||||
stdout: str
|
||||
stderr: str
|
||||
duration_ms: int
|
||||
truncated: bool
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class RunCommandRequest(BaseModel):
|
||||
command: str = Field(min_length=1, max_length=4_000)
|
||||
|
||||
|
||||
class FileReadOut(BaseModel):
|
||||
path: str
|
||||
content: str
|
||||
|
||||
|
||||
class ReadFileRequest(BaseModel):
|
||||
path: str = Field(min_length=1, max_length=1_000)
|
||||
|
||||
|
||||
class WriteFileRequest(BaseModel):
|
||||
path: str = Field(min_length=1, max_length=1_000)
|
||||
content: str = Field(max_length=1_000_000)
|
||||
|
||||
|
||||
class SnapshotOut(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class CreateSnapshotRequest(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=120)
|
||||
|
||||
|
||||
class PreviewUrlOut(BaseModel):
|
||||
port: int
|
||||
url: str
|
||||
|
||||
|
||||
class ResourceLimitOut(BaseModel):
|
||||
cpu: float
|
||||
memory_mb: int
|
||||
disk_mb: int
|
||||
timeout_seconds: int
|
||||
max_output_bytes: int
|
||||
network_policy: str
|
||||
|
||||
|
||||
class SetLimitsRequest(BaseModel):
|
||||
scope: str = "organization"
|
||||
scope_id: str | None = None
|
||||
cpu: float = Field(default=1.0, gt=0, le=16)
|
||||
memory_mb: int = Field(default=1024, ge=128, le=65_536)
|
||||
disk_mb: int = Field(default=2048, ge=256, le=1_048_576)
|
||||
timeout_seconds: int = Field(default=120, ge=1, le=900)
|
||||
max_output_bytes: int = Field(default=200_000, ge=1024, le=2_000_000)
|
||||
network_policy: NetworkPolicy = NetworkPolicy.DENY_ALL
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Templates & limits
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/templates", response_model=list[SandboxTemplateOut])
|
||||
async def list_templates(
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: SandboxService = Depends(get_sandbox_service),
|
||||
) -> list[SandboxTemplateOut]:
|
||||
templates = await service.list_templates()
|
||||
return [
|
||||
SandboxTemplateOut(
|
||||
id=t.id, name=t.name, provider=str(t.provider), image=t.image,
|
||||
description=t.description, default_cpu=t.default_cpu,
|
||||
default_memory_mb=t.default_memory_mb, default_disk_mb=t.default_disk_mb,
|
||||
)
|
||||
for t in templates
|
||||
]
|
||||
|
||||
|
||||
@router.get("/limits", response_model=ResourceLimitOut)
|
||||
async def get_limits(
|
||||
project_id: str | None = Query(default=None),
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: SandboxService = Depends(get_sandbox_service),
|
||||
) -> ResourceLimitOut:
|
||||
limit = await service.effective_limits(
|
||||
organization_id=context.org_id, project_id=project_id
|
||||
)
|
||||
return ResourceLimitOut(
|
||||
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),
|
||||
)
|
||||
|
||||
|
||||
@router.put("/limits", response_model=ResourceLimitOut)
|
||||
async def set_limits(
|
||||
payload: SetLimitsRequest,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: SandboxService = Depends(get_sandbox_service),
|
||||
) -> ResourceLimitOut:
|
||||
context.require(CAN_MANAGE_PROJECTS)
|
||||
limit = await service.set_limits(
|
||||
scope=payload.scope, scope_id=payload.scope_id or context.org_id,
|
||||
cpu=payload.cpu, memory_mb=payload.memory_mb, disk_mb=payload.disk_mb,
|
||||
timeout_seconds=payload.timeout_seconds,
|
||||
max_output_bytes=payload.max_output_bytes,
|
||||
network_policy=payload.network_policy,
|
||||
)
|
||||
return ResourceLimitOut(
|
||||
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),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Workspaces
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post(
|
||||
"/workspaces", response_model=WorkspaceOut, status_code=status.HTTP_201_CREATED
|
||||
)
|
||||
async def create_workspace(
|
||||
payload: CreateWorkspaceRequest,
|
||||
request: Request,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: SandboxService = Depends(get_sandbox_service),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> WorkspaceOut:
|
||||
context.require(CAN_USE_SANDBOX)
|
||||
|
||||
workspace = await service.create_workspace(
|
||||
organization_id=context.org_id,
|
||||
project_id=payload.project_id,
|
||||
template_id=payload.template_id,
|
||||
)
|
||||
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session, action="sandbox.workspace_create", organization_id=context.org_id,
|
||||
actor_user_id=context.user.id, target_type="workspace", target_id=workspace.id,
|
||||
metadata={"project_id": workspace.project_id, "provider": str(workspace.provider)},
|
||||
ip=ip, user_agent=ua,
|
||||
)
|
||||
return WorkspaceOut(
|
||||
id=workspace.id, project_id=workspace.project_id,
|
||||
template_id=workspace.template_id, provider=str(workspace.provider),
|
||||
status=str(workspace.status), created_at=workspace.created_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/workspaces", response_model=list[WorkspaceOut])
|
||||
async def list_workspaces(
|
||||
project_id: str = Query(min_length=1),
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: SandboxService = Depends(get_sandbox_service),
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
) -> list[WorkspaceOut]:
|
||||
workspaces = await service.list_workspaces(
|
||||
organization_id=context.org_id,
|
||||
project_id=project_id,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
return [
|
||||
WorkspaceOut(
|
||||
id=w.id, project_id=w.project_id, template_id=w.template_id,
|
||||
provider=str(w.provider), status=str(w.status), created_at=w.created_at,
|
||||
)
|
||||
for w in workspaces
|
||||
]
|
||||
|
||||
|
||||
@router.delete("/workspaces/{workspace_id}", response_model=WorkspaceOut)
|
||||
async def destroy_workspace(
|
||||
workspace_id: str,
|
||||
request: Request,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: SandboxService = Depends(get_sandbox_service),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> WorkspaceOut:
|
||||
context.require(CAN_MANAGE_PROJECTS)
|
||||
workspace = await service.destroy_workspace(
|
||||
organization_id=context.org_id, workspace_id=workspace_id
|
||||
)
|
||||
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session, action=AuditAction.SANDBOX_DESTROY, organization_id=context.org_id,
|
||||
actor_user_id=context.user.id, target_type="workspace",
|
||||
target_id=workspace.id, ip=ip, user_agent=ua,
|
||||
)
|
||||
return WorkspaceOut(
|
||||
id=workspace.id, project_id=workspace.project_id,
|
||||
template_id=workspace.template_id, provider=str(workspace.provider),
|
||||
status=str(workspace.status), created_at=workspace.created_at,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sessions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post(
|
||||
"/sessions", response_model=SessionOut, status_code=status.HTTP_201_CREATED
|
||||
)
|
||||
async def start_session(
|
||||
payload: StartSessionRequest,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: SandboxService = Depends(get_sandbox_service),
|
||||
) -> SessionOut:
|
||||
context.require(CAN_USE_SANDBOX)
|
||||
sandbox_session = await service.start_session(
|
||||
organization_id=context.org_id, workspace_id=payload.workspace_id
|
||||
)
|
||||
return SessionOut(
|
||||
id=sandbox_session.id, workspace_id=sandbox_session.workspace_id,
|
||||
status=str(sandbox_session.status), started_at=sandbox_session.started_at,
|
||||
last_active_at=sandbox_session.last_active_at,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/sessions/{session_id}/stop", response_model=SessionOut)
|
||||
async def stop_session(
|
||||
session_id: str,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: SandboxService = Depends(get_sandbox_service),
|
||||
) -> SessionOut:
|
||||
context.require(CAN_USE_SANDBOX)
|
||||
sandbox_session = await service.stop_session(
|
||||
organization_id=context.org_id, session_id=session_id
|
||||
)
|
||||
return SessionOut(
|
||||
id=sandbox_session.id, workspace_id=sandbox_session.workspace_id,
|
||||
status=str(sandbox_session.status), started_at=sandbox_session.started_at,
|
||||
last_active_at=sandbox_session.last_active_at,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Commands & files
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/sessions/{session_id}/commands", response_model=CommandRunOut)
|
||||
async def run_command(
|
||||
session_id: str,
|
||||
payload: RunCommandRequest,
|
||||
request: Request,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: SandboxService = Depends(get_sandbox_service),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> CommandRunOut:
|
||||
context.require(CAN_USE_SANDBOX)
|
||||
|
||||
run, _result = await service.run_command(
|
||||
organization_id=context.org_id,
|
||||
session_id=session_id,
|
||||
command=payload.command,
|
||||
user_id=context.user.id,
|
||||
)
|
||||
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session, action=AuditAction.SANDBOX_COMMAND, organization_id=context.org_id,
|
||||
actor_user_id=context.user.id, target_type="sandbox_session",
|
||||
target_id=session_id,
|
||||
# The command itself is recorded, but never a secret from the environment.
|
||||
metadata={"exit_code": run.exit_code, "duration_ms": run.duration_ms},
|
||||
ip=ip, user_agent=ua,
|
||||
)
|
||||
return CommandRunOut(
|
||||
id=run.id, command=run.command, exit_code=run.exit_code,
|
||||
stdout=run.stdout_ref, stderr=run.stderr_ref, duration_ms=run.duration_ms,
|
||||
truncated=run.truncated, created_at=run.created_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/sessions/{session_id}/commands", response_model=list[CommandRunOut])
|
||||
async def list_commands(
|
||||
session_id: str,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: SandboxService = Depends(get_sandbox_service),
|
||||
) -> list[CommandRunOut]:
|
||||
await service.get_session_or_404(
|
||||
organization_id=context.org_id, session_id=session_id
|
||||
)
|
||||
runs = await service.list_commands(session_id=session_id)
|
||||
return [
|
||||
CommandRunOut(
|
||||
id=r.id, command=r.command, exit_code=r.exit_code, stdout=r.stdout_ref,
|
||||
stderr=r.stderr_ref, duration_ms=r.duration_ms, truncated=r.truncated,
|
||||
created_at=r.created_at,
|
||||
)
|
||||
for r in runs
|
||||
]
|
||||
|
||||
|
||||
@router.post("/sessions/{session_id}/files/read", response_model=FileReadOut)
|
||||
async def read_file(
|
||||
session_id: str,
|
||||
payload: ReadFileRequest,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: SandboxService = Depends(get_sandbox_service),
|
||||
) -> FileReadOut:
|
||||
context.require(CAN_USE_SANDBOX)
|
||||
content = await service.read_file(
|
||||
organization_id=context.org_id, session_id=session_id, path=payload.path
|
||||
)
|
||||
return FileReadOut(path=payload.path, content=content)
|
||||
|
||||
|
||||
@router.post("/sessions/{session_id}/files/write", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def write_file(
|
||||
session_id: str,
|
||||
payload: WriteFileRequest,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: SandboxService = Depends(get_sandbox_service),
|
||||
) -> None:
|
||||
context.require(CAN_USE_SANDBOX)
|
||||
await service.write_file(
|
||||
organization_id=context.org_id, session_id=session_id,
|
||||
path=payload.path, content=payload.content,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Snapshots & preview
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post(
|
||||
"/sessions/{session_id}/snapshots",
|
||||
response_model=SnapshotOut,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_snapshot(
|
||||
session_id: str,
|
||||
payload: CreateSnapshotRequest,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: SandboxService = Depends(get_sandbox_service),
|
||||
) -> SnapshotOut:
|
||||
context.require(CAN_USE_SANDBOX)
|
||||
snapshot = await service.snapshot(
|
||||
organization_id=context.org_id, session_id=session_id, name=payload.name
|
||||
)
|
||||
return SnapshotOut(
|
||||
id=snapshot.id, name=snapshot.name, created_at=snapshot.created_at
|
||||
)
|
||||
|
||||
|
||||
@router.post("/sessions/{session_id}/preview-url", response_model=PreviewUrlOut)
|
||||
async def get_preview_url(
|
||||
session_id: str,
|
||||
port: int = Query(ge=1, le=65535),
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: SandboxService = Depends(get_sandbox_service),
|
||||
) -> PreviewUrlOut:
|
||||
context.require(CAN_USE_SANDBOX)
|
||||
preview = await service.get_preview_url(
|
||||
organization_id=context.org_id, session_id=session_id, port=port
|
||||
)
|
||||
return PreviewUrlOut(port=preview.port, url=preview.url)
|
||||
@@ -0,0 +1,803 @@
|
||||
"""AI Workforce, Manager Chat, Tasks and Runs.
|
||||
|
||||
Two things are load-bearing in this module:
|
||||
|
||||
* **Model identity never leaves.** ``EmployeeOut`` and ``RunOut`` carry a routing
|
||||
policy id at most. There is no field capable of holding a model or provider
|
||||
name, so a future refactor cannot accidentally leak one.
|
||||
* **Every route resolves its tenant** through ``get_org_context``, and every
|
||||
nested id (employee, thread, task, run) is re-scoped to that tenant.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request, status
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.application.services.audit_service import record_audit
|
||||
from app.application.services.workforce_service import WorkforceService
|
||||
from app.core.database import get_session
|
||||
from app.domain.audit.models import AuditAction
|
||||
from app.domain.identity.models import CAN_MANAGE_EMPLOYEES, CAN_RUN_TASKS
|
||||
from app.domain.workforce.models import (
|
||||
EmployeeStatus,
|
||||
RoleKey,
|
||||
TaskStatus,
|
||||
)
|
||||
from app.interfaces.http.deps import (
|
||||
OrgContext,
|
||||
client_meta,
|
||||
get_org_context,
|
||||
get_workforce_service,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/organizations/{org_id}", tags=["workforce"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class AgentTemplateOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
name: str
|
||||
role_key: str
|
||||
description: str
|
||||
default_tools_json: list[str]
|
||||
|
||||
|
||||
class RoutingChoiceOut(BaseModel):
|
||||
"""A routing policy as a *customer* may see it.
|
||||
|
||||
Name and description only. Deliberately omits ``mode`` and every field that
|
||||
could hint at which model sits behind the choice.
|
||||
"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
description: str
|
||||
|
||||
|
||||
class ToolSpecOut(BaseModel):
|
||||
key: str
|
||||
label: str
|
||||
description: str
|
||||
requires_approval: bool
|
||||
destructive: bool
|
||||
category: str
|
||||
|
||||
|
||||
class SetToolPermissionRequest(BaseModel):
|
||||
allowed: bool
|
||||
|
||||
|
||||
class DecideApprovalRequest(BaseModel):
|
||||
approve: bool
|
||||
note: str = Field(default="", max_length=1000)
|
||||
|
||||
|
||||
class EmployeeOut(BaseModel):
|
||||
id: str
|
||||
organization_id: str
|
||||
project_id: str | None
|
||||
template_id: str | None
|
||||
name: str
|
||||
role_key: str
|
||||
status: str
|
||||
# A routing policy id — never a model or provider name.
|
||||
routing_policy_id: str | None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class EmployeeDetailOut(EmployeeOut):
|
||||
prompt_versions: list[PromptVersionOut]
|
||||
tool_permissions: list[ToolPermissionOut]
|
||||
open_task_count: int
|
||||
|
||||
|
||||
class CreateEmployeeRequest(BaseModel):
|
||||
name: str = Field(min_length=2, max_length=160)
|
||||
role_key: RoleKey
|
||||
template_id: str | None = None
|
||||
project_id: str | None = None
|
||||
routing_policy_id: str | None = None
|
||||
|
||||
|
||||
class PromptVersionOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
version: int
|
||||
content: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class ToolPermissionOut(BaseModel):
|
||||
tool_key: str
|
||||
allowed: bool
|
||||
|
||||
|
||||
# ``EmployeeDetailOut`` refers to the two models above by name. With
|
||||
# ``from __future__ import annotations`` every annotation is a string, so Pydantic
|
||||
# must be told to resolve them once the names exist.
|
||||
EmployeeDetailOut.model_rebuild()
|
||||
|
||||
|
||||
class UpdateEmployeeRequest(BaseModel):
|
||||
name: str | None = Field(default=None, min_length=2, max_length=160)
|
||||
status: EmployeeStatus | None = None
|
||||
project_id: str | None = None
|
||||
routing_policy_id: str | None = None
|
||||
|
||||
|
||||
class CreatePromptVersionRequest(BaseModel):
|
||||
content: str = Field(min_length=1, max_length=20_000)
|
||||
|
||||
|
||||
class ThreadOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
organization_id: str
|
||||
project_id: str | None
|
||||
title: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class MessageOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
thread_id: str
|
||||
role: str
|
||||
content: str
|
||||
employee_id: str | None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class ThreadDetailOut(ThreadOut):
|
||||
messages: list[MessageOut]
|
||||
|
||||
|
||||
class CreateThreadRequest(BaseModel):
|
||||
title: str = Field(default="", max_length=255)
|
||||
project_id: str | None = None
|
||||
|
||||
|
||||
class PostMessageRequest(BaseModel):
|
||||
content: str = Field(min_length=1, max_length=20_000)
|
||||
employee_id: str | None = None
|
||||
|
||||
|
||||
class PostMessageResponse(BaseModel):
|
||||
user_message: MessageOut
|
||||
assistant_message: MessageOut
|
||||
|
||||
|
||||
class TaskOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
organization_id: str
|
||||
project_id: str | None
|
||||
thread_id: str | None
|
||||
title: str
|
||||
description: str
|
||||
status: str
|
||||
assigned_employee_id: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class CreateTaskRequest(BaseModel):
|
||||
title: str = Field(min_length=2, max_length=255)
|
||||
description: str = Field(default="", max_length=10_000)
|
||||
project_id: str | None = None
|
||||
thread_id: str | None = None
|
||||
assigned_employee_id: str | None = None
|
||||
|
||||
|
||||
class RunOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
task_id: str
|
||||
employee_id: str | None
|
||||
status: str
|
||||
error: str | None
|
||||
started_at: datetime | None
|
||||
finished_at: datetime | None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class RunStepOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
index: int
|
||||
kind: str
|
||||
title: str
|
||||
payload_json: dict
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class ToolCallOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
tool_key: str
|
||||
status: str
|
||||
requires_approval: bool
|
||||
arguments_json: dict
|
||||
result_json: dict
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class ArtifactOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
name: str
|
||||
kind: str
|
||||
content_ref: str
|
||||
size_bytes: int
|
||||
run_id: str | None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class RunDetailOut(RunOut):
|
||||
steps: list[RunStepOut]
|
||||
tool_calls: list[ToolCallOut]
|
||||
artifacts: list[ArtifactOut]
|
||||
|
||||
|
||||
class TaskDetailOut(TaskOut):
|
||||
runs: list[RunOut]
|
||||
|
||||
|
||||
class StartRunRequest(BaseModel):
|
||||
employee_id: str | None = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _employee_out(employee) -> EmployeeOut:
|
||||
return EmployeeOut(
|
||||
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,
|
||||
created_at=employee.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _thread_out(thread) -> ThreadOut:
|
||||
return ThreadOut(
|
||||
id=thread.id, organization_id=thread.organization_id,
|
||||
project_id=thread.project_id, title=thread.title, created_at=thread.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _task_out(task) -> TaskOut:
|
||||
return TaskOut(
|
||||
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_at=task.created_at, updated_at=task.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _run_out(run) -> RunOut:
|
||||
return RunOut(
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Templates
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/agent-templates", response_model=list[AgentTemplateOut])
|
||||
async def list_agent_templates(
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: WorkforceService = Depends(get_workforce_service),
|
||||
) -> list[AgentTemplateOut]:
|
||||
templates = await service.list_templates()
|
||||
return [
|
||||
AgentTemplateOut(
|
||||
id=t.id, name=t.name, role_key=str(t.role_key), description=t.description,
|
||||
default_tools_json=list(t.default_tools_json),
|
||||
)
|
||||
for t in templates
|
||||
]
|
||||
|
||||
|
||||
@router.get("/routing-choices", response_model=list[RoutingChoiceOut])
|
||||
async def list_routing_choices(
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: WorkforceService = Depends(get_workforce_service),
|
||||
) -> list[RoutingChoiceOut]:
|
||||
"""Let a customer pick *how* an employee should work, without naming a model."""
|
||||
policies = await service.list_routing_choices()
|
||||
return [
|
||||
RoutingChoiceOut(id=p.id, name=p.name, description=p.description) for p in policies
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Employees
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post(
|
||||
"/employees", response_model=EmployeeOut, status_code=status.HTTP_201_CREATED
|
||||
)
|
||||
async def create_employee(
|
||||
payload: CreateEmployeeRequest,
|
||||
request: Request,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: WorkforceService = Depends(get_workforce_service),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> EmployeeOut:
|
||||
context.require(CAN_MANAGE_EMPLOYEES)
|
||||
|
||||
employee = await service.create_employee(
|
||||
organization_id=context.org_id,
|
||||
created_by_user_id=context.user.id,
|
||||
name=payload.name,
|
||||
role_key=payload.role_key,
|
||||
template_id=payload.template_id,
|
||||
project_id=payload.project_id,
|
||||
routing_policy_id=payload.routing_policy_id,
|
||||
)
|
||||
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session, action=AuditAction.EMPLOYEE_CREATE, organization_id=context.org_id,
|
||||
actor_user_id=context.user.id, target_type="employee", target_id=employee.id,
|
||||
metadata={"name": employee.name, "role_key": str(employee.role_key)},
|
||||
ip=ip, user_agent=ua,
|
||||
)
|
||||
return _employee_out(employee)
|
||||
|
||||
|
||||
@router.get("/employees", response_model=list[EmployeeOut])
|
||||
async def list_employees(
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: WorkforceService = Depends(get_workforce_service),
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
) -> list[EmployeeOut]:
|
||||
return [
|
||||
_employee_out(e)
|
||||
for e in await service.list_employees(context.org_id, limit=limit, offset=offset)
|
||||
]
|
||||
|
||||
|
||||
@router.get("/employees/{employee_id}", response_model=EmployeeDetailOut)
|
||||
async def get_employee(
|
||||
employee_id: str,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: WorkforceService = Depends(get_workforce_service),
|
||||
) -> EmployeeDetailOut:
|
||||
employee = await service.get_employee_or_404(
|
||||
organization_id=context.org_id, employee_id=employee_id
|
||||
)
|
||||
base = _employee_out(employee)
|
||||
return EmployeeDetailOut(
|
||||
**base.model_dump(),
|
||||
prompt_versions=[
|
||||
PromptVersionOut(
|
||||
id=v.id, version=v.version, content=v.content, created_at=v.created_at
|
||||
)
|
||||
for v in await service.list_prompt_versions(employee.id)
|
||||
],
|
||||
tool_permissions=[
|
||||
ToolPermissionOut(tool_key=p.tool_key, allowed=p.allowed)
|
||||
for p in await service.list_tool_permissions(employee.id)
|
||||
],
|
||||
open_task_count=await service.tasks.count_open_for_employee(employee.id),
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/employees/{employee_id}", response_model=EmployeeOut)
|
||||
async def update_employee(
|
||||
employee_id: str,
|
||||
payload: UpdateEmployeeRequest,
|
||||
request: Request,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: WorkforceService = Depends(get_workforce_service),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> EmployeeOut:
|
||||
context.require(CAN_MANAGE_EMPLOYEES)
|
||||
|
||||
employee = await service.update_employee(
|
||||
organization_id=context.org_id,
|
||||
employee_id=employee_id,
|
||||
name=payload.name,
|
||||
status=payload.status,
|
||||
project_id=payload.project_id,
|
||||
routing_policy_id=payload.routing_policy_id,
|
||||
)
|
||||
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session, action=AuditAction.EMPLOYEE_UPDATE, organization_id=context.org_id,
|
||||
actor_user_id=context.user.id, target_type="employee", target_id=employee.id,
|
||||
metadata={"status": str(employee.status)},
|
||||
ip=ip, user_agent=ua,
|
||||
)
|
||||
return _employee_out(employee)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/employees/{employee_id}/prompt-versions",
|
||||
response_model=PromptVersionOut,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def add_prompt_version(
|
||||
employee_id: str,
|
||||
payload: CreatePromptVersionRequest,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: WorkforceService = Depends(get_workforce_service),
|
||||
) -> PromptVersionOut:
|
||||
context.require(CAN_MANAGE_EMPLOYEES)
|
||||
# Confirms tenant ownership before writing anything.
|
||||
await service.get_employee_or_404(
|
||||
organization_id=context.org_id, employee_id=employee_id
|
||||
)
|
||||
version = await service.add_prompt_version(
|
||||
employee_id=employee_id, content=payload.content,
|
||||
created_by_user_id=context.user.id,
|
||||
)
|
||||
return PromptVersionOut(
|
||||
id=version.id, version=version.version, content=version.content,
|
||||
created_at=version.created_at,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Threads & messages
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post(
|
||||
"/threads", response_model=ThreadOut, status_code=status.HTTP_201_CREATED
|
||||
)
|
||||
async def create_thread(
|
||||
payload: CreateThreadRequest,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: WorkforceService = Depends(get_workforce_service),
|
||||
) -> ThreadOut:
|
||||
thread = await service.create_thread(
|
||||
organization_id=context.org_id, created_by_user_id=context.user.id,
|
||||
title=payload.title, project_id=payload.project_id,
|
||||
)
|
||||
return _thread_out(thread)
|
||||
|
||||
|
||||
@router.get("/threads", response_model=list[ThreadOut])
|
||||
async def list_threads(
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: WorkforceService = Depends(get_workforce_service),
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
) -> list[ThreadOut]:
|
||||
return [
|
||||
_thread_out(t)
|
||||
for t in await service.list_threads(context.org_id, limit=limit, offset=offset)
|
||||
]
|
||||
|
||||
|
||||
@router.get("/threads/{thread_id}", response_model=ThreadDetailOut)
|
||||
async def get_thread(
|
||||
thread_id: str,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: WorkforceService = Depends(get_workforce_service),
|
||||
) -> ThreadDetailOut:
|
||||
thread = await service.get_thread_or_404(
|
||||
organization_id=context.org_id, thread_id=thread_id
|
||||
)
|
||||
messages = await service.list_messages(thread.id)
|
||||
base = _thread_out(thread)
|
||||
return ThreadDetailOut(
|
||||
**base.model_dump(),
|
||||
messages=[
|
||||
MessageOut(
|
||||
id=m.id, thread_id=m.thread_id, role=str(m.role), content=m.content,
|
||||
employee_id=m.employee_id, created_at=m.created_at,
|
||||
)
|
||||
for m in messages
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/threads/{thread_id}/messages",
|
||||
response_model=PostMessageResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def post_message(
|
||||
thread_id: str,
|
||||
payload: PostMessageRequest,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: WorkforceService = Depends(get_workforce_service),
|
||||
) -> PostMessageResponse:
|
||||
context.require(CAN_RUN_TASKS)
|
||||
|
||||
user_message, assistant_message = await service.post_user_message(
|
||||
organization_id=context.org_id,
|
||||
thread_id=thread_id,
|
||||
content=payload.content,
|
||||
user_id=context.user.id,
|
||||
employee_id=payload.employee_id,
|
||||
)
|
||||
return PostMessageResponse(
|
||||
user_message=MessageOut(
|
||||
id=user_message.id, thread_id=user_message.thread_id,
|
||||
role=str(user_message.role), content=user_message.content,
|
||||
employee_id=user_message.employee_id, created_at=user_message.created_at,
|
||||
),
|
||||
assistant_message=MessageOut(
|
||||
id=assistant_message.id, thread_id=assistant_message.thread_id,
|
||||
role=str(assistant_message.role), content=assistant_message.content,
|
||||
employee_id=assistant_message.employee_id,
|
||||
created_at=assistant_message.created_at,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tasks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/tasks", response_model=TaskOut, status_code=status.HTTP_201_CREATED)
|
||||
async def create_task(
|
||||
payload: CreateTaskRequest,
|
||||
request: Request,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: WorkforceService = Depends(get_workforce_service),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> TaskOut:
|
||||
context.require(CAN_RUN_TASKS)
|
||||
|
||||
task = await service.create_task(
|
||||
organization_id=context.org_id,
|
||||
created_by_user_id=context.user.id,
|
||||
title=payload.title,
|
||||
description=payload.description,
|
||||
project_id=payload.project_id,
|
||||
thread_id=payload.thread_id,
|
||||
assigned_employee_id=payload.assigned_employee_id,
|
||||
)
|
||||
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session, action=AuditAction.TASK_CREATE, organization_id=context.org_id,
|
||||
actor_user_id=context.user.id, target_type="task", target_id=task.id,
|
||||
metadata={"title": task.title}, ip=ip, user_agent=ua,
|
||||
)
|
||||
return _task_out(task)
|
||||
|
||||
|
||||
@router.get("/tasks", response_model=list[TaskOut])
|
||||
async def list_tasks(
|
||||
status_filter: TaskStatus | None = Query(default=None, alias="status"),
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: WorkforceService = Depends(get_workforce_service),
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
) -> list[TaskOut]:
|
||||
tasks = await service.list_tasks(
|
||||
context.org_id, status=status_filter, limit=limit, offset=offset
|
||||
)
|
||||
return [_task_out(t) for t in tasks]
|
||||
|
||||
|
||||
@router.get("/tasks/{task_id}", response_model=TaskDetailOut)
|
||||
async def get_task(
|
||||
task_id: str,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: WorkforceService = Depends(get_workforce_service),
|
||||
) -> TaskDetailOut:
|
||||
task = await service.get_task_or_404(organization_id=context.org_id, task_id=task_id)
|
||||
runs = await service.list_task_runs(task.id)
|
||||
base = _task_out(task)
|
||||
return TaskDetailOut(**base.model_dump(), runs=[_run_out(r) for r in runs])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Runs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post(
|
||||
"/tasks/{task_id}/runs",
|
||||
response_model=RunDetailOut,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def start_run(
|
||||
task_id: str,
|
||||
payload: StartRunRequest,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: WorkforceService = Depends(get_workforce_service),
|
||||
) -> RunDetailOut:
|
||||
context.require(CAN_RUN_TASKS)
|
||||
|
||||
run = await service.start_run(
|
||||
organization_id=context.org_id,
|
||||
task_id=task_id,
|
||||
user_id=context.user.id,
|
||||
employee_id=payload.employee_id,
|
||||
)
|
||||
return await _run_detail(service, context.org_id, run.id)
|
||||
|
||||
|
||||
@router.get("/runs/{run_id}", response_model=RunDetailOut)
|
||||
async def get_run(
|
||||
run_id: str,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: WorkforceService = Depends(get_workforce_service),
|
||||
) -> RunDetailOut:
|
||||
return await _run_detail(service, context.org_id, run_id)
|
||||
|
||||
|
||||
@router.post("/runs/{run_id}/cancel", response_model=RunDetailOut)
|
||||
async def cancel_run(
|
||||
run_id: str,
|
||||
request: Request,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: WorkforceService = Depends(get_workforce_service),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> RunDetailOut:
|
||||
context.require(CAN_RUN_TASKS)
|
||||
|
||||
run = await service.cancel_run(organization_id=context.org_id, run_id=run_id)
|
||||
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session, action=AuditAction.RUN_CANCEL, organization_id=context.org_id,
|
||||
actor_user_id=context.user.id, target_type="run", target_id=run.id,
|
||||
ip=ip, user_agent=ua,
|
||||
)
|
||||
return await _run_detail(service, context.org_id, run.id)
|
||||
|
||||
|
||||
@router.get("/approvals/pending", response_model=list[ToolCallOut])
|
||||
async def list_pending_approvals(
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: WorkforceService = Depends(get_workforce_service),
|
||||
) -> list[ToolCallOut]:
|
||||
calls = await service.list_pending_approvals(context.org_id)
|
||||
return [
|
||||
ToolCallOut(
|
||||
id=c.id, tool_key=c.tool_key, status=str(c.status),
|
||||
requires_approval=c.requires_approval,
|
||||
arguments_json=dict(c.arguments_json), result_json=dict(c.result_json),
|
||||
created_at=c.created_at,
|
||||
)
|
||||
for c in calls
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool catalog & approvals
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/tools", response_model=list[ToolSpecOut])
|
||||
async def list_tools(
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: WorkforceService = Depends(get_workforce_service),
|
||||
) -> list[ToolSpecOut]:
|
||||
"""The catalog of tools an employee can be granted."""
|
||||
return [
|
||||
ToolSpecOut(
|
||||
key=spec.key, label=spec.label, description=spec.description,
|
||||
requires_approval=spec.requires_approval, destructive=spec.destructive,
|
||||
category=spec.category,
|
||||
)
|
||||
for spec in service.tool_catalog()
|
||||
]
|
||||
|
||||
|
||||
@router.put(
|
||||
"/employees/{employee_id}/tools/{tool_key}", response_model=ToolPermissionOut
|
||||
)
|
||||
async def set_tool_permission(
|
||||
employee_id: str,
|
||||
tool_key: str,
|
||||
payload: SetToolPermissionRequest,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: WorkforceService = Depends(get_workforce_service),
|
||||
) -> ToolPermissionOut:
|
||||
context.require(CAN_MANAGE_EMPLOYEES)
|
||||
permission = await service.set_tool_permission(
|
||||
organization_id=context.org_id,
|
||||
employee_id=employee_id,
|
||||
tool_key=tool_key,
|
||||
allowed=payload.allowed,
|
||||
)
|
||||
return ToolPermissionOut(tool_key=permission.tool_key, allowed=permission.allowed)
|
||||
|
||||
|
||||
@router.post("/approvals/{tool_call_id}/decide", response_model=ToolCallOut)
|
||||
async def decide_approval(
|
||||
tool_call_id: str,
|
||||
payload: DecideApprovalRequest,
|
||||
request: Request,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: WorkforceService = Depends(get_workforce_service),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ToolCallOut:
|
||||
context.require(CAN_RUN_TASKS)
|
||||
|
||||
call = await service.decide_approval(
|
||||
organization_id=context.org_id,
|
||||
tool_call_id=tool_call_id,
|
||||
approve=payload.approve,
|
||||
decided_by_user_id=context.user.id,
|
||||
note=payload.note,
|
||||
)
|
||||
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session, action=AuditAction.APPROVAL_DECISION, organization_id=context.org_id,
|
||||
actor_user_id=context.user.id, target_type="tool_call", target_id=call.id,
|
||||
metadata={"tool_key": call.tool_key, "approved": payload.approve},
|
||||
ip=ip, user_agent=ua,
|
||||
)
|
||||
return ToolCallOut(
|
||||
id=call.id, tool_key=call.tool_key, status=str(call.status),
|
||||
requires_approval=call.requires_approval,
|
||||
arguments_json=dict(call.arguments_json), result_json=dict(call.result_json),
|
||||
created_at=call.created_at,
|
||||
)
|
||||
|
||||
|
||||
async def _run_detail(
|
||||
service: WorkforceService, organization_id: str, run_id: str
|
||||
) -> RunDetailOut:
|
||||
run, steps, tool_calls, artifacts = await service.get_run_or_404(
|
||||
organization_id=organization_id, run_id=run_id
|
||||
)
|
||||
base = _run_out(run)
|
||||
return RunDetailOut(
|
||||
**base.model_dump(),
|
||||
steps=[
|
||||
RunStepOut(
|
||||
id=s.id, index=s.index, kind=s.kind, title=s.title,
|
||||
payload_json=dict(s.payload_json), created_at=s.created_at,
|
||||
)
|
||||
for s in steps
|
||||
],
|
||||
tool_calls=[
|
||||
ToolCallOut(
|
||||
id=c.id, tool_key=c.tool_key, status=str(c.status),
|
||||
requires_approval=c.requires_approval,
|
||||
arguments_json=dict(c.arguments_json),
|
||||
result_json=dict(c.result_json), created_at=c.created_at,
|
||||
)
|
||||
for c in tool_calls
|
||||
],
|
||||
artifacts=[
|
||||
ArtifactOut(
|
||||
id=a.id, name=a.name, kind=a.kind, content_ref=a.content_ref,
|
||||
size_bytes=a.size_bytes, run_id=a.run_id, created_at=a.created_at,
|
||||
)
|
||||
for a in artifacts
|
||||
],
|
||||
)
|
||||
@@ -0,0 +1,137 @@
|
||||
"""FastAPI application factory.
|
||||
|
||||
Nothing in the inner layers imports FastAPI; this module wires them together.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import Base, engine
|
||||
from app.interfaces.http.errors import register_exception_handlers
|
||||
from app.interfaces.http.request_context import RequestContextMiddleware
|
||||
from app.interfaces.http.routers import (
|
||||
admin,
|
||||
auth,
|
||||
billing,
|
||||
health,
|
||||
knowledge,
|
||||
organizations,
|
||||
projects,
|
||||
sandbox,
|
||||
workforce,
|
||||
)
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
|
||||
)
|
||||
logger = logging.getLogger("agy.api")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_: FastAPI):
|
||||
try:
|
||||
# In development we create any missing tables so `pnpm dev` works on a
|
||||
# fresh clone. Production schema changes go through Alembic
|
||||
# (`pnpm db:migrate`).
|
||||
if not settings.is_production:
|
||||
import app.infrastructure.db.models # noqa: F401 - register metadata
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
logger.info("Development schema ensured.")
|
||||
logger.info(
|
||||
"Adapters -> ai=%s git=%s domain=%s sandbox=%s payment=%s",
|
||||
settings.ai_default_provider,
|
||||
settings.git_provider,
|
||||
settings.domain_router,
|
||||
settings.sandbox_provider,
|
||||
settings.payment_provider,
|
||||
)
|
||||
except Exception as exc:
|
||||
# Startup failures are the hardest to diagnose after the fact: the
|
||||
# process is gone and all that remains is whatever reached stderr, which
|
||||
# a container runtime routinely discards. Record it, then still fail
|
||||
# loudly — a half-started API must not accept traffic.
|
||||
await _record_startup_failure(exc)
|
||||
raise
|
||||
yield
|
||||
|
||||
|
||||
async def _record_startup_failure(exc: BaseException) -> None:
|
||||
"""Persist a boot failure. Never raises; the caller re-raises regardless."""
|
||||
try:
|
||||
from app.domain.audit.models import ErrorSeverity, ErrorSource
|
||||
from app.infrastructure.error_log import record_error
|
||||
|
||||
await record_error(
|
||||
kind="startup_failed",
|
||||
message=str(exc) or type(exc).__name__,
|
||||
exc=exc,
|
||||
source=ErrorSource.STARTUP,
|
||||
severity=ErrorSeverity.CRITICAL,
|
||||
metadata={"app_env": settings.app_env},
|
||||
)
|
||||
except Exception: # pragma: no cover - boot is already failing
|
||||
logger.exception("Could not record the startup failure.")
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
application = FastAPI(
|
||||
title=settings.app_name,
|
||||
version="0.1.0",
|
||||
description="AI Workforce Platform — standalone multi-tenant SaaS.",
|
||||
lifespan=lifespan,
|
||||
docs_url="/docs",
|
||||
openapi_url="/openapi.json",
|
||||
)
|
||||
|
||||
# Starlette applies middleware outermost-first in reverse registration
|
||||
# order, so the last one added wraps everything else. The request-context
|
||||
# middleware goes last on purpose: that way even a CORS rejection gets an
|
||||
# ``X-Request-ID`` — and a CORS rejection is exactly the kind of opaque
|
||||
# failure where a correlatable id is worth having.
|
||||
application.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origin_list,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
# Without this the browser cannot read the header off a cross-origin
|
||||
# response, and the id would be useless to the web client.
|
||||
expose_headers=["X-Request-ID"],
|
||||
)
|
||||
|
||||
application.add_middleware(RequestContextMiddleware)
|
||||
|
||||
register_exception_handlers(application)
|
||||
|
||||
application.include_router(health.router)
|
||||
application.include_router(auth.router, prefix="/api")
|
||||
application.include_router(organizations.router, prefix="/api")
|
||||
application.include_router(projects.router, prefix="/api")
|
||||
application.include_router(workforce.router, prefix="/api")
|
||||
application.include_router(billing.router, prefix="/api")
|
||||
application.include_router(knowledge.router, prefix="/api")
|
||||
application.include_router(sandbox.router, prefix="/api")
|
||||
application.include_router(admin.router, prefix="/api")
|
||||
|
||||
@application.get("/", tags=["meta"])
|
||||
async def root() -> dict:
|
||||
return {
|
||||
"name": settings.app_name,
|
||||
"version": "0.1.0",
|
||||
"docs": "/docs",
|
||||
"health": "/health",
|
||||
}
|
||||
|
||||
return application
|
||||
|
||||
|
||||
app = create_app()
|
||||
@@ -0,0 +1,366 @@
|
||||
"""Idempotent development seed.
|
||||
|
||||
Run with ``pnpm db:seed``. Creates a platform owner, a demo user, a sample
|
||||
organization that both of them own, the system agent templates, routing
|
||||
policies, rate cards and a mock provider with two models.
|
||||
|
||||
Safe to run repeatedly: every step checks for existing rows first.
|
||||
|
||||
The two accounts it prints at the end are meant to be usable immediately —
|
||||
``tests/test_seed.py`` asserts exactly that, because a seed whose credentials the
|
||||
login endpoint rejects is worse than no seed at all.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.database import Base, SessionFactory, engine
|
||||
from app.core.security import hash_password
|
||||
from app.domain.ai_control.models import (
|
||||
AiModel,
|
||||
Capability,
|
||||
Provider,
|
||||
ProviderType,
|
||||
RateCard,
|
||||
RateUnit,
|
||||
RoutingMode,
|
||||
RoutingPolicy,
|
||||
)
|
||||
from app.domain.identity.models import Membership, MembershipStatus, Organization, Role, User
|
||||
from app.domain.sandbox.models import SandboxProviderKind, SandboxTemplate
|
||||
from app.domain.workforce.models import AgentTemplate, RoleKey
|
||||
from app.infrastructure.db.models import (
|
||||
AgentTemplateRow,
|
||||
AiModelRow,
|
||||
AiProviderRow,
|
||||
CreditWalletRow,
|
||||
RateCardRow,
|
||||
RoutingPolicyRow,
|
||||
SandboxTemplateRow,
|
||||
UserRow,
|
||||
)
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)-7s %(message)s")
|
||||
logger = logging.getLogger("agy.seed")
|
||||
|
||||
#: ``example.com`` is a documentation domain and passes email validation.
|
||||
#: Do NOT use ``.local``/``.test``/``.invalid``: those are special-use TLDs and
|
||||
#: ``email-validator`` rejects them, so the seed would create an account that the
|
||||
#: API then refuses to log in — the user exists but is unreachable.
|
||||
PLATFORM_OWNER_EMAIL = "owner@example.com"
|
||||
PLATFORM_OWNER_PASSWORD = "owner12345"
|
||||
DEMO_EMAIL = "demo@example.com"
|
||||
DEMO_PASSWORD = "demo12345"
|
||||
#: Stable identifiers for the sample organization. Both must stay constant for
|
||||
#: the seed to be idempotent — see ``_ensure_org``.
|
||||
DEMO_ORG_NAME = "سازمان نمونه"
|
||||
DEMO_ORG_SLUG = "sample-org"
|
||||
|
||||
AGENT_TEMPLATES: list[tuple[str, RoleKey, str, list[str]]] = [
|
||||
(
|
||||
"مدیر سازمان", RoleKey.MANAGER,
|
||||
"کارها را دستهبندی میکند و بین کارمندها تقسیم میکند.",
|
||||
["knowledge.search", "artifact.create"],
|
||||
),
|
||||
(
|
||||
"تحلیلگر و معمار", RoleKey.PLANNER,
|
||||
"نیازمندیها را تحلیل و پلن اجرایی میسازد.",
|
||||
["knowledge.search"],
|
||||
),
|
||||
(
|
||||
"برنامهنویس", RoleKey.DEVELOPER,
|
||||
"کد مینویسد و در sandbox اجرا و تست میکند.",
|
||||
["git.ensure_repo", "git.create_branch", "sandbox.run_command",
|
||||
"sandbox.read_file", "sandbox.write_file"],
|
||||
),
|
||||
(
|
||||
"بازبین کد", RoleKey.REVIEWER,
|
||||
"PRها را بازبینی میکند و بازخورد میدهد.",
|
||||
["git.open_pr"],
|
||||
),
|
||||
(
|
||||
"تستر", RoleKey.TESTER,
|
||||
"تست مینویسد و اجرا میکند.",
|
||||
["sandbox.run_command", "sandbox.write_file"],
|
||||
),
|
||||
(
|
||||
"محقق", RoleKey.RESEARCHER,
|
||||
"منابع را جمع و خلاصه میکند.",
|
||||
["knowledge.search", "artifact.create"],
|
||||
),
|
||||
(
|
||||
"تولیدکننده محتوا", RoleKey.CONTENT_CREATOR,
|
||||
"متن و محتوای سازمانی تولید میکند.",
|
||||
["artifact.create"],
|
||||
),
|
||||
(
|
||||
"پشتیبان مشتری", RoleKey.SUPPORT,
|
||||
"به پرسشهای مشتری پاسخ میدهد.",
|
||||
["knowledge.search"],
|
||||
),
|
||||
]
|
||||
|
||||
ROUTING_POLICIES: list[tuple[str, RoutingMode, bool, str]] = [
|
||||
("سریع و ارزان", RoutingMode.CHEAP, True, "برای کارهای روزمره و پرتکرار."),
|
||||
("متعادل", RoutingMode.BALANCED, False, "پیشفرض متعادل بین هزینه و کیفیت."),
|
||||
("استدلال عمیق", RoutingMode.REASONING, False, "برای تحلیل و کارهای پیچیده."),
|
||||
("کدنویسی", RoutingMode.CODING, False, "بهینه برای تولید و اصلاح کد."),
|
||||
("چندرسانهای", RoutingMode.VISION, False, "برای ورودی تصویری."),
|
||||
]
|
||||
|
||||
RATE_CARDS: list[tuple[RateUnit, float, float]] = [
|
||||
(RateUnit.MODEL_INPUT_TOKENS, 0.0000004, 0.0000002),
|
||||
(RateUnit.MODEL_OUTPUT_TOKENS, 0.0000016, 0.0000008),
|
||||
(RateUnit.SANDBOX_CPU_SECOND, 0.00002, 0.00001),
|
||||
(RateUnit.SANDBOX_MEMORY_GB_SECOND, 0.000004, 0.000002),
|
||||
(RateUnit.STORAGE_GB_DAY, 0.0004, 0.0002),
|
||||
(RateUnit.TOOL_CALL, 0.0005, 0.0001),
|
||||
(RateUnit.PREMIUM_EMPLOYEE_RUN, 0.02, 0.008),
|
||||
]
|
||||
|
||||
#: (name, image, description, cpu, memory_mb, disk_mb)
|
||||
SANDBOX_TEMPLATES: list[tuple[str, str, str, float, int, int]] = [
|
||||
(
|
||||
"python-3.13", "python:3.13-slim",
|
||||
"محیط پایتون برای اجرای تست و اسکریپت.", 1.0, 1024, 2048,
|
||||
),
|
||||
(
|
||||
"node-22", "node:22-slim",
|
||||
"محیط Node برای build و اجرای پروژههای فرانتاند.", 1.0, 2048, 4096,
|
||||
),
|
||||
(
|
||||
"ubuntu-base", "ubuntu:24.04",
|
||||
"محیط عمومی لینوکس برای ابزارهای خط فرمان.", 1.0, 1024, 2048,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
async def _ensure_user(
|
||||
session, *, email: str, password: str, name: str, platform_owner: bool
|
||||
) -> User:
|
||||
existing = (
|
||||
await session.execute(select(UserRow).where(UserRow.email == email))
|
||||
).scalar_one_or_none()
|
||||
if existing:
|
||||
return User(
|
||||
id=existing.id, email=existing.email, password_hash=existing.password_hash,
|
||||
name=existing.name, is_platform_owner=existing.is_platform_owner,
|
||||
)
|
||||
from app.infrastructure.repositories.identity import UserRepository
|
||||
|
||||
user = await UserRepository(session).create(
|
||||
User(
|
||||
email=email,
|
||||
password_hash=hash_password(password),
|
||||
name=name,
|
||||
is_platform_owner=platform_owner,
|
||||
)
|
||||
)
|
||||
logger.info("created user %s", email)
|
||||
return user
|
||||
|
||||
|
||||
async def _ensure_org(session, *, members: list[User]) -> Organization:
|
||||
"""Get or create the sample organization, with every given user in it.
|
||||
|
||||
Idempotent in all three parts: the organization, its memberships and its
|
||||
credit wallet. A developer who runs ``pnpm db:seed`` twice must end up with
|
||||
exactly one organization and no duplicate rows.
|
||||
"""
|
||||
from app.infrastructure.repositories.identity import (
|
||||
MembershipRepository,
|
||||
OrganizationRepository,
|
||||
)
|
||||
|
||||
orgs = OrganizationRepository(session)
|
||||
memberships = MembershipRepository(session)
|
||||
|
||||
# A *fixed* slug, not `slugify(name)`. The name is Persian, and `slugify`
|
||||
# falls back to a random `x<hex>` when it cannot transliterate — so deriving
|
||||
# the slug here would produce a different value on every run, `get_by_slug`
|
||||
# would always miss, and re-seeding would silently create a duplicate
|
||||
# organization (and a duplicate wallet) each time.
|
||||
org = await orgs.get_by_slug(DEMO_ORG_SLUG)
|
||||
if org is None:
|
||||
org = await orgs.create(
|
||||
Organization(name=DEMO_ORG_NAME, slug=DEMO_ORG_SLUG, owner_user_id=members[0].id)
|
||||
)
|
||||
logger.info("created organization %s", org.slug)
|
||||
|
||||
# Every listed user becomes an owner of the sample org. Without this the
|
||||
# demo account can log in but belongs to no organization, so every screen
|
||||
# renders empty and the account looks broken.
|
||||
for user in members:
|
||||
if await memberships.get(org.id, user.id) is None:
|
||||
await memberships.add(
|
||||
Membership(
|
||||
organization_id=org.id, user_id=user.id,
|
||||
role=Role.OWNER, status=MembershipStatus.ACTIVE,
|
||||
)
|
||||
)
|
||||
logger.info("added %s to %s as owner", user.email, org.slug)
|
||||
|
||||
wallet = (
|
||||
await session.execute(
|
||||
select(CreditWalletRow).where(CreditWalletRow.organization_id == org.id)
|
||||
)
|
||||
).scalars().first()
|
||||
if wallet is None:
|
||||
session.add(CreditWalletRow(organization_id=org.id, balance=25.0, currency="USD"))
|
||||
return org
|
||||
|
||||
|
||||
async def seed() -> None:
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
async with SessionFactory() as session:
|
||||
owner = await _ensure_user(
|
||||
session, email=PLATFORM_OWNER_EMAIL, password=PLATFORM_OWNER_PASSWORD,
|
||||
name="مالک سامانه", platform_owner=True,
|
||||
)
|
||||
demo = await _ensure_user(
|
||||
session, email=DEMO_EMAIL, password=DEMO_PASSWORD,
|
||||
name="کاربر نمونه", platform_owner=False,
|
||||
)
|
||||
# Both accounts join the sample organization, so a developer who logs in
|
||||
# as either one lands on a populated dashboard rather than an empty one.
|
||||
await _ensure_org(session, members=[owner, demo])
|
||||
|
||||
if not (await session.execute(select(AiProviderRow))).scalars().first():
|
||||
provider = Provider(name="mock", type=ProviderType.MOCK, base_url="")
|
||||
session.add(
|
||||
AiProviderRow(
|
||||
id=provider.id, name=provider.name, type=provider.type.value,
|
||||
base_url=provider.base_url, is_active=True,
|
||||
)
|
||||
)
|
||||
await session.flush()
|
||||
for internal, display, caps in (
|
||||
("mock-fast", "Mock Fast", [Capability.TEXT]),
|
||||
(
|
||||
"mock-reasoning",
|
||||
"Mock Reasoning",
|
||||
[Capability.TEXT, Capability.REASONING, Capability.CODE],
|
||||
),
|
||||
):
|
||||
model = AiModel(
|
||||
provider_id=provider.id,
|
||||
internal_name=internal,
|
||||
display_name_for_admin=display,
|
||||
capabilities=[str(c) for c in caps],
|
||||
input_price_per_1m=0.3,
|
||||
output_price_per_1m=1.2,
|
||||
)
|
||||
session.add(
|
||||
AiModelRow(
|
||||
id=model.id, provider_id=model.provider_id,
|
||||
internal_name=model.internal_name,
|
||||
display_name_for_admin=model.display_name_for_admin,
|
||||
capabilities=model.capabilities,
|
||||
input_price_per_1m=model.input_price_per_1m,
|
||||
output_price_per_1m=model.output_price_per_1m,
|
||||
is_active=True,
|
||||
)
|
||||
)
|
||||
logger.info("created mock provider + 2 models")
|
||||
|
||||
if not (await session.execute(select(AgentTemplateRow))).scalars().first():
|
||||
for name, role_key, description, tools in AGENT_TEMPLATES:
|
||||
template = AgentTemplate(
|
||||
name=name, role_key=role_key, description=description,
|
||||
default_prompt=f"تو {name} هستی. وظیفهات: {description}",
|
||||
default_tools_json=tools,
|
||||
)
|
||||
session.add(
|
||||
AgentTemplateRow(
|
||||
id=template.id, name=template.name, role_key=template.role_key.value,
|
||||
description=template.description, default_prompt=template.default_prompt,
|
||||
default_tools_json=template.default_tools_json, is_system=True,
|
||||
)
|
||||
)
|
||||
logger.info("created %d agent templates", len(AGENT_TEMPLATES))
|
||||
|
||||
if not (await session.execute(select(RoutingPolicyRow))).scalars().first():
|
||||
for name, mode, is_default, description in ROUTING_POLICIES:
|
||||
policy = RoutingPolicy(
|
||||
name=name, mode=mode, description=description, is_default=is_default
|
||||
)
|
||||
session.add(
|
||||
RoutingPolicyRow(
|
||||
id=policy.id, name=policy.name, description=policy.description,
|
||||
mode=policy.mode.value, is_default=policy.is_default, is_active=True,
|
||||
)
|
||||
)
|
||||
logger.info("created %d routing policies", len(ROUTING_POLICIES))
|
||||
|
||||
if not (await session.execute(select(RateCardRow))).scalars().first():
|
||||
for unit, customer_price, internal_cost in RATE_CARDS:
|
||||
card = RateCard(
|
||||
name=unit.value.replace("_", " "),
|
||||
unit=unit,
|
||||
customer_price=customer_price,
|
||||
internal_cost_estimate=internal_cost,
|
||||
)
|
||||
session.add(
|
||||
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=True,
|
||||
)
|
||||
)
|
||||
logger.info("created %d rate cards", len(RATE_CARDS))
|
||||
|
||||
if not (await session.execute(select(SandboxTemplateRow))).scalars().first():
|
||||
for name, image, description, cpu, memory_mb, disk_mb in SANDBOX_TEMPLATES:
|
||||
# Named distinctly from the agent-template loop above so the two
|
||||
# entities cannot be confused.
|
||||
sandbox_template = SandboxTemplate(
|
||||
name=name, provider=SandboxProviderKind.MOCK, image=image,
|
||||
description=description, default_cpu=cpu,
|
||||
default_memory_mb=memory_mb, default_disk_mb=disk_mb,
|
||||
)
|
||||
session.add(
|
||||
SandboxTemplateRow(
|
||||
id=sandbox_template.id, name=sandbox_template.name,
|
||||
provider=str(sandbox_template.provider),
|
||||
image=sandbox_template.image,
|
||||
description=sandbox_template.description,
|
||||
default_cpu=sandbox_template.default_cpu,
|
||||
default_memory_mb=sandbox_template.default_memory_mb,
|
||||
default_disk_mb=sandbox_template.default_disk_mb,
|
||||
is_system=True,
|
||||
)
|
||||
)
|
||||
logger.info("created %d sandbox templates", len(SANDBOX_TEMPLATES))
|
||||
|
||||
await session.commit()
|
||||
|
||||
logger.info("seed complete")
|
||||
logger.info("platform owner: %s / %s", PLATFORM_OWNER_EMAIL, PLATFORM_OWNER_PASSWORD)
|
||||
logger.info("demo user: %s / %s", DEMO_EMAIL, DEMO_PASSWORD)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
asyncio.run(seed())
|
||||
except Exception as exc:
|
||||
# A failed seed is almost always a first-run problem — the very moment
|
||||
# there is nobody watching a terminal. Record it before exiting non-zero.
|
||||
from app.domain.audit.models import ErrorSeverity, ErrorSource
|
||||
from app.infrastructure.error_log import record_error
|
||||
|
||||
asyncio.run(
|
||||
record_error(
|
||||
kind="seed_failed",
|
||||
message=str(exc) or type(exc).__name__,
|
||||
exc=exc,
|
||||
source=ErrorSource.STARTUP,
|
||||
severity=ErrorSeverity.CRITICAL,
|
||||
)
|
||||
)
|
||||
raise
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Standalone workers.
|
||||
|
||||
Each worker is a thin driver over the application services. They are shipped as
|
||||
modules inside the API package so they reuse the exact same composition
|
||||
(``app.application.container``); ``services/*`` are the process-level shells
|
||||
that launch them, ready to be split into separate deployments.
|
||||
|
||||
Run one with::
|
||||
|
||||
python -m app.workers billing
|
||||
python -m app.workers ai-runtime
|
||||
python -m app.workers sandbox-gateway
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.workers.ai_runtime import AiRuntimeWorker
|
||||
from app.workers.base import WorkerOutcome, run_periodically, worker_session
|
||||
from app.workers.billing import BillingWorker
|
||||
from app.workers.sandbox_gateway import SandboxGatewayWorker
|
||||
|
||||
__all__ = [
|
||||
"AiRuntimeWorker",
|
||||
"BillingWorker",
|
||||
"SandboxGatewayWorker",
|
||||
"WorkerOutcome",
|
||||
"run_periodically",
|
||||
"worker_session",
|
||||
]
|
||||
@@ -0,0 +1,132 @@
|
||||
"""CLI entry point for the workers.
|
||||
|
||||
python -m app.workers billing [--loop --interval 3600]
|
||||
python -m app.workers ai-runtime [--loop --interval 30 --limit 10]
|
||||
python -m app.workers sandbox-gateway [--loop --interval 600 --idle-minutes 120]
|
||||
|
||||
Default is **one pass and exit**, which is the shape a cron entry wants. Pass
|
||||
``--loop`` for a long-running deployment; SIGINT/SIGTERM then stop it cleanly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
|
||||
from app.workers.base import WorkerOutcome, install_signal_handlers, run_periodically
|
||||
|
||||
logger = logging.getLogger("agy.worker")
|
||||
|
||||
|
||||
def _build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(prog="app.workers", description="AGY background workers")
|
||||
parser.add_argument(
|
||||
"worker",
|
||||
choices=["billing", "ai-runtime", "sandbox-gateway"],
|
||||
help="which worker to run",
|
||||
)
|
||||
parser.add_argument("--loop", action="store_true", help="keep running instead of one pass")
|
||||
parser.add_argument(
|
||||
"--interval",
|
||||
type=float,
|
||||
default=300.0,
|
||||
help="seconds between passes when --loop is set (default: 300)",
|
||||
)
|
||||
parser.add_argument("--limit", type=int, default=10, help="max rows per pass (default: 10)")
|
||||
parser.add_argument(
|
||||
"--idle-minutes",
|
||||
type=int,
|
||||
default=120,
|
||||
help="sandbox-gateway: stop sessions idle for this long (default: 120)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--period-days",
|
||||
type=int,
|
||||
default=30,
|
||||
help="billing: length of the billing period (default: 30)",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def _billing_pass(args: argparse.Namespace):
|
||||
from datetime import timedelta
|
||||
|
||||
from app.domain.base import utcnow
|
||||
from app.workers.billing import BillingWorker
|
||||
|
||||
worker = BillingWorker()
|
||||
|
||||
async def one_pass() -> WorkerOutcome:
|
||||
end = utcnow()
|
||||
return await worker.roll_up(
|
||||
period_start=end - timedelta(days=max(1, args.period_days)),
|
||||
period_end=end,
|
||||
)
|
||||
|
||||
return one_pass
|
||||
|
||||
|
||||
def _ai_runtime_pass(args: argparse.Namespace):
|
||||
from app.workers.ai_runtime import AiRuntimeWorker
|
||||
|
||||
worker = AiRuntimeWorker()
|
||||
|
||||
async def one_pass() -> WorkerOutcome:
|
||||
return await worker.drain(limit=args.limit)
|
||||
|
||||
return one_pass
|
||||
|
||||
|
||||
def _sandbox_gateway_pass(args: argparse.Namespace):
|
||||
from app.workers.sandbox_gateway import SandboxGatewayWorker
|
||||
|
||||
gateway = SandboxGatewayWorker()
|
||||
|
||||
async def one_pass() -> WorkerOutcome:
|
||||
return await gateway.reap(
|
||||
idle_minutes=args.idle_minutes, limit=max(args.limit, 200)
|
||||
)
|
||||
|
||||
return one_pass
|
||||
|
||||
|
||||
def _pass_function(args: argparse.Namespace):
|
||||
"""Return a zero-argument coroutine function for one pass."""
|
||||
if args.worker == "billing":
|
||||
return _billing_pass(args)
|
||||
if args.worker == "ai-runtime":
|
||||
return _ai_runtime_pass(args)
|
||||
return _sandbox_gateway_pass(args)
|
||||
|
||||
|
||||
async def _main_async(args: argparse.Namespace) -> int:
|
||||
one_pass = _pass_function(args)
|
||||
stop = asyncio.Event()
|
||||
if args.loop:
|
||||
install_signal_handlers(stop)
|
||||
|
||||
outcomes = await run_periodically(
|
||||
one_pass,
|
||||
interval_seconds=max(1.0, args.interval),
|
||||
stop=stop,
|
||||
run_once=not args.loop,
|
||||
worker_name=args.worker,
|
||||
)
|
||||
|
||||
failed = sum(o.failed for o in outcomes)
|
||||
return 1 if failed else 0
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
|
||||
)
|
||||
args = _build_parser().parse_args(argv)
|
||||
return asyncio.run(_main_async(args))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,94 @@
|
||||
"""AI runtime worker — drives queued tasks through the run engine.
|
||||
|
||||
The HTTP API can start a run inline (that is what the "اجرا" button does), but
|
||||
a platform that only ever runs work on the request thread has two problems: a
|
||||
slow model call holds a connection open, and a task created by anything other
|
||||
than a button press never runs at all. This worker is the second driver.
|
||||
|
||||
It deliberately owns **no** orchestration logic. It finds runnable tasks and
|
||||
hands each one to ``WorkforceService.start_run`` — the same call the router
|
||||
makes. Model routing, usage accounting, the timeline and the artifact are all
|
||||
the service's business.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.application.container import build_workforce_service
|
||||
from app.core.errors import AppError
|
||||
from app.domain.audit.models import ErrorSeverity, ErrorSource
|
||||
from app.domain.workforce.models import TaskStatus
|
||||
from app.infrastructure.error_log import record_error
|
||||
from app.infrastructure.repositories.workforce import TaskRepository
|
||||
from app.workers.base import WorkerOutcome, worker_session
|
||||
|
||||
|
||||
class AiRuntimeWorker:
|
||||
"""Drains the queue of tasks that nobody has run yet."""
|
||||
|
||||
name = "ai-runtime"
|
||||
|
||||
async def drain(self, *, limit: int = 10) -> WorkerOutcome:
|
||||
"""Run up to ``limit`` queued tasks, oldest first.
|
||||
|
||||
A task that fails is recorded and the pass continues: one poisoned task
|
||||
must not block the queue behind it.
|
||||
"""
|
||||
outcome = WorkerOutcome(name=self.name)
|
||||
|
||||
async with worker_session() as session:
|
||||
tasks = TaskRepository(session)
|
||||
workforce = build_workforce_service(session)
|
||||
|
||||
for task in await tasks.list_runnable(limit=limit):
|
||||
# Re-read through the service so the tenant check and the
|
||||
# "already closed" guard both apply. The worker gets no
|
||||
# privileged bypass.
|
||||
try:
|
||||
current = await workforce.get_task_or_404(
|
||||
organization_id=task.organization_id, task_id=task.id
|
||||
)
|
||||
except AppError:
|
||||
outcome.skipped += 1
|
||||
outcome.note(f"task {task.id}: vanished before the run")
|
||||
continue
|
||||
|
||||
if current.status not in (TaskStatus.NEW, TaskStatus.TRIAGED):
|
||||
outcome.skipped += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
run = await workforce.start_run(
|
||||
organization_id=task.organization_id,
|
||||
task_id=task.id,
|
||||
user_id=task.created_by_user_id or "",
|
||||
employee_id=task.assigned_employee_id,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 - keep draining
|
||||
outcome.failed += 1
|
||||
outcome.note(f"task {task.id}: {type(exc).__name__}: {exc}")
|
||||
# Persist with the tenant attached: a task that fails to run
|
||||
# is a tenant-visible problem, and the console must be able
|
||||
# to show that organization what happened.
|
||||
await record_error(
|
||||
kind="task_run_failed",
|
||||
message=str(exc) or type(exc).__name__,
|
||||
exc=exc,
|
||||
source=ErrorSource.WORKER,
|
||||
severity=ErrorSeverity.ERROR,
|
||||
organization_id=task.organization_id,
|
||||
user_id=task.created_by_user_id,
|
||||
metadata={
|
||||
"worker": self.name,
|
||||
"task_id": task.id,
|
||||
"employee_id": task.assigned_employee_id,
|
||||
},
|
||||
)
|
||||
continue
|
||||
|
||||
outcome.processed += 1
|
||||
outcome.note(f"task {task.id} -> run {run.id} ({run.status})")
|
||||
|
||||
return outcome
|
||||
|
||||
|
||||
__all__ = ["AiRuntimeWorker"]
|
||||
@@ -0,0 +1,166 @@
|
||||
"""Shared plumbing for the standalone workers.
|
||||
|
||||
The workers are deliberately thin. Every one of them is a *driver* that calls
|
||||
the same application services the HTTP API calls — the wiring comes from
|
||||
``app.application.container``, so a worker and the API can never disagree about
|
||||
which adapter backs a port. No business rule lives in this package; if a worker
|
||||
and the API ever disagreed, the API would be right.
|
||||
|
||||
That is also what makes the ``services/*`` split cheap later: moving a worker
|
||||
into its own process changes the process boundary, not the code.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import AsyncIterator, Awaitable, Callable
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import SessionFactory
|
||||
|
||||
logger = logging.getLogger("agy.worker")
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class WorkerOutcome:
|
||||
"""What one pass of a worker did.
|
||||
|
||||
Returned rather than only logged, so a test can assert on it and an
|
||||
operator can see a number instead of grepping a log file.
|
||||
"""
|
||||
|
||||
name: str
|
||||
processed: int = 0
|
||||
skipped: int = 0
|
||||
failed: int = 0
|
||||
notes: list[str] = field(default_factory=list)
|
||||
|
||||
#: Keep the note list bounded: a worker that processes 10k rows must not
|
||||
#: accumulate 10k strings in memory just to print a summary.
|
||||
MAX_NOTES = 50
|
||||
|
||||
def note(self, message: str) -> None:
|
||||
if len(self.notes) < self.MAX_NOTES:
|
||||
self.notes.append(message)
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
return self.failed == 0
|
||||
|
||||
@property
|
||||
def touched(self) -> int:
|
||||
return self.processed + self.skipped + self.failed
|
||||
|
||||
def summary(self) -> str:
|
||||
return (
|
||||
f"{self.name}: processed={self.processed} "
|
||||
f"skipped={self.skipped} failed={self.failed}"
|
||||
)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def worker_session() -> AsyncIterator[AsyncSession]:
|
||||
"""One committed transaction per pass; rolled back whole on failure.
|
||||
|
||||
A worker pass is all-or-nothing on purpose: a half-applied invoice rollup
|
||||
is worse than no rollup, because the next pass would skip the period it
|
||||
thinks it already billed.
|
||||
"""
|
||||
async with SessionFactory() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
|
||||
async def run_periodically(
|
||||
pass_fn: Callable[[], Awaitable[WorkerOutcome]],
|
||||
*,
|
||||
interval_seconds: float,
|
||||
stop: asyncio.Event,
|
||||
run_once: bool = False,
|
||||
worker_name: str | None = None,
|
||||
) -> list[WorkerOutcome]:
|
||||
"""Drive ``pass_fn`` until ``stop`` is set — or exactly once.
|
||||
|
||||
``run_once=True`` is the cron/scheduled shape (what the CLI uses by
|
||||
default). The loop shape exists for long-running deployments.
|
||||
|
||||
A failing pass is persisted before the loop continues. A worker that only
|
||||
logged to stderr would lose every failure the moment the container was
|
||||
replaced — which is precisely when someone wants to know what went wrong.
|
||||
"""
|
||||
outcomes: list[WorkerOutcome] = []
|
||||
name: str = worker_name or str(getattr(pass_fn, "__name__", "worker"))
|
||||
while True:
|
||||
try:
|
||||
outcome = await pass_fn()
|
||||
outcomes.append(outcome)
|
||||
logger.info(outcome.summary())
|
||||
for note in outcome.notes:
|
||||
logger.info("%s | %s", outcome.name, note)
|
||||
except Exception as exc: # noqa: BLE001 - a worker must survive a bad pass
|
||||
logger.exception("%s: pass failed", name)
|
||||
await _record_pass_failure(name, exc)
|
||||
outcomes.append(
|
||||
WorkerOutcome(name=name, failed=1, notes=[f"pass failed: {type(exc).__name__}"])
|
||||
)
|
||||
|
||||
if run_once:
|
||||
return outcomes
|
||||
|
||||
try:
|
||||
# Returns early (cleanly) when ``stop`` is set during the wait.
|
||||
await asyncio.wait_for(stop.wait(), timeout=interval_seconds)
|
||||
return outcomes
|
||||
except TimeoutError:
|
||||
continue
|
||||
|
||||
|
||||
async def _record_pass_failure(worker_name: str, exc: BaseException) -> None:
|
||||
"""Persist a failed pass. Imported lazily to keep this module import-light."""
|
||||
from app.domain.audit.models import ErrorSeverity, ErrorSource
|
||||
from app.infrastructure.error_log import record_error
|
||||
|
||||
await record_error(
|
||||
kind="worker_pass_failed",
|
||||
message=str(exc) or type(exc).__name__,
|
||||
exc=exc,
|
||||
source=ErrorSource.WORKER,
|
||||
severity=ErrorSeverity.ERROR,
|
||||
metadata={"worker": worker_name},
|
||||
)
|
||||
|
||||
|
||||
def install_signal_handlers(stop: asyncio.Event) -> None:
|
||||
"""Ask the event loop to set ``stop`` on SIGINT/SIGTERM.
|
||||
|
||||
Best-effort: on a platform without these signals (or outside the main
|
||||
thread) the worker simply runs until interrupted some other way.
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
for signal_name in ("SIGINT", "SIGTERM"):
|
||||
try:
|
||||
import signal as _signal
|
||||
|
||||
sig = getattr(_signal, signal_name)
|
||||
except (AttributeError, ValueError):
|
||||
continue
|
||||
try:
|
||||
loop.add_signal_handler(sig, stop.set)
|
||||
except (NotImplementedError, RuntimeError, ValueError):
|
||||
continue
|
||||
|
||||
|
||||
__all__ = [
|
||||
"WorkerOutcome",
|
||||
"install_signal_handlers",
|
||||
"run_periodically",
|
||||
"worker_session",
|
||||
]
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Billing worker — rolls the usage ledger into draft invoices.
|
||||
|
||||
The usage ledger is the source of truth for billing; an invoice is a *snapshot*
|
||||
of one closed period. That ordering matters: the worker never invents an amount
|
||||
the ledger does not already contain, and it never talks to a payment provider —
|
||||
moving money is the API's job (``BillingService.settle_payment``), not a
|
||||
background job's.
|
||||
|
||||
The one hard requirement is **idempotency**. A cron job that runs twice for the
|
||||
same period must not bill twice, so a period that already has an invoice is
|
||||
skipped rather than re-created.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from app.domain.base import utcnow
|
||||
from app.domain.billing.models import Invoice, InvoiceStatus
|
||||
from app.infrastructure.repositories.audit import UsageLedgerRepository
|
||||
from app.infrastructure.repositories.billing import InvoiceRepository
|
||||
from app.infrastructure.repositories.identity import OrganizationRepository
|
||||
from app.workers.base import WorkerOutcome, worker_session
|
||||
|
||||
#: A period shorter than this is almost certainly a bug (a stray manual run),
|
||||
#: not a real billing cycle. Refusing it keeps the invoice table meaningful.
|
||||
MIN_PERIOD = timedelta(hours=1)
|
||||
|
||||
|
||||
class BillingWorker:
|
||||
"""Turns usage into invoices, once per period, per organization."""
|
||||
|
||||
name = "billing-worker"
|
||||
|
||||
async def roll_up(
|
||||
self,
|
||||
*,
|
||||
period_start: datetime | None = None,
|
||||
period_end: datetime | None = None,
|
||||
organization_id: str | None = None,
|
||||
) -> WorkerOutcome:
|
||||
"""Create a DRAFT invoice for every organization with usage in the period.
|
||||
|
||||
``organization_id`` narrows the pass to one tenant (useful for a
|
||||
backfill or a support-driven re-run).
|
||||
"""
|
||||
end = period_end or utcnow()
|
||||
start = period_start or (end - timedelta(days=30))
|
||||
|
||||
if end <= start:
|
||||
raise ValueError("period_end must be after period_start")
|
||||
if end - start < MIN_PERIOD:
|
||||
raise ValueError(
|
||||
f"billing period must be at least {MIN_PERIOD} long"
|
||||
)
|
||||
|
||||
outcome = WorkerOutcome(name=self.name)
|
||||
|
||||
async with worker_session() as session:
|
||||
invoices = InvoiceRepository(session)
|
||||
ledger = UsageLedgerRepository(session)
|
||||
organizations = OrganizationRepository(session)
|
||||
|
||||
if organization_id is not None:
|
||||
targets = [organization_id]
|
||||
else:
|
||||
targets = [org.id for org in await organizations.list_all()]
|
||||
|
||||
for org_id in targets:
|
||||
existing = await invoices.list_for_org(org_id)
|
||||
if any(self._covers(i, start, end) for i in existing):
|
||||
outcome.skipped += 1
|
||||
continue
|
||||
|
||||
breakdown = await ledger.breakdown(
|
||||
organization_id=org_id, since=start, until=end
|
||||
)
|
||||
# Six decimals, not two: a single small model call costs a
|
||||
# fraction of a cent, and rounding to cents here would silently
|
||||
# turn real usage into a zero invoice.
|
||||
amount = round(breakdown.total_price, 6)
|
||||
if amount <= 0:
|
||||
# No billable usage: do not litter the table with $0 drafts.
|
||||
outcome.skipped += 1
|
||||
outcome.note(f"{org_id}: no billable usage in this period")
|
||||
continue
|
||||
|
||||
invoice = await invoices.create(
|
||||
Invoice(
|
||||
organization_id=org_id,
|
||||
amount=amount,
|
||||
status=InvoiceStatus.DRAFT,
|
||||
period_start=start,
|
||||
period_end=end,
|
||||
)
|
||||
)
|
||||
outcome.processed += 1
|
||||
outcome.note(f"{org_id}: {invoice.id} amount={amount}")
|
||||
|
||||
return outcome
|
||||
|
||||
@staticmethod
|
||||
def _naive_utc(value: datetime) -> datetime:
|
||||
"""Normalise a timestamp to naive UTC for comparison.
|
||||
|
||||
SQLite drops ``tzinfo`` on round-trip while Postgres keeps it, so
|
||||
comparing a freshly computed aware timestamp against one read back from
|
||||
the database raises ``TypeError`` on one backend and works on the other.
|
||||
Comparing in a single explicit frame avoids depending on which database
|
||||
the deployment happens to use.
|
||||
"""
|
||||
if value.tzinfo is None:
|
||||
return value
|
||||
return value.astimezone(UTC).replace(tzinfo=None)
|
||||
|
||||
@classmethod
|
||||
def _covers(cls, invoice: Invoice, start: datetime, end: datetime) -> bool:
|
||||
"""Is this period already invoiced?
|
||||
|
||||
Any invoice whose period *overlaps* the requested one counts. Overlap
|
||||
rather than exact equality, because two runs a few seconds apart would
|
||||
otherwise produce two near-identical invoices for the same window.
|
||||
"""
|
||||
if invoice.period_start is None or invoice.period_end is None:
|
||||
return False
|
||||
window_start, window_end = cls._naive_utc(start), cls._naive_utc(end)
|
||||
return (
|
||||
cls._naive_utc(invoice.period_start) < window_end
|
||||
and window_start < cls._naive_utc(invoice.period_end)
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["MIN_PERIOD", "BillingWorker"]
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Sandbox gateway worker — housekeeping for the only sanctioned execution path.
|
||||
|
||||
Two jobs, both about not leaking resources:
|
||||
|
||||
1. **Reap idle sessions.** A sandbox session that nobody has touched for hours
|
||||
is still consuming CPU and disk on the provider. The reaper stops it and
|
||||
writes an audit event, because "the platform killed my sandbox" must be
|
||||
answerable from the audit log.
|
||||
|
||||
2. **Report the policy in force.** The gateway is the one place where untrusted
|
||||
code runs, so the worker logs the effective network policy and the absolute
|
||||
caps at startup. An operator should be able to see the safety envelope
|
||||
without reading the source.
|
||||
|
||||
The worker never executes a command. It only stops sessions — the command path
|
||||
is ``SandboxService.run_command``, reachable from the API under a tenant check.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from app.application.container import build_sandbox_service
|
||||
from app.core.config import settings
|
||||
from app.domain.audit.models import AuditAction, AuditEvent
|
||||
from app.domain.base import utcnow
|
||||
from app.infrastructure.repositories.audit import AuditRepository
|
||||
from app.infrastructure.repositories.sandbox import WorkspaceRepository
|
||||
from app.workers.base import WorkerOutcome, worker_session
|
||||
|
||||
#: Sessions are considered abandoned after this long with no activity.
|
||||
DEFAULT_IDLE_MINUTES = 120
|
||||
|
||||
|
||||
class SandboxGatewayWorker:
|
||||
"""Stops idle sandbox sessions and records why."""
|
||||
|
||||
name = "sandbox-gateway"
|
||||
|
||||
async def reap(
|
||||
self, *, idle_minutes: int = DEFAULT_IDLE_MINUTES, limit: int = 200
|
||||
) -> WorkerOutcome:
|
||||
outcome = WorkerOutcome(name=self.name)
|
||||
idle_before = utcnow() - timedelta(minutes=max(1, idle_minutes))
|
||||
|
||||
async with worker_session() as session:
|
||||
sandbox = build_sandbox_service(session)
|
||||
workspaces = WorkspaceRepository(session)
|
||||
audit = AuditRepository(session)
|
||||
|
||||
stopped = await sandbox.reap_idle_sessions(
|
||||
idle_before=idle_before, limit=limit
|
||||
)
|
||||
|
||||
for sandbox_session in stopped:
|
||||
# Resolve the tenant for the audit trail. A session whose
|
||||
# workspace has vanished is still stopped, just recorded
|
||||
# without an organization.
|
||||
workspace = await workspaces.get(sandbox_session.workspace_id)
|
||||
await audit.record(
|
||||
event=AuditEvent(
|
||||
action=AuditAction.SANDBOX_REAP,
|
||||
organization_id=workspace.organization_id if workspace else None,
|
||||
target_type="sandbox_session",
|
||||
target_id=sandbox_session.id,
|
||||
metadata_json={
|
||||
"workspace_id": sandbox_session.workspace_id,
|
||||
"idle_minutes": idle_minutes,
|
||||
"reason": "idle_timeout",
|
||||
},
|
||||
)
|
||||
)
|
||||
outcome.processed += 1
|
||||
outcome.note(f"stopped session {sandbox_session.id}")
|
||||
|
||||
if not stopped:
|
||||
outcome.skipped += 1
|
||||
|
||||
return outcome
|
||||
|
||||
def policy_report(self) -> WorkerOutcome:
|
||||
"""Describe the safety envelope this gateway is running under."""
|
||||
outcome = WorkerOutcome(name=self.name)
|
||||
outcome.note(f"sandbox provider = {settings.sandbox_provider}")
|
||||
outcome.note(
|
||||
f"command timeout = {settings.sandbox_command_timeout_seconds}s "
|
||||
f"(absolute cap enforced in the service layer)"
|
||||
)
|
||||
outcome.note(f"max output = {settings.sandbox_max_output_bytes} bytes")
|
||||
outcome.note("egress policy default = deny_all (set per resource limit)")
|
||||
return outcome
|
||||
|
||||
|
||||
__all__ = ["DEFAULT_IDLE_MINUTES", "SandboxGatewayWorker"]
|
||||
Reference in New Issue
Block a user