نسخهٔ وب مستقل — پلتفرم نیروی کار هوش مصنوعی
مخزن اختصاصی برنامهٔ وب؛ محتوای پوشهٔ web در ریشهٔ مخزن قرار گرفت. تاریخچهٔ پروژهٔ ربات (agy-bot) در این مخزن وجود ندارد و هیچ وابستگی runtime به آن نیست.
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user