نسخهٔ وب مستقل — پلتفرم نیروی کار هوش مصنوعی
مخزن اختصاصی برنامهٔ وب؛ محتوای پوشهٔ web در ریشهٔ مخزن قرار گرفت. تاریخچهٔ پروژهٔ ربات (agy-bot) در این مخزن وجود ندارد و هیچ وابستگی runtime به آن نیست.
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
"""AI Control Plane domain: providers, credentials, models, routing, rate cards.
|
||||
|
||||
Everything here is platform-owner-only. A normal customer never receives these
|
||||
records through the API — the application layer strips them.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from enum import StrEnum
|
||||
|
||||
from app.domain.base import new_id, utcnow
|
||||
|
||||
|
||||
class ProviderType(StrEnum):
|
||||
MOCK = "mock"
|
||||
OPENAI_COMPATIBLE = "openai_compatible"
|
||||
ANTHROPIC = "anthropic"
|
||||
GEMINI = "gemini"
|
||||
OPENROUTER = "openrouter"
|
||||
|
||||
|
||||
class RoutingMode(StrEnum):
|
||||
FAST = "fast"
|
||||
CHEAP = "cheap"
|
||||
BALANCED = "balanced"
|
||||
REASONING = "reasoning"
|
||||
VISION = "vision"
|
||||
CODING = "coding"
|
||||
|
||||
|
||||
class Capability(StrEnum):
|
||||
TEXT = "text"
|
||||
VISION = "vision"
|
||||
TOOLS = "tools"
|
||||
REASONING = "reasoning"
|
||||
LONG_CONTEXT = "long_context"
|
||||
CODE = "code"
|
||||
|
||||
|
||||
class RateUnit(StrEnum):
|
||||
MODEL_INPUT_TOKENS = "model_input_tokens"
|
||||
MODEL_OUTPUT_TOKENS = "model_output_tokens"
|
||||
SANDBOX_CPU_SECOND = "sandbox_cpu_second"
|
||||
SANDBOX_MEMORY_GB_SECOND = "sandbox_memory_gb_second"
|
||||
STORAGE_GB_DAY = "storage_gb_day"
|
||||
TOOL_CALL = "tool_call"
|
||||
PREMIUM_EMPLOYEE_RUN = "premium_employee_run"
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class Provider:
|
||||
name: str
|
||||
type: ProviderType = ProviderType.MOCK
|
||||
base_url: str = ""
|
||||
id: str = field(default_factory=new_id)
|
||||
is_active: bool = True
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class ModelCredential:
|
||||
provider_id: str
|
||||
name: str
|
||||
encrypted_api_key: str
|
||||
id: str = field(default_factory=new_id)
|
||||
is_active: bool = True
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class AiModel:
|
||||
provider_id: str
|
||||
internal_name: str
|
||||
display_name_for_admin: str
|
||||
capabilities: list[str] = field(default_factory=list)
|
||||
input_price_per_1m: float = 0.0
|
||||
output_price_per_1m: float = 0.0
|
||||
id: str = field(default_factory=new_id)
|
||||
is_active: bool = True
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
|
||||
def supports(self, capability: str | Capability) -> bool:
|
||||
return str(capability) in set(self.capabilities)
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class RoutingPolicy:
|
||||
name: str
|
||||
mode: RoutingMode = RoutingMode.BALANCED
|
||||
description: str = ""
|
||||
fallback_policy_json: dict = field(default_factory=dict)
|
||||
budget_limits_json: dict = field(default_factory=dict)
|
||||
id: str = field(default_factory=new_id)
|
||||
is_default: bool = False
|
||||
is_active: bool = True
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class RateCard:
|
||||
name: str
|
||||
unit: RateUnit
|
||||
customer_price: float
|
||||
internal_cost_estimate: float = 0.0
|
||||
id: str = field(default_factory=new_id)
|
||||
is_active: bool = True
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
@@ -0,0 +1,153 @@
|
||||
"""Observability & Audit domain: the immutable trail of who did what."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
|
||||
from app.domain.base import new_id, utcnow
|
||||
|
||||
|
||||
class AuditAction:
|
||||
"""Well-known auditable actions. Free-form strings are allowed but these are
|
||||
the ones the application layer emits so far."""
|
||||
|
||||
LOGIN = "auth.login"
|
||||
LOGOUT = "auth.logout"
|
||||
SIGNUP = "auth.signup"
|
||||
ORG_CREATE = "organization.create"
|
||||
ORG_DELETE = "organization.delete"
|
||||
MEMBER_INVITE = "member.invite"
|
||||
MEMBER_ROLE_CHANGE = "member.role_change"
|
||||
MEMBER_REMOVE = "member.remove"
|
||||
EMPLOYEE_CREATE = "employee.create"
|
||||
EMPLOYEE_UPDATE = "employee.update"
|
||||
REPO_CREATE = "repo.create"
|
||||
REPO_DELETE = "repo.delete"
|
||||
DOMAIN_CREATE = "domain.create"
|
||||
DOMAIN_DELETE = "domain.delete"
|
||||
SANDBOX_COMMAND = "sandbox.command"
|
||||
SANDBOX_DESTROY = "sandbox.destroy"
|
||||
#: A platform worker stopped a sandbox session that had gone idle. System
|
||||
#: action, not a tenant one — the actor is the platform, not a user.
|
||||
SANDBOX_REAP = "sandbox.reap"
|
||||
APPROVAL_DECISION = "approval.decision"
|
||||
BILLING_CREDIT = "billing.credit"
|
||||
BILLING_PAYMENT = "billing.payment"
|
||||
PROVIDER_CHANGE = "provider.change"
|
||||
MODEL_CHANGE = "model.change"
|
||||
ROUTING_CHANGE = "routing.change"
|
||||
PROJECT_CREATE = "project.create"
|
||||
PROJECT_DELETE = "project.delete"
|
||||
TASK_CREATE = "task.create"
|
||||
RUN_CANCEL = "run.cancel"
|
||||
#: A platform owner triaged an error in the console. Auditable so "who
|
||||
#: decided this bug was fixed?" has an answer.
|
||||
ERROR_RESOLVE = "error.resolve"
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class AuditEvent:
|
||||
action: str
|
||||
organization_id: str | None = None
|
||||
actor_user_id: str | None = None
|
||||
target_type: str = ""
|
||||
target_id: str = ""
|
||||
metadata_json: dict = field(default_factory=dict)
|
||||
ip_address: str = ""
|
||||
user_agent: str = ""
|
||||
id: str = field(default_factory=new_id)
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class Trace:
|
||||
organization_id: str
|
||||
name: str = ""
|
||||
run_id: str | None = None
|
||||
id: str = field(default_factory=new_id)
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class Span:
|
||||
trace_id: str
|
||||
name: str
|
||||
kind: str = "internal"
|
||||
parent_span_id: str | None = None
|
||||
attributes_json: dict = field(default_factory=dict)
|
||||
id: str = field(default_factory=new_id)
|
||||
started_at: datetime = field(default_factory=utcnow)
|
||||
ended_at: datetime | None = None
|
||||
|
||||
@property
|
||||
def duration_ms(self) -> int | None:
|
||||
if self.ended_at is None:
|
||||
return None
|
||||
return int((self.ended_at - self.started_at).total_seconds() * 1000)
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class ErrorEvent:
|
||||
"""A persisted failure, recorded so it can be diagnosed later.
|
||||
|
||||
Deliberately *not* a domain aggregate: nothing in the business rules depends
|
||||
on it. It is an operational record, which is why it carries transport-level
|
||||
detail (method, path, status code) that no domain model would ever hold.
|
||||
"""
|
||||
|
||||
kind: str
|
||||
message: str
|
||||
organization_id: str | None = None
|
||||
run_id: str | None = None
|
||||
stack: str = ""
|
||||
metadata_json: dict = field(default_factory=dict)
|
||||
#: Where the failure happened: ``http``, ``worker`` or ``startup``.
|
||||
source: str = "http"
|
||||
#: ``error`` for anything unexpected, ``warning`` for expected 4xx noise,
|
||||
#: ``critical`` for startup/infrastructure failures.
|
||||
severity: str = "error"
|
||||
#: Exception class name (``ValueError``, ``IntegrityError``, …). Empty when
|
||||
#: the failure was not an exception.
|
||||
error_type: str = ""
|
||||
#: Stable hash of the failure's identity, so a thousand occurrences of the
|
||||
#: same bug group into one row in the console instead of a thousand.
|
||||
fingerprint: str = ""
|
||||
status_code: int | None = None
|
||||
method: str = ""
|
||||
path: str = ""
|
||||
user_id: str | None = None
|
||||
#: Correlates this record with the response the caller received and with
|
||||
#: every log line written while handling the same request.
|
||||
request_id: str = ""
|
||||
ip: str = ""
|
||||
user_agent: str = ""
|
||||
resolved_at: datetime | None = None
|
||||
resolved_note: str = ""
|
||||
id: str = field(default_factory=new_id)
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
|
||||
|
||||
class ErrorSource:
|
||||
"""Where an error was caught. Kept as strings so a new source needs no
|
||||
migration."""
|
||||
|
||||
HTTP = "http"
|
||||
WORKER = "worker"
|
||||
STARTUP = "startup"
|
||||
|
||||
|
||||
class ErrorSeverity:
|
||||
ERROR = "error"
|
||||
WARNING = "warning"
|
||||
CRITICAL = "critical"
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class QualityMetric:
|
||||
organization_id: str
|
||||
name: str
|
||||
value: float
|
||||
dimensions_json: dict = field(default_factory=dict)
|
||||
id: str = field(default_factory=new_id)
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Shared domain primitives.
|
||||
|
||||
Pure Python — no framework, no I/O. The domain layer must stay importable
|
||||
without FastAPI, SQLAlchemy or a database connection.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
_SLUG_RE = re.compile(r"[^a-z0-9]+")
|
||||
|
||||
|
||||
def new_id() -> str:
|
||||
"""Opaque, URL-safe entity id (portable across SQLite and PostgreSQL)."""
|
||||
return uuid.uuid4().hex
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
def slugify(value: str, *, max_length: int = 48) -> str:
|
||||
"""ASCII slug. Non-ASCII input (e.g. Persian) collapses to a stable fallback.
|
||||
|
||||
Persian organization names do not transliterate, so we keep a short random
|
||||
suffix appended by the caller when the slug would be empty.
|
||||
"""
|
||||
slug = _SLUG_RE.sub("-", value.strip().lower()).strip("-")
|
||||
slug = slug[:max_length].strip("-")
|
||||
if not slug:
|
||||
slug = f"x{uuid.uuid4().hex[:8]}"
|
||||
return slug
|
||||
|
||||
|
||||
def ensure_unique_slug(base: str, taken: set[str], *, max_length: int = 48) -> str:
|
||||
"""Append ``-2``, ``-3`` ... until the slug is free."""
|
||||
candidate = base[:max_length]
|
||||
if candidate not in taken:
|
||||
return candidate
|
||||
n = 2
|
||||
while True:
|
||||
suffix = f"-{n}"
|
||||
candidate = f"{base[: max_length - len(suffix)]}{suffix}"
|
||||
if candidate not in taken:
|
||||
return candidate
|
||||
n += 1
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Billing & Usage domain.
|
||||
|
||||
The internal usage ledger is the source of truth. Stripe (or any other payment
|
||||
provider) is an *adapter* that mirrors it — never the other way around.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from enum import StrEnum
|
||||
|
||||
from app.domain.ai_control.models import RateUnit
|
||||
from app.domain.base import new_id, utcnow
|
||||
|
||||
|
||||
class SubscriptionStatus(StrEnum):
|
||||
TRIALING = "trialing"
|
||||
ACTIVE = "active"
|
||||
PAST_DUE = "past_due"
|
||||
CANCELED = "canceled"
|
||||
|
||||
|
||||
class InvoiceStatus(StrEnum):
|
||||
DRAFT = "draft"
|
||||
OPEN = "open"
|
||||
PAID = "paid"
|
||||
VOID = "void"
|
||||
UNCOLLECTIBLE = "uncollectible"
|
||||
|
||||
|
||||
class PaymentStatus(StrEnum):
|
||||
PENDING = "pending"
|
||||
SUCCEEDED = "succeeded"
|
||||
FAILED = "failed"
|
||||
REFUNDED = "refunded"
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class Plan:
|
||||
name: str
|
||||
monthly_price: float = 0.0
|
||||
included_credit: float = 0.0
|
||||
features_json: dict = field(default_factory=dict)
|
||||
id: str = field(default_factory=new_id)
|
||||
is_active: bool = True
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class Subscription:
|
||||
organization_id: str
|
||||
plan_id: str
|
||||
status: SubscriptionStatus = SubscriptionStatus.ACTIVE
|
||||
external_subscription_id: str | None = None
|
||||
current_period_end: datetime | None = None
|
||||
id: str = field(default_factory=new_id)
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class CreditWallet:
|
||||
organization_id: str
|
||||
balance: float = 0.0
|
||||
currency: str = "USD"
|
||||
low_balance_threshold: float = 5.0
|
||||
id: str = field(default_factory=new_id)
|
||||
updated_at: datetime = field(default_factory=utcnow)
|
||||
|
||||
@property
|
||||
def is_low(self) -> bool:
|
||||
return self.balance <= self.low_balance_threshold
|
||||
|
||||
def can_afford(self, amount: float) -> bool:
|
||||
return self.balance >= amount
|
||||
|
||||
def debit(self, amount: float) -> None:
|
||||
self.balance -= amount
|
||||
self.updated_at = utcnow()
|
||||
|
||||
def credit(self, amount: float) -> None:
|
||||
self.balance += amount
|
||||
self.updated_at = utcnow()
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class UsageEvent:
|
||||
"""One billable unit of work. Append-only."""
|
||||
|
||||
organization_id: str
|
||||
unit: RateUnit
|
||||
quantity: float
|
||||
unit_price: float = 0.0
|
||||
internal_cost_estimate: float = 0.0
|
||||
total_price: float = 0.0
|
||||
project_id: str | None = None
|
||||
user_id: str | None = None
|
||||
employee_id: str | None = None
|
||||
run_id: str | None = None
|
||||
metadata_json: dict = field(default_factory=dict)
|
||||
id: str = field(default_factory=new_id)
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class Invoice:
|
||||
organization_id: str
|
||||
amount: float
|
||||
status: InvoiceStatus = InvoiceStatus.DRAFT
|
||||
currency: str = "USD"
|
||||
external_invoice_id: str | None = None
|
||||
period_start: datetime | None = None
|
||||
period_end: datetime | None = None
|
||||
id: str = field(default_factory=new_id)
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class Payment:
|
||||
organization_id: str
|
||||
amount: float
|
||||
status: PaymentStatus = PaymentStatus.PENDING
|
||||
provider: str = "mock"
|
||||
external_payment_id: str | None = None
|
||||
invoice_id: str | None = None
|
||||
id: str = field(default_factory=new_id)
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Identity & Access domain: users, organizations, memberships, invites."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from enum import StrEnum
|
||||
|
||||
from app.domain.base import new_id, utcnow
|
||||
|
||||
|
||||
class Role(StrEnum):
|
||||
OWNER = "owner"
|
||||
ADMIN = "admin"
|
||||
MANAGER = "manager"
|
||||
MEMBER = "member"
|
||||
VIEWER = "viewer"
|
||||
|
||||
|
||||
#: Higher rank implies every permission of the lower ranks.
|
||||
ROLE_RANK: dict[Role, int] = {
|
||||
Role.VIEWER: 10,
|
||||
Role.MEMBER: 20,
|
||||
Role.MANAGER: 30,
|
||||
Role.ADMIN: 40,
|
||||
Role.OWNER: 50,
|
||||
}
|
||||
|
||||
#: Capabilities used across the application layer.
|
||||
CAN_MANAGE_ORG = "org.manage"
|
||||
CAN_MANAGE_MEMBERS = "org.members"
|
||||
CAN_MANAGE_PROJECTS = "projects.manage"
|
||||
CAN_MANAGE_EMPLOYEES = "employees.manage"
|
||||
CAN_RUN_TASKS = "tasks.run"
|
||||
CAN_VIEW_BILLING = "billing.view"
|
||||
CAN_MANAGE_BILLING = "billing.manage"
|
||||
CAN_VIEW_AUDIT = "audit.view"
|
||||
CAN_USE_SANDBOX = "sandbox.use"
|
||||
|
||||
_ROLE_CAPABILITIES: dict[Role, set[str]] = {
|
||||
Role.VIEWER: set(),
|
||||
Role.MEMBER: {CAN_RUN_TASKS, CAN_VIEW_BILLING},
|
||||
Role.MANAGER: {
|
||||
CAN_RUN_TASKS,
|
||||
CAN_VIEW_BILLING,
|
||||
CAN_MANAGE_PROJECTS,
|
||||
CAN_MANAGE_EMPLOYEES,
|
||||
CAN_USE_SANDBOX,
|
||||
},
|
||||
Role.ADMIN: {
|
||||
CAN_RUN_TASKS,
|
||||
CAN_VIEW_BILLING,
|
||||
CAN_MANAGE_BILLING,
|
||||
CAN_MANAGE_PROJECTS,
|
||||
CAN_MANAGE_EMPLOYEES,
|
||||
CAN_USE_SANDBOX,
|
||||
CAN_MANAGE_MEMBERS,
|
||||
CAN_VIEW_AUDIT,
|
||||
},
|
||||
Role.OWNER: {
|
||||
CAN_RUN_TASKS,
|
||||
CAN_VIEW_BILLING,
|
||||
CAN_MANAGE_BILLING,
|
||||
CAN_MANAGE_PROJECTS,
|
||||
CAN_MANAGE_EMPLOYEES,
|
||||
CAN_USE_SANDBOX,
|
||||
CAN_MANAGE_MEMBERS,
|
||||
CAN_VIEW_AUDIT,
|
||||
CAN_MANAGE_ORG,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def role_has_capability(role: Role, capability: str) -> bool:
|
||||
return capability in _ROLE_CAPABILITIES.get(role, set())
|
||||
|
||||
|
||||
class MembershipStatus(StrEnum):
|
||||
ACTIVE = "active"
|
||||
INVITED = "invited"
|
||||
SUSPENDED = "suspended"
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class User:
|
||||
email: str
|
||||
password_hash: str
|
||||
name: str = ""
|
||||
id: str = field(default_factory=new_id)
|
||||
avatar_url: str | None = None
|
||||
is_platform_owner: bool = False
|
||||
is_active: bool = True
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
updated_at: datetime = field(default_factory=utcnow)
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
return self.name.strip() or self.email.split("@")[0]
|
||||
|
||||
def normalized_email(self) -> str:
|
||||
return self.email.strip().lower()
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class Organization:
|
||||
name: str
|
||||
slug: str
|
||||
owner_user_id: str
|
||||
id: str = field(default_factory=new_id)
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class Membership:
|
||||
organization_id: str
|
||||
user_id: str
|
||||
role: Role = Role.MEMBER
|
||||
status: MembershipStatus = MembershipStatus.ACTIVE
|
||||
id: str = field(default_factory=new_id)
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
|
||||
@property
|
||||
def is_active(self) -> bool:
|
||||
return self.status == MembershipStatus.ACTIVE
|
||||
|
||||
def has(self, capability: str) -> bool:
|
||||
return self.is_active and role_has_capability(self.role, capability)
|
||||
|
||||
def outranks(self, other_role: Role) -> bool:
|
||||
return ROLE_RANK[self.role] > ROLE_RANK[other_role]
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class Invite:
|
||||
organization_id: str
|
||||
email: str
|
||||
role: Role
|
||||
token_hash: str
|
||||
expires_at: datetime
|
||||
id: str = field(default_factory=new_id)
|
||||
invited_by_user_id: str | None = None
|
||||
accepted_at: datetime | None = None
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
|
||||
def is_expired(self, now: datetime | None = None) -> bool:
|
||||
return (now or utcnow()) >= self.expires_at
|
||||
|
||||
def is_pending(self, now: datetime | None = None) -> bool:
|
||||
return self.accepted_at is None and not self.is_expired(now)
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Knowledge domain: sources, documents, chunks and retrieval policy.
|
||||
|
||||
Retrieval is permission-aware: an employee only sees knowledge scoped to its
|
||||
organization (and optionally its project).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from enum import StrEnum
|
||||
|
||||
from app.domain.base import new_id, utcnow
|
||||
|
||||
|
||||
class SourceKind(StrEnum):
|
||||
FILE = "file"
|
||||
TEXT = "text"
|
||||
URL = "url"
|
||||
REPO = "repo"
|
||||
|
||||
|
||||
class EmbeddingJobStatus(StrEnum):
|
||||
PENDING = "pending"
|
||||
RUNNING = "running"
|
||||
DONE = "done"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class KnowledgeSource:
|
||||
organization_id: str
|
||||
name: str
|
||||
kind: SourceKind = SourceKind.TEXT
|
||||
uri: str = ""
|
||||
project_id: str | None = None
|
||||
id: str = field(default_factory=new_id)
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class Document:
|
||||
source_id: str
|
||||
title: str
|
||||
content: str = ""
|
||||
id: str = field(default_factory=new_id)
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class DocumentChunk:
|
||||
document_id: str
|
||||
index: int
|
||||
content: str
|
||||
embedding_json: list[float] = field(default_factory=list)
|
||||
id: str = field(default_factory=new_id)
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class EmbeddingJob:
|
||||
source_id: str
|
||||
status: EmbeddingJobStatus = EmbeddingJobStatus.PENDING
|
||||
error: str | None = None
|
||||
id: str = field(default_factory=new_id)
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class RetrievalPolicy:
|
||||
organization_id: str
|
||||
name: str
|
||||
scope_json: dict = field(default_factory=dict)
|
||||
id: str = field(default_factory=new_id)
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Platform-wide validation rules carried over from the existing infrastructure.
|
||||
|
||||
These are pure functions with no I/O, so both the application layer *and* the
|
||||
adapters can enforce them. Defence in depth: a rule that is only checked in one
|
||||
place is a rule that will eventually be bypassed.
|
||||
|
||||
Reference: ``docs/current-infra-notes.md`` §1.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
DEFAULT_ROOT_DOMAIN = "msa.artacloud.ir"
|
||||
|
||||
#: Ports that are never allocatable (system services + data stores).
|
||||
SYSTEM_RESERVED_PORTS: frozenset[int] = frozenset(
|
||||
{22, 53, 80, 443, 2019, 3000, 5432, 6379, 8008}
|
||||
)
|
||||
|
||||
#: Ports of internal platform services. A tenant preview must never proxy to
|
||||
#: these, otherwise a public hostname would expose another component.
|
||||
SYSTEM_PROTECTED_PORTS: frozenset[int] = (
|
||||
frozenset({3000, 35555, 35556, 35557}) | SYSTEM_RESERVED_PORTS
|
||||
)
|
||||
|
||||
DEFAULT_PORT_RANGE_START = 8080
|
||||
DEFAULT_PORT_RANGE_END = 8999
|
||||
|
||||
LABEL_MIN = 3
|
||||
LABEL_MAX = 40
|
||||
_LABEL_RE = re.compile(r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?$")
|
||||
_PROXY_TARGET_RE = re.compile(r"^(https?://)?(localhost|127\.0\.0\.1):(\d{1,5})$")
|
||||
|
||||
|
||||
class RuleViolation(ValueError):
|
||||
"""Raised when a request violates a platform safety rule."""
|
||||
|
||||
|
||||
def validate_subdomain_label(label: str) -> str:
|
||||
"""Normalise and validate a single DNS label.
|
||||
|
||||
Only ``a-z0-9-``, must start and end alphanumeric, 3-40 characters.
|
||||
"""
|
||||
clean = (label or "").strip().lower()
|
||||
if not (LABEL_MIN <= len(clean) <= LABEL_MAX):
|
||||
raise RuleViolation(
|
||||
f"نام زیردامنه باید بین {LABEL_MIN} تا {LABEL_MAX} کاراکتر باشد."
|
||||
)
|
||||
if not _LABEL_RE.match(clean):
|
||||
raise RuleViolation("نام زیردامنه فقط میتواند شامل حروف کوچک، عدد و خط تیره باشد.")
|
||||
return clean
|
||||
|
||||
|
||||
def build_host(label: str, root_domain: str = DEFAULT_ROOT_DOMAIN) -> str:
|
||||
return f"{validate_subdomain_label(label)}.{root_domain.strip().lower()}"
|
||||
|
||||
|
||||
def validate_managed_host(host: str, root_domain: str = DEFAULT_ROOT_DOMAIN) -> str:
|
||||
"""Validate a fully-qualified host against the managed wildcard domain.
|
||||
|
||||
Rejects anything that is not a single label under the root domain — this is
|
||||
the anti-injection check that keeps a crafted host from breaking out of a
|
||||
Caddy site block or pointing at an arbitrary host.
|
||||
"""
|
||||
clean = (host or "").strip().lower()
|
||||
suffix = f".{root_domain.strip().lower()}"
|
||||
if not clean.endswith(suffix):
|
||||
raise RuleViolation(f"دامنه باید زیرمجموعهٔ «{root_domain}» باشد.")
|
||||
label = clean[: -len(suffix)]
|
||||
if "." in label:
|
||||
raise RuleViolation("فقط یک برچسب (single label) مجاز است.")
|
||||
validate_subdomain_label(label)
|
||||
return clean
|
||||
|
||||
|
||||
def validate_proxy_target(target: str) -> tuple[str, int]:
|
||||
"""Ensure the reverse-proxy target is a loopback address with a numeric port.
|
||||
|
||||
Blocks SSRF: a tenant must not be able to proxy a public hostname to an
|
||||
internal service.
|
||||
"""
|
||||
clean = (target or "").strip()
|
||||
match = _PROXY_TARGET_RE.match(clean)
|
||||
if not match:
|
||||
raise RuleViolation(
|
||||
"مقصد reverse proxy باید localhost یا 127.0.0.1 با پورت عددی باشد."
|
||||
)
|
||||
port = int(match.group(3))
|
||||
if not (1 <= port <= 65535):
|
||||
raise RuleViolation("شمارهٔ پورت نامعتبر است.")
|
||||
return match.group(2), port
|
||||
|
||||
|
||||
def is_port_allocatable(
|
||||
port: int,
|
||||
*,
|
||||
start: int = DEFAULT_PORT_RANGE_START,
|
||||
end: int = DEFAULT_PORT_RANGE_END,
|
||||
) -> bool:
|
||||
if port in SYSTEM_RESERVED_PORTS:
|
||||
return False
|
||||
return start <= port <= end
|
||||
|
||||
|
||||
def assert_port_allocatable(
|
||||
port: int,
|
||||
*,
|
||||
start: int = DEFAULT_PORT_RANGE_START,
|
||||
end: int = DEFAULT_PORT_RANGE_END,
|
||||
) -> int:
|
||||
if not is_port_allocatable(port, start=start, end=end):
|
||||
raise RuleViolation(f"پورت {port} قابل تخصیص نیست.")
|
||||
return port
|
||||
|
||||
|
||||
def assert_not_protected_port(port: int) -> int:
|
||||
"""A preview domain must never point at an internal platform port."""
|
||||
if port in SYSTEM_PROTECTED_PORTS:
|
||||
raise RuleViolation(f"اتصال به پورت محافظتشدهٔ {port} ممنوع است.")
|
||||
return port
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Projects domain: projects, their Git repositories and per-project credentials."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
|
||||
from app.domain.base import new_id, utcnow
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class Project:
|
||||
organization_id: str
|
||||
name: str
|
||||
slug: str
|
||||
description: str = ""
|
||||
created_by_user_id: str | None = None
|
||||
default_branch: str = "main"
|
||||
id: str = field(default_factory=new_id)
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class ProjectGitRepo:
|
||||
"""The Git repository attached to a project.
|
||||
|
||||
Named ``ProjectGitRepo`` rather than ``ProjectRepository`` on purpose: the
|
||||
latter collided with the *infrastructure* repository that manages ``Project``
|
||||
rows, and the shadowing silently broke every Git endpoint at runtime.
|
||||
"""
|
||||
|
||||
project_id: str
|
||||
provider: str = "gitea"
|
||||
owner: str = ""
|
||||
repo_name: str = ""
|
||||
clone_url: str = ""
|
||||
web_url: str = ""
|
||||
default_branch: str = "main"
|
||||
id: str = field(default_factory=new_id)
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
|
||||
@property
|
||||
def full_name(self) -> str:
|
||||
return f"{self.owner}/{self.repo_name}"
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class ProjectGitCredential:
|
||||
"""Per-project Git identity. The password is encrypted at rest and never
|
||||
returned to the frontend."""
|
||||
|
||||
project_id: str
|
||||
username: str
|
||||
encrypted_password: str
|
||||
id: str = field(default_factory=new_id)
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Sandbox domain: templates, workspaces, sessions, commands, snapshots, limits.
|
||||
|
||||
The host machine must never execute tenant code. Every command flows through a
|
||||
SandboxProviderPort adapter and is bounded by a timeout and an output cap.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from enum import StrEnum
|
||||
|
||||
from app.domain.base import new_id, utcnow
|
||||
|
||||
|
||||
class SandboxProviderKind(StrEnum):
|
||||
MOCK = "mock"
|
||||
DAYTONA = "daytona"
|
||||
E2B = "e2b"
|
||||
|
||||
|
||||
class WorkspaceStatus(StrEnum):
|
||||
PENDING = "pending"
|
||||
READY = "ready"
|
||||
STOPPED = "stopped"
|
||||
DESTROYED = "destroyed"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class SessionStatus(StrEnum):
|
||||
STARTING = "starting"
|
||||
RUNNING = "running"
|
||||
PAUSED = "paused"
|
||||
STOPPED = "stopped"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class NetworkPolicy(StrEnum):
|
||||
DENY_ALL = "deny_all"
|
||||
ALLOWLIST = "allowlist"
|
||||
OPEN = "open"
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class SandboxTemplate:
|
||||
name: str
|
||||
provider: str = SandboxProviderKind.MOCK
|
||||
image: str = "python:3.13-slim"
|
||||
description: str = ""
|
||||
default_cpu: float = 1.0
|
||||
default_memory_mb: int = 1024
|
||||
default_disk_mb: int = 2048
|
||||
id: str = field(default_factory=new_id)
|
||||
is_system: bool = True
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class Workspace:
|
||||
organization_id: str
|
||||
project_id: str
|
||||
template_id: str
|
||||
provider: str = SandboxProviderKind.MOCK
|
||||
external_workspace_id: str | None = None
|
||||
status: WorkspaceStatus = WorkspaceStatus.PENDING
|
||||
id: str = field(default_factory=new_id)
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class SandboxSession:
|
||||
workspace_id: str
|
||||
external_session_id: str | None = None
|
||||
status: SessionStatus = SessionStatus.STARTING
|
||||
id: str = field(default_factory=new_id)
|
||||
started_at: datetime = field(default_factory=utcnow)
|
||||
stopped_at: datetime | None = None
|
||||
last_active_at: datetime = field(default_factory=utcnow)
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class CommandRun:
|
||||
session_id: str
|
||||
command: str
|
||||
exit_code: int | None = None
|
||||
stdout_ref: str = ""
|
||||
stderr_ref: str = ""
|
||||
duration_ms: int = 0
|
||||
truncated: bool = False
|
||||
id: str = field(default_factory=new_id)
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
|
||||
@property
|
||||
def succeeded(self) -> bool:
|
||||
return self.exit_code == 0
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class Snapshot:
|
||||
workspace_id: str
|
||||
name: str
|
||||
external_snapshot_id: str | None = None
|
||||
id: str = field(default_factory=new_id)
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class PreviewUrl:
|
||||
workspace_id: str
|
||||
port: int
|
||||
url: str
|
||||
id: str = field(default_factory=new_id)
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class SandboxResourceLimit:
|
||||
"""Limits are stored in policy, never trusted from the caller."""
|
||||
|
||||
cpu: float = 1.0
|
||||
memory_mb: int = 1024
|
||||
disk_mb: int = 2048
|
||||
timeout_seconds: int = 120
|
||||
max_output_bytes: int = 200_000
|
||||
network_policy: NetworkPolicy = NetworkPolicy.DENY_ALL
|
||||
scope: str = "organization"
|
||||
scope_id: str | None = None
|
||||
id: str = field(default_factory=new_id)
|
||||
@@ -0,0 +1,252 @@
|
||||
"""Agent Workforce domain: employees, chat threads, tasks, runs and artifacts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from enum import StrEnum
|
||||
|
||||
from app.domain.base import new_id, utcnow
|
||||
from app.domain.identity.models import Role
|
||||
|
||||
|
||||
class EmployeeStatus(StrEnum):
|
||||
ACTIVE = "active"
|
||||
PAUSED = "paused"
|
||||
ARCHIVED = "archived"
|
||||
|
||||
|
||||
class RoleKey(StrEnum):
|
||||
MANAGER = "manager"
|
||||
PLANNER = "planner"
|
||||
DEVELOPER = "developer"
|
||||
REVIEWER = "reviewer"
|
||||
TESTER = "tester"
|
||||
RESEARCHER = "researcher"
|
||||
CONTENT_CREATOR = "content_creator"
|
||||
SUPPORT = "support"
|
||||
|
||||
|
||||
class TaskStatus(StrEnum):
|
||||
NEW = "new"
|
||||
TRIAGED = "triaged"
|
||||
WAITING_APPROVAL = "waiting_approval"
|
||||
RUNNING = "running"
|
||||
DONE = "done"
|
||||
FAILED = "failed"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
class RunStatus(StrEnum):
|
||||
QUEUED = "queued"
|
||||
PLANNING = "planning"
|
||||
WAITING_APPROVAL = "waiting_approval"
|
||||
RUNNING = "running"
|
||||
TOOL_CALLING = "tool_calling"
|
||||
SANDBOX_RUNNING = "sandbox_running"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
CANCELLED = "cancelled"
|
||||
TIMED_OUT = "timed_out"
|
||||
|
||||
|
||||
TERMINAL_RUN_STATUSES: frozenset[RunStatus] = frozenset(
|
||||
{RunStatus.COMPLETED, RunStatus.FAILED, RunStatus.CANCELLED, RunStatus.TIMED_OUT}
|
||||
)
|
||||
TERMINAL_TASK_STATUSES: frozenset[TaskStatus] = frozenset(
|
||||
{TaskStatus.DONE, TaskStatus.FAILED, TaskStatus.CANCELLED}
|
||||
)
|
||||
|
||||
#: Allowed run state transitions. Anything else raises in the application layer.
|
||||
RUN_TRANSITIONS: dict[RunStatus, frozenset[RunStatus]] = {
|
||||
RunStatus.QUEUED: frozenset({RunStatus.PLANNING, RunStatus.CANCELLED, RunStatus.FAILED}),
|
||||
RunStatus.PLANNING: frozenset(
|
||||
{RunStatus.RUNNING, RunStatus.WAITING_APPROVAL, RunStatus.FAILED, RunStatus.CANCELLED}
|
||||
),
|
||||
RunStatus.WAITING_APPROVAL: frozenset(
|
||||
{RunStatus.RUNNING, RunStatus.CANCELLED, RunStatus.FAILED, RunStatus.TIMED_OUT}
|
||||
),
|
||||
RunStatus.RUNNING: frozenset(
|
||||
{
|
||||
RunStatus.TOOL_CALLING,
|
||||
RunStatus.SANDBOX_RUNNING,
|
||||
RunStatus.WAITING_APPROVAL,
|
||||
RunStatus.COMPLETED,
|
||||
RunStatus.FAILED,
|
||||
RunStatus.CANCELLED,
|
||||
RunStatus.TIMED_OUT,
|
||||
}
|
||||
),
|
||||
RunStatus.TOOL_CALLING: frozenset(
|
||||
{RunStatus.RUNNING, RunStatus.WAITING_APPROVAL, RunStatus.FAILED, RunStatus.TIMED_OUT}
|
||||
),
|
||||
RunStatus.SANDBOX_RUNNING: frozenset(
|
||||
{RunStatus.RUNNING, RunStatus.FAILED, RunStatus.CANCELLED, RunStatus.TIMED_OUT}
|
||||
),
|
||||
RunStatus.COMPLETED: frozenset(),
|
||||
RunStatus.FAILED: frozenset(),
|
||||
RunStatus.CANCELLED: frozenset(),
|
||||
RunStatus.TIMED_OUT: frozenset(),
|
||||
}
|
||||
|
||||
|
||||
def can_transition_run(current: RunStatus, target: RunStatus) -> bool:
|
||||
return target in RUN_TRANSITIONS.get(current, frozenset())
|
||||
|
||||
|
||||
class MessageRole(StrEnum):
|
||||
USER = "user"
|
||||
ASSISTANT = "assistant"
|
||||
SYSTEM = "system"
|
||||
|
||||
|
||||
class ToolCallStatus(StrEnum):
|
||||
PENDING = "pending"
|
||||
AWAITING_APPROVAL = "awaiting_approval"
|
||||
APPROVED = "approved"
|
||||
REJECTED = "rejected"
|
||||
SUCCEEDED = "succeeded"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class AgentTemplate:
|
||||
name: str
|
||||
role_key: RoleKey
|
||||
description: str = ""
|
||||
default_prompt: str = ""
|
||||
default_tools_json: list[str] = field(default_factory=list)
|
||||
default_routing_policy_id: str | None = None
|
||||
id: str = field(default_factory=new_id)
|
||||
is_system: bool = True
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class Employee:
|
||||
organization_id: str
|
||||
name: str
|
||||
role_key: RoleKey
|
||||
template_id: str | None = None
|
||||
project_id: str | None = None
|
||||
status: EmployeeStatus = EmployeeStatus.ACTIVE
|
||||
routing_policy_id: str | None = None
|
||||
budget_limit_json: dict = field(default_factory=dict)
|
||||
id: str = field(default_factory=new_id)
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
|
||||
@property
|
||||
def is_available(self) -> bool:
|
||||
return self.status == EmployeeStatus.ACTIVE
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class PromptVersion:
|
||||
employee_id: str
|
||||
version: int
|
||||
content: str
|
||||
created_by_user_id: str | None = None
|
||||
id: str = field(default_factory=new_id)
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class EmployeeToolPermission:
|
||||
employee_id: str
|
||||
tool_key: str
|
||||
allowed: bool = False
|
||||
policy_json: dict = field(default_factory=dict)
|
||||
id: str = field(default_factory=new_id)
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class Thread:
|
||||
organization_id: str
|
||||
title: str = ""
|
||||
project_id: str | None = None
|
||||
created_by_user_id: str | None = None
|
||||
id: str = field(default_factory=new_id)
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class Message:
|
||||
thread_id: str
|
||||
role: MessageRole
|
||||
content: str
|
||||
employee_id: str | None = None
|
||||
id: str = field(default_factory=new_id)
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class Task:
|
||||
organization_id: str
|
||||
title: str
|
||||
description: str = ""
|
||||
project_id: str | None = None
|
||||
thread_id: str | None = None
|
||||
assigned_employee_id: str | None = None
|
||||
created_by_user_id: str | None = None
|
||||
status: TaskStatus = TaskStatus.NEW
|
||||
id: str = field(default_factory=new_id)
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
updated_at: datetime = field(default_factory=utcnow)
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class Run:
|
||||
task_id: str
|
||||
employee_id: str | None = None
|
||||
status: RunStatus = RunStatus.QUEUED
|
||||
error: str | None = None
|
||||
id: str = field(default_factory=new_id)
|
||||
started_at: datetime | None = None
|
||||
finished_at: datetime | None = None
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
|
||||
@property
|
||||
def is_terminal(self) -> bool:
|
||||
return self.status in TERMINAL_RUN_STATUSES
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class RunStep:
|
||||
run_id: str
|
||||
index: int
|
||||
kind: str
|
||||
title: str = ""
|
||||
payload_json: dict = field(default_factory=dict)
|
||||
id: str = field(default_factory=new_id)
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class ToolCall:
|
||||
run_id: str
|
||||
tool_key: str
|
||||
arguments_json: dict = field(default_factory=dict)
|
||||
result_json: dict = field(default_factory=dict)
|
||||
status: ToolCallStatus = ToolCallStatus.PENDING
|
||||
requires_approval: bool = False
|
||||
approved_by_user_id: str | None = None
|
||||
id: str = field(default_factory=new_id)
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class Artifact:
|
||||
organization_id: str
|
||||
name: str
|
||||
kind: str = "file"
|
||||
content_ref: str = ""
|
||||
project_id: str | None = None
|
||||
run_id: str | None = None
|
||||
size_bytes: int = 0
|
||||
id: str = field(default_factory=new_id)
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
|
||||
|
||||
#: Roles that may only be created by an org owner/admin.
|
||||
ELEVATED_ROLE_KEYS: frozenset[RoleKey] = frozenset({RoleKey.MANAGER})
|
||||
DEFAULT_EMPLOYEE_ROLE: Role = Role.MEMBER
|
||||
@@ -0,0 +1,169 @@
|
||||
"""Tool catalog for AI employees.
|
||||
|
||||
Three rules are encoded here as data rather than as scattered ``if`` statements:
|
||||
|
||||
1. Every tool has a stable key and an explicit approval requirement.
|
||||
2. Dangerous tools require human approval **before** they run — the flag lives
|
||||
with the tool, so a new call site cannot forget it.
|
||||
3. Nothing is allowed by default; an employee needs an explicit permission row.
|
||||
|
||||
This module is pure: no I/O, no framework, no database.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
class ToolKey:
|
||||
"""Stable tool identifiers. These appear in audit logs and permissions."""
|
||||
|
||||
GIT_ENSURE_REPO = "git.ensure_repo"
|
||||
GIT_CREATE_BRANCH = "git.create_branch"
|
||||
GIT_OPEN_PR = "git.open_pr"
|
||||
GIT_MERGE_PR = "git.merge_pr"
|
||||
GIT_DELETE_REPO = "git.delete_repo"
|
||||
DOMAIN_CREATE_PREVIEW = "domain.create_preview"
|
||||
DOMAIN_DELETE_PREVIEW = "domain.delete_preview"
|
||||
SANDBOX_RUN_COMMAND = "sandbox.run_command"
|
||||
SANDBOX_READ_FILE = "sandbox.read_file"
|
||||
SANDBOX_WRITE_FILE = "sandbox.write_file"
|
||||
SANDBOX_DELETE_FILE = "sandbox.delete_file"
|
||||
SANDBOX_DESTROY = "sandbox.destroy"
|
||||
KNOWLEDGE_SEARCH = "knowledge.search"
|
||||
ARTIFACT_CREATE = "artifact.create"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ToolSpec:
|
||||
key: str
|
||||
label: str
|
||||
description: str
|
||||
#: Human approval is required before this tool may execute.
|
||||
requires_approval: bool = False
|
||||
#: Irreversible or destructive: never auto-approved, always audited.
|
||||
destructive: bool = False
|
||||
#: Tools that touch the outside world are the ones worth approving.
|
||||
category: str = "general"
|
||||
|
||||
|
||||
TOOL_CATALOG: tuple[ToolSpec, ...] = (
|
||||
ToolSpec(
|
||||
key=ToolKey.GIT_ENSURE_REPO,
|
||||
label="ساخت مخزن",
|
||||
description="ساخت مخزن خصوصی Git برای پروژه.",
|
||||
category="git",
|
||||
),
|
||||
ToolSpec(
|
||||
key=ToolKey.GIT_CREATE_BRANCH,
|
||||
label="ساخت شاخه",
|
||||
description="ساخت یک شاخهٔ جدید از شاخهٔ مشخص.",
|
||||
category="git",
|
||||
),
|
||||
ToolSpec(
|
||||
key=ToolKey.GIT_OPEN_PR,
|
||||
label="باز کردن درخواست ادغام",
|
||||
description="ثبت یک PR از شاخهٔ کاری به شاخهٔ پایه.",
|
||||
category="git",
|
||||
),
|
||||
ToolSpec(
|
||||
key=ToolKey.GIT_MERGE_PR,
|
||||
label="ادغام درخواست ادغام",
|
||||
description="ادغام PR در شاخهٔ پایه. بازگشتناپذیر.",
|
||||
requires_approval=True,
|
||||
destructive=True,
|
||||
category="git",
|
||||
),
|
||||
ToolSpec(
|
||||
key=ToolKey.GIT_DELETE_REPO,
|
||||
label="حذف مخزن",
|
||||
description="حذف کامل مخزن پروژه. بازگشتناپذیر.",
|
||||
requires_approval=True,
|
||||
destructive=True,
|
||||
category="git",
|
||||
),
|
||||
ToolSpec(
|
||||
key=ToolKey.DOMAIN_CREATE_PREVIEW,
|
||||
label="ساخت دامنهٔ تست",
|
||||
description="انتشار موقت پروژه روی یک زیردامنه.",
|
||||
category="domain",
|
||||
),
|
||||
ToolSpec(
|
||||
key=ToolKey.DOMAIN_DELETE_PREVIEW,
|
||||
label="حذف دامنهٔ تست",
|
||||
description="برداشتن دامنهٔ تست از دسترس.",
|
||||
requires_approval=True,
|
||||
category="domain",
|
||||
),
|
||||
ToolSpec(
|
||||
key=ToolKey.SANDBOX_RUN_COMMAND,
|
||||
label="اجرای دستور در سندباکس",
|
||||
description="اجرای یک دستور داخل محیط ایزوله با محدودیت زمان و خروجی.",
|
||||
category="sandbox",
|
||||
),
|
||||
ToolSpec(
|
||||
key=ToolKey.SANDBOX_READ_FILE,
|
||||
label="خواندن فایل",
|
||||
description="خواندن محتوای یک فایل در سندباکس.",
|
||||
category="sandbox",
|
||||
),
|
||||
ToolSpec(
|
||||
key=ToolKey.SANDBOX_WRITE_FILE,
|
||||
label="نوشتن فایل",
|
||||
description="نوشتن یا بازنویسی یک فایل در سندباکس.",
|
||||
category="sandbox",
|
||||
),
|
||||
ToolSpec(
|
||||
key=ToolKey.SANDBOX_DELETE_FILE,
|
||||
label="حذف فایل",
|
||||
description="حذف یک فایل از سندباکس. بازگشتناپذیر.",
|
||||
requires_approval=True,
|
||||
destructive=True,
|
||||
category="sandbox",
|
||||
),
|
||||
ToolSpec(
|
||||
key=ToolKey.SANDBOX_DESTROY,
|
||||
label="نابودی سندباکس",
|
||||
description="پایان دادن به نشست سندباکس و آزادسازی منابع.",
|
||||
requires_approval=True,
|
||||
destructive=True,
|
||||
category="sandbox",
|
||||
),
|
||||
ToolSpec(
|
||||
key=ToolKey.KNOWLEDGE_SEARCH,
|
||||
label="جستوجو در دانش",
|
||||
description="جستوجو در پایگاه دانش سازمان.",
|
||||
category="knowledge",
|
||||
),
|
||||
ToolSpec(
|
||||
key=ToolKey.ARTIFACT_CREATE,
|
||||
label="ساخت خروجی",
|
||||
description="ثبت یک سند یا خروجی قابل مشاهده برای کاربر.",
|
||||
category="artifact",
|
||||
),
|
||||
)
|
||||
|
||||
_BY_KEY: dict[str, ToolSpec] = {spec.key: spec for spec in TOOL_CATALOG}
|
||||
|
||||
#: Templates that reference an unknown tool key would silently grant nothing;
|
||||
#: this set lets a test assert the seed data and the catalog agree.
|
||||
KNOWN_TOOL_KEYS: frozenset[str] = frozenset(_BY_KEY)
|
||||
|
||||
|
||||
def get_tool_spec(key: str) -> ToolSpec | None:
|
||||
return _BY_KEY.get(key)
|
||||
|
||||
|
||||
def is_known_tool(key: str) -> bool:
|
||||
return key in _BY_KEY
|
||||
|
||||
|
||||
def tool_requires_approval(key: str) -> bool:
|
||||
"""Unknown tools are treated as requiring approval, never as safe."""
|
||||
spec = _BY_KEY.get(key)
|
||||
return True if spec is None else spec.requires_approval
|
||||
|
||||
|
||||
def is_destructive(key: str) -> bool:
|
||||
spec = _BY_KEY.get(key)
|
||||
return False if spec is None else spec.destructive
|
||||
Reference in New Issue
Block a user