نسخهٔ وب مستقل — پلتفرم نیروی کار هوش مصنوعی
مخزن اختصاصی برنامهٔ وب؛ محتوای پوشهٔ web در ریشهٔ مخزن قرار گرفت. تاریخچهٔ پروژهٔ ربات (agy-bot) در این مخزن وجود ندارد و هیچ وابستگی runtime به آن نیست.
This commit is contained in:
@@ -0,0 +1,520 @@
|
||||
"""Mock adapters — every port has one so the stack runs with zero credentials.
|
||||
|
||||
These are not toys: they enforce the same contracts (timeouts, output caps,
|
||||
validation) as the real adapters, so switching a ``*_PROVIDER`` env var does not
|
||||
change application behaviour.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import secrets
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Iterable
|
||||
from typing import Any
|
||||
|
||||
from app.application.ports import (
|
||||
BranchInfo,
|
||||
ChatMessage,
|
||||
CheckoutSession,
|
||||
CommandResult,
|
||||
CommitInfo,
|
||||
GitCredentials,
|
||||
MergeResult,
|
||||
ModelHealth,
|
||||
ModelResponse,
|
||||
Notification,
|
||||
PaymentResult,
|
||||
PreviewDomainInfo,
|
||||
PullRequestInfo,
|
||||
RepoInfo,
|
||||
RepoStatus,
|
||||
ReverseProxyResult,
|
||||
SessionInfo,
|
||||
SnapshotInfo,
|
||||
StoredObject,
|
||||
VectorHit,
|
||||
WorkspaceInfo,
|
||||
)
|
||||
from app.domain.ai_control.models import AiModel
|
||||
from app.domain.platform.rules import (
|
||||
assert_port_allocatable,
|
||||
build_host,
|
||||
validate_managed_host,
|
||||
validate_proxy_target,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Model
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class MockModelProviderAdapter:
|
||||
"""Deterministic, offline stand-in for an LLM.
|
||||
|
||||
Returns a canned Persian acknowledgement that names the task so tests can
|
||||
assert on it without a network call.
|
||||
"""
|
||||
|
||||
def __init__(self, latency_ms: int = 0) -> None:
|
||||
self._latency_ms = latency_ms
|
||||
|
||||
async def complete(
|
||||
self,
|
||||
*,
|
||||
model: AiModel,
|
||||
credential=None,
|
||||
messages: list[ChatMessage],
|
||||
system: str = "",
|
||||
temperature: float = 0.2,
|
||||
max_tokens: int = 2048,
|
||||
) -> ModelResponse:
|
||||
started = time.perf_counter()
|
||||
if self._latency_ms:
|
||||
await asyncio.sleep(self._latency_ms / 1000)
|
||||
last_user = next((m.content for m in reversed(messages) if m.role == "user"), "")
|
||||
prompt_tokens = sum(len(m.content.split()) for m in messages)
|
||||
# The canned text must read like model output, not like a debug tag: the
|
||||
# provider/model identity is platform-internal and must never surface in
|
||||
# anything a customer can read (see ``test_run_payload_never_leaks_model_identity``).
|
||||
content = (
|
||||
"این پاسخ آزمایشی است. "
|
||||
f"درخواست دریافتی: {last_user[:280] or '—'}"
|
||||
)
|
||||
return ModelResponse(
|
||||
content=content,
|
||||
model_internal_name=model.internal_name,
|
||||
input_tokens=prompt_tokens + len(system.split()),
|
||||
output_tokens=len(content.split()),
|
||||
latency_ms=int((time.perf_counter() - started) * 1000),
|
||||
raw={"mock": True},
|
||||
)
|
||||
|
||||
async def health(self) -> ModelHealth:
|
||||
return ModelHealth(ok=True, detail="mock provider")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Git
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class MockGitAdapter:
|
||||
"""In-memory Git provider. Nothing touches a real forge.
|
||||
|
||||
``owner`` / ``repo_name`` are accepted for interface parity with the Gitea
|
||||
adapter but ignored here — this adapter keys everything by ``project_id``.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._repos: dict[str, RepoInfo] = {}
|
||||
self._credentials: dict[str, GitCredentials] = {}
|
||||
self._branches: dict[str, dict[str, str]] = {}
|
||||
self._prs: dict[str, list[PullRequestInfo]] = {}
|
||||
|
||||
async def ensure_project_repo(
|
||||
self, *, project_id: str, repo_name: str, description: str = ""
|
||||
) -> RepoInfo:
|
||||
if project_id not in self._repos:
|
||||
self._repos[project_id] = RepoInfo(
|
||||
owner="mock",
|
||||
repo_name=repo_name,
|
||||
clone_url=f"https://git.example.invalid/mock/{repo_name}.git",
|
||||
web_url=f"https://git.example.invalid/mock/{repo_name}",
|
||||
default_branch="main",
|
||||
private=True,
|
||||
)
|
||||
self._branches[project_id] = {"main": hashlib.sha1(b"init").hexdigest()[:40]}
|
||||
self._prs[project_id] = []
|
||||
return self._repos[project_id]
|
||||
|
||||
async def ensure_credentials(
|
||||
self, *, project_id: str, owner: str = "", repo_name: str = ""
|
||||
) -> GitCredentials:
|
||||
if project_id not in self._credentials:
|
||||
self._credentials[project_id] = GitCredentials(
|
||||
username=f"proj_{project_id[:8]}",
|
||||
password=secrets.token_urlsafe(12),
|
||||
)
|
||||
return self._credentials[project_id]
|
||||
|
||||
async def create_branch(
|
||||
self,
|
||||
*,
|
||||
project_id: str,
|
||||
branch_name: str,
|
||||
from_branch: str,
|
||||
owner: str = "",
|
||||
repo_name: str = "",
|
||||
) -> BranchInfo:
|
||||
branches = self._branches.setdefault(project_id, {"main": "0" * 40})
|
||||
base = branches.get(from_branch, "0" * 40)
|
||||
sha = hashlib.sha1(f"{base}:{branch_name}".encode()).hexdigest()[:40]
|
||||
branches[branch_name] = sha
|
||||
return BranchInfo(name=branch_name, sha=sha)
|
||||
|
||||
async def commit_files(
|
||||
self,
|
||||
*,
|
||||
project_id: str,
|
||||
branch_name: str,
|
||||
files: dict[str, str],
|
||||
message: str,
|
||||
owner: str = "",
|
||||
repo_name: str = "",
|
||||
) -> CommitInfo:
|
||||
digest = hashlib.sha1(
|
||||
f"{branch_name}:{message}:{sorted(files)}".encode()
|
||||
).hexdigest()[:40]
|
||||
self._branches.setdefault(project_id, {})[branch_name] = digest
|
||||
return CommitInfo(
|
||||
sha=digest, branch=branch_name, message=message, files_changed=len(files)
|
||||
)
|
||||
|
||||
async def open_pull_request(
|
||||
self,
|
||||
*,
|
||||
project_id: str,
|
||||
head: str,
|
||||
base: str,
|
||||
title: str,
|
||||
body: str = "",
|
||||
owner: str = "",
|
||||
repo_name: str = "",
|
||||
) -> PullRequestInfo:
|
||||
prs = self._prs.setdefault(project_id, [])
|
||||
number = len(prs) + 1
|
||||
repo = self._repos.get(project_id)
|
||||
web = repo.web_url if repo else "https://git.example.invalid/mock"
|
||||
pr = PullRequestInfo(number=number, url=f"{web}/pulls/{number}", state="open")
|
||||
prs.append(pr)
|
||||
return pr
|
||||
|
||||
async def merge_pull_request(
|
||||
self, *, project_id: str, pr_number: int, owner: str = "", repo_name: str = ""
|
||||
) -> MergeResult:
|
||||
prs = self._prs.setdefault(project_id, [])
|
||||
for pr in prs:
|
||||
if pr.number == pr_number:
|
||||
pr.state = "merged"
|
||||
return MergeResult(merged=True, message="ادغام شد (mock).")
|
||||
return MergeResult(merged=False, message="PR یافت نشد.")
|
||||
|
||||
async def get_repo_status(
|
||||
self, *, project_id: str, owner: str = "", repo_name: str = ""
|
||||
) -> RepoStatus:
|
||||
repo = self._repos.get(project_id)
|
||||
if repo is None:
|
||||
return RepoStatus(exists=False)
|
||||
return RepoStatus(
|
||||
exists=True,
|
||||
private=repo.private,
|
||||
default_branch=repo.default_branch,
|
||||
branches=sorted(self._branches.get(project_id, {}).keys()),
|
||||
open_pull_requests=sum(
|
||||
1 for pr in self._prs.get(project_id, []) if pr.state == "open"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Domain router
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class MockDomainRouterAdapter:
|
||||
"""In-memory port + subdomain ledger.
|
||||
|
||||
Applies the *same* validation rules as the real Caddy adapter, so tests that
|
||||
pass here describe behaviour the production adapter also guarantees.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
root_domain: str = "msa.artacloud.ir",
|
||||
port_start: int = 8080,
|
||||
port_end: int = 8999,
|
||||
) -> None:
|
||||
self.root_domain = root_domain
|
||||
self.port_start = port_start
|
||||
self.port_end = port_end
|
||||
self._ports: dict[tuple[str, str], int] = {}
|
||||
self._domains: dict[str, PreviewDomainInfo] = {}
|
||||
self._project_domains: dict[str, list[str]] = {}
|
||||
|
||||
async def allocate_port(
|
||||
self, *, organization_id: str, project_id: str, service_name: str
|
||||
) -> int:
|
||||
key = (project_id, service_name)
|
||||
if key in self._ports:
|
||||
return self._ports[key]
|
||||
used = set(self._ports.values())
|
||||
for port in range(self.port_start, self.port_end + 1):
|
||||
if port in used:
|
||||
continue
|
||||
try:
|
||||
assert_port_allocatable(port, start=self.port_start, end=self.port_end)
|
||||
except Exception:
|
||||
continue
|
||||
self._ports[key] = port
|
||||
return port
|
||||
raise RuntimeError("No available ports in the configured range.")
|
||||
|
||||
async def claim_preview_domain(
|
||||
self,
|
||||
*,
|
||||
organization_id: str,
|
||||
project_id: str,
|
||||
requested_subdomain: str,
|
||||
port: int,
|
||||
) -> PreviewDomainInfo:
|
||||
host = build_host(requested_subdomain, self.root_domain)
|
||||
if host in self._domains:
|
||||
owner_domains = self._project_domains.get(project_id, [])
|
||||
if host not in owner_domains:
|
||||
raise ValueError(f"دامنهٔ {host} قبلاً ثبت شده است.")
|
||||
info = PreviewDomainInfo(
|
||||
host=host, port=port, url=f"https://{host}", status="active"
|
||||
)
|
||||
self._domains[host] = info
|
||||
self._project_domains.setdefault(project_id, []).append(host)
|
||||
return info
|
||||
|
||||
async def configure_reverse_proxy(self, *, host: str, port: int) -> ReverseProxyResult:
|
||||
validate_managed_host(host, self.root_domain)
|
||||
validate_proxy_target(f"localhost:{port}")
|
||||
return ReverseProxyResult(ok=True, message="route ثبت شد (mock).", url=f"https://{host}")
|
||||
|
||||
async def remove_preview_domain(self, *, domain_id: str) -> None:
|
||||
for host, info in list(self._domains.items()):
|
||||
if info.host == domain_id or domain_id in self._project_domains.get(domain_id, []):
|
||||
self._domains.pop(host, None)
|
||||
self._domains.pop(domain_id, None)
|
||||
for hosts in self._project_domains.values():
|
||||
if domain_id in hosts:
|
||||
hosts.remove(domain_id)
|
||||
|
||||
async def list_project_domains(self, *, project_id: str) -> list[PreviewDomainInfo]:
|
||||
return [
|
||||
self._domains[h]
|
||||
for h in self._project_domains.get(project_id, [])
|
||||
if h in self._domains
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sandbox
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class LocalMockSandboxAdapter:
|
||||
"""Executes nothing. Returns plausible results and records commands.
|
||||
|
||||
``run_command`` still honours the timeout and output cap contract so callers
|
||||
cannot depend on behaviour the real adapters will not provide.
|
||||
"""
|
||||
|
||||
def __init__(self, max_output_bytes: int = 200_000) -> None:
|
||||
self._workspaces: dict[str, str] = {}
|
||||
self._sessions: dict[str, str] = {}
|
||||
self._files: dict[str, dict[str, str]] = {}
|
||||
self.commands: list[str] = []
|
||||
self.max_output_bytes = max_output_bytes
|
||||
|
||||
async def create_workspace(
|
||||
self, *, organization_id: str, project_id: str, template_id: str, image: str
|
||||
) -> WorkspaceInfo:
|
||||
external_id = f"ws_{uuid.uuid4().hex[:12]}"
|
||||
self._workspaces[external_id] = image
|
||||
return WorkspaceInfo(external_id=external_id, status="ready", raw={"image": image})
|
||||
|
||||
async def start_session(self, *, workspace_external_id: str) -> SessionInfo:
|
||||
external_id = f"ses_{uuid.uuid4().hex[:12]}"
|
||||
self._sessions[external_id] = workspace_external_id
|
||||
self._files.setdefault(external_id, {})
|
||||
return SessionInfo(external_id=external_id, status="running")
|
||||
|
||||
async def run_command(
|
||||
self,
|
||||
*,
|
||||
session_external_id: str,
|
||||
command: str,
|
||||
timeout_seconds: int,
|
||||
max_output_bytes: int | None = None,
|
||||
env: dict[str, str] | None = None,
|
||||
) -> CommandResult:
|
||||
limit = max_output_bytes or self.max_output_bytes
|
||||
started = time.perf_counter()
|
||||
self.commands.append(command)
|
||||
stdout = f"[mock] اجرا شد: {command}"
|
||||
truncated = len(stdout.encode()) > limit
|
||||
if truncated:
|
||||
stdout = stdout.encode()[:limit].decode(errors="ignore")
|
||||
return CommandResult(
|
||||
exit_code=0,
|
||||
stdout=stdout,
|
||||
stderr="",
|
||||
duration_ms=max(1, int((time.perf_counter() - started) * 1000)),
|
||||
truncated=truncated,
|
||||
)
|
||||
|
||||
async def read_file(self, *, session_external_id: str, path: str) -> str:
|
||||
return self._files.get(session_external_id, {}).get(path, "")
|
||||
|
||||
async def write_file(self, *, session_external_id: str, path: str, content: str) -> None:
|
||||
self._files.setdefault(session_external_id, {})[path] = content
|
||||
|
||||
async def snapshot(self, *, session_external_id: str, name: str) -> SnapshotInfo:
|
||||
return SnapshotInfo(external_id=f"snap_{uuid.uuid4().hex[:12]}", name=name)
|
||||
|
||||
async def pause(self, *, session_external_id: str) -> None:
|
||||
return None
|
||||
|
||||
async def resume(self, *, session_external_id: str) -> SessionInfo:
|
||||
return SessionInfo(external_id=session_external_id, status="running")
|
||||
|
||||
async def destroy(self, *, session_external_id: str) -> None:
|
||||
self._sessions.pop(session_external_id, None)
|
||||
self._files.pop(session_external_id, None)
|
||||
|
||||
async def get_preview_url(self, *, session_external_id: str, port: int) -> str:
|
||||
return f"http://127.0.0.1:{port}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Payment
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class LocalMockPaymentAdapter:
|
||||
async def create_checkout(
|
||||
self,
|
||||
*,
|
||||
organization_id: str,
|
||||
amount: float,
|
||||
currency: str,
|
||||
success_url: str,
|
||||
cancel_url: str,
|
||||
) -> CheckoutSession:
|
||||
external_id = f"mock_cs_{uuid.uuid4().hex[:12]}"
|
||||
return CheckoutSession(
|
||||
external_id=external_id,
|
||||
url=f"{success_url}?mock_checkout={external_id}",
|
||||
amount=amount,
|
||||
currency=currency,
|
||||
)
|
||||
|
||||
async def refund(
|
||||
self, *, external_payment_id: str, amount: float | None = None
|
||||
) -> PaymentResult:
|
||||
return PaymentResult(ok=True, external_id=external_payment_id, message="بازگشت (mock).")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Storage / vector / notification
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class InMemoryStorageAdapter:
|
||||
def __init__(self) -> None:
|
||||
self._objects: dict[str, bytes] = {}
|
||||
|
||||
async def put(self, *, key: str, data: bytes, content_type: str = "") -> StoredObject:
|
||||
self._objects[key] = data
|
||||
return StoredObject(key=key, size_bytes=len(data), url=f"memory://{key}")
|
||||
|
||||
async def get(self, *, key: str) -> bytes:
|
||||
return self._objects.get(key, b"")
|
||||
|
||||
async def delete(self, *, key: str) -> None:
|
||||
self._objects.pop(key, None)
|
||||
|
||||
async def signed_url(self, *, key: str, expires_seconds: int = 900) -> str:
|
||||
return f"memory://{key}?expires={expires_seconds}"
|
||||
|
||||
|
||||
class InMemoryVectorStoreAdapter:
|
||||
"""Keyword-overlap search. Good enough for MVP; swappable for pgvector."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._chunks: dict[str, tuple[str, str, str, dict]] = {}
|
||||
|
||||
async def upsert(
|
||||
self,
|
||||
*,
|
||||
organization_id: str,
|
||||
chunks: Iterable[tuple[str, str, list[float]]],
|
||||
metadata: dict | None = None,
|
||||
) -> int:
|
||||
count = 0
|
||||
for chunk_id, content, _embedding in chunks:
|
||||
document_id = str((metadata or {}).get("document_id", ""))
|
||||
self._chunks[chunk_id] = (organization_id, document_id, content, metadata or {})
|
||||
count += 1
|
||||
return count
|
||||
|
||||
async def search(
|
||||
self,
|
||||
*,
|
||||
organization_id: str,
|
||||
query: str,
|
||||
limit: int = 5,
|
||||
project_id: str | None = None,
|
||||
) -> list[VectorHit]:
|
||||
terms = {t for t in query.lower().split() if t}
|
||||
scored: list[VectorHit] = []
|
||||
for chunk_id, (org, document_id, content, meta) in self._chunks.items():
|
||||
if org != organization_id:
|
||||
continue
|
||||
if project_id and meta.get("project_id") not in (None, project_id):
|
||||
continue
|
||||
overlap = len(terms & set(content.lower().split()))
|
||||
if overlap:
|
||||
scored.append(
|
||||
VectorHit(
|
||||
chunk_id=chunk_id,
|
||||
document_id=document_id,
|
||||
content=content,
|
||||
score=float(overlap),
|
||||
metadata=meta,
|
||||
)
|
||||
)
|
||||
scored.sort(key=lambda h: h.score, reverse=True)
|
||||
return scored[:limit]
|
||||
|
||||
async def delete_document(self, *, document_id: str) -> None:
|
||||
for chunk_id, (_, doc_id, _, _) in list(self._chunks.items()):
|
||||
if doc_id == document_id:
|
||||
self._chunks.pop(chunk_id, None)
|
||||
|
||||
|
||||
class ConsoleNotificationAdapter:
|
||||
"""Records notifications instead of sending them."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.sent: list[Notification] = []
|
||||
|
||||
async def send(self, *, notification: Notification) -> bool:
|
||||
self.sent.append(notification)
|
||||
return True
|
||||
|
||||
|
||||
class InMemoryAuditAdapter:
|
||||
"""In-memory AuditPort, used by unit tests that do not want a database."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
# Records whatever the caller hands over; the port is untyped here.
|
||||
self.events: list[Any] = []
|
||||
|
||||
async def record(self, *, event):
|
||||
self.events.append(event)
|
||||
return event
|
||||
|
||||
async def list_for_organization(
|
||||
self, *, organization_id: str, limit: int = 100, offset: int = 0
|
||||
):
|
||||
matching = [e for e in self.events if e.organization_id == organization_id]
|
||||
return matching[offset : offset + limit]
|
||||
Reference in New Issue
Block a user