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